From a8deac9ea3a1efc1993da4ab250f47f17438c6e0 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 10 Jun 2026 16:08:26 +0800 Subject: [PATCH 01/32] docs(rfc): vp migrate upgrade path for existing Vite+ projects Proposes extending vp migrate to repair projects migrated by older Vite+ versions after the @voidzero-dev/vite-plus-test wrapper removal (#1588): state-based detection of stale wrapper aliases, an upgrade-fixups registry, per-package-manager override reconciliation, and a global-CLI preflight to avoid the local-first delegation chicken-and-egg. --- rfcs/migrate-upgrade-path.md | 239 +++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 rfcs/migrate-upgrade-path.md diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md new file mode 100644 index 0000000000..887621abcb --- /dev/null +++ b/rfcs/migrate-upgrade-path.md @@ -0,0 +1,239 @@ +# RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects + +- Status: Draft (for discussion) +- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) +- Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` + +## Background + +PR #1588 deletes the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. New migrations write a different dependency shape: `vitest` and nine `@vitest/*` internals are pinned to the bundled `VITEST_VERSION` in the package-manager override mechanism, instead of aliasing `vitest` to the wrapper. + +Every project migrated **before** #1588 carries the old shape on disk. Per package manager: + +| Package manager | Location | Stale entry | +| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- | +| pnpm | `pnpm-workspace.yaml` `catalog` (and named `catalogs`) | `vitest: npm:@voidzero-dev/vite-plus-test@latest` (or pinned) | +| pnpm (existing `pnpm` config) | `package.json` `pnpm.overrides` | same alias | +| npm | `package.json` `overrides` | `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` | +| bun | `package.json` `overrides` / `workspaces.catalog` | same alias | +| yarn | `package.json` `resolutions` | same alias | +| all | dependency fields (`devDependencies` etc.) | `vitest` aliased to the wrapper in some setups | +| all | lockfile | resolved `@voidzero-dev/vite-plus-test` entries | + +Old projects also **lack** entries the new shape requires: + +- The nine `@vitest/*` override/catalog pins (`@vitest/expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`, `coverage-v8`, `coverage-istanbul`). +- The expanded pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` for those packages). +- The pnpm `allowBuilds` entries for browser-provider drivers. + +### What breaks if we do nothing + +The wrapper stays published on npm (existing versions are immutable), so installs do not hard-fail. The failure modes are quieter and worse: + +1. **Permanently stale vitest.** The wrapper receives no further releases. The override forces every `vitest` in the tree, including the one `vite-plus` itself depends on, to the last wrapper version. Users never receive vitest updates or security fixes again, regardless of how often they update `vite-plus`. +2. **Mixed vitest copies.** The unpinned `@vitest/*` internals resolve to the newest 4.x while `vitest` is pinned to the old wrapper. Two physical vitest module graphs is the classic source of mock-hoisting and internal-state bugs. +3. **Peer conflicts.** New `vite-plus` ships `@vitest/browser-*` providers with exact `vitest` peers. The override forces a non-matching version into the tree. +4. **Dead-end update advice.** `docs/guide/upgrade.md` previously told users to run `vp update @voidzero-dev/vite-plus-test`, which now updates to a package that will never move. + +#1588 already adds `pruneLegacyWrapperAliases` / `pruneYamlMapLegacyWrapperAliases` sweeps in `migrator.ts`, but they only run inside the **full migration** path (`rewritePackageJson` / `rewriteConfigs`). When a project already has `vite-plus` as a dependency, `bin.ts` takes the early-return path (`packages/cli/src/migration/bin.ts`, "Early return if already using Vite+") which only offers ESLint, Prettier, git hooks, baseUrl, and node-version migrations. The stale override shape is never touched. That gap is what this RFC closes. + +## Goals + +1. `vp migrate` on a project that already uses Vite+ detects state written by older Vite+ versions and repairs it automatically. +2. The first (and motivating) repair: replace stale `@voidzero-dev/vite-plus-test` aliases with the upstream-vitest shape and add the missing `@vitest/*` pins, across all four package managers, in standalone projects and monorepos. +3. Establish a small, ordered **upgrade-fixups registry** so future breaking changes in the managed dependency shape get the same treatment without redesigning the flow. +4. Idempotent: re-running `vp migrate` on an already-repaired project is a no-op. +5. Conservative: user-authored specs that are not wrapper aliases are preserved (same stance as #1588's prune sweeps). + +## Non-Goals + +- Changing the behavior of `vp migrate` for projects that do not have `vite-plus` yet (the full-migration path already handles stale aliases after #1588). +- A general project codemod system for arbitrary user code. Import rewrites from the original migration are not re-run; `vite-plus/test*` remains the stable public API, so no source files need to change. +- Replacing `vp update` / `vp outdated`. Those remain the way to bump versions day to day; `vp migrate` repairs **shape**, not routine version bumps. + +## Design + +### 1. Detection: state-based, not version-based + +There is no reliable marker recording which Vite+ version performed the original migration, and config files may have been hand-edited since. Detection therefore inspects the actual state: + +- Scan `package.json` (`overrides`, `resolutions`, `pnpm.overrides`, `workspaces.catalog(s)`, dependency fields) and `pnpm-workspace.yaml` (`catalog`, `catalogs`, `overrides`) for specs matching `npm:@voidzero-dev/vite-plus-test` or `npm:@voidzero-dev/vite-plus-test@*` (reuse `isLegacyWrapperSpec` from #1588). +- Independently, detect a **vp-managed override block** (identified by the `vite: npm:@voidzero-dev/vite-plus-core@...` alias) that is missing keys from the current `VITE_PLUS_OVERRIDE_PACKAGES`. This catches projects where someone hand-removed the wrapper alias but still lacks the `@vitest/*` pins. + +Either signal marks the project as needing the fixup. Detection is cheap (file reads, no network, no install). + +### 2. Upgrade-fixups registry + +A new module `packages/cli/src/migration/upgrade-fixups.ts`: + +```ts +interface UpgradeFixup { + /** Stable id, e.g. 'vitest-wrapper-removal' */ + id: string; + /** One-line description shown in the prompt and the summary */ + summary: string; + /** Cheap, read-only check against the workspace */ + detect(workspace: WorkspaceInfo): boolean; + /** Mutates config files; returns what changed for the report */ + apply(workspace: WorkspaceInfo, report: MigrationReport): Promise; +} + +export const UPGRADE_FIXUPS: UpgradeFixup[] = [vitestWrapperRemovalFixup]; +``` + +The already-using-Vite+ path in `bin.ts` runs `detect()` for each registered fixup, prompts once for the batch (see UX below), applies them in order, and triggers a single reinstall if any fixup mutated files. Future breaking changes (for example, if the `vite` alias shape ever changes) append a new entry instead of growing ad-hoc branches. + +### 3. Fixup #1: vitest wrapper removal + +`apply()` reuses the #1588 machinery rather than introducing new rewrite logic: + +1. **Prune wrapper aliases** everywhere they can appear, via `pruneLegacyWrapperAliases` (JSON records: `overrides`, `resolutions`, `pnpm.overrides`, dependency fields, bun `workspaces.catalog(s)`) and `pruneYamlMapLegacyWrapperAliases` (pnpm-workspace.yaml `catalog`, named `catalogs`, `overrides`). `vitest` keys are rewritten to `VITEST_VERSION` so existing `catalog:` references keep resolving; other wrapper-targeted keys are dropped. +2. **Reconcile the managed block to the canonical shape**: for the override mechanism the project already uses, ensure every key in `VITE_PLUS_OVERRIDE_PACKAGES` is present with the canonical value, and extend pnpm `peerDependencyRules` / `allowBuilds` the same way the full migration writes them (reuse the existing per-package-manager writers in `migrator.ts`). Existing keys whose value is a user-authored, non-wrapper spec are left alone and reported as a warning instead of being overwritten. +3. **Walk workspace packages** in monorepos: each package's `package.json` dependency fields get the same prune (mirrors the #1588 sweep at the dependency-field level). + +Before/after, pnpm monorepo (`pnpm-workspace.yaml`): + +```yaml +# before (written by older vp migrate) +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + vite-plus: latest +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: [vite, vitest] + allowedVersions: { vite: '*', vitest: '*' } + +# after +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: 4.1.7 + '@vitest/expect': 4.1.7 + # ... runner, snapshot, spy, utils, mocker, pretty-format, coverage-v8, coverage-istanbul + vite-plus: latest +allowBuilds: + edgedriver: false + geckodriver: false +overrides: + vite: 'catalog:' + vitest: 'catalog:' + '@vitest/expect': 'catalog:' + # ... same set +peerDependencyRules: + allowAny: [vite, vitest, '@vitest/expect', ...] + allowedVersions: { vite: '*', vitest: '*', '@vitest/expect': '*', ... } +``` + +Before/after, npm/bun standalone (`package.json`): + +```jsonc +// before +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" +} + +// after +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.7", + "@vitest/expect": "4.1.7" + // ... same set +} +``` + +yarn `resolutions` follows the npm shape. + +### 4. Bumping `vite-plus` itself + +The repaired shape pins `vitest` to the `VITEST_VERSION` baked into the CLI performing the migration. That pin is only correct if the project's `vite-plus` version bundles the same vitest. The fixup therefore also normalizes the `vite-plus` spec: + +- If the spec is a dist-tag (`latest`) or a range satisfied by the CLI's own version, leave it; the reinstall resolves it forward. +- Otherwise (older pinned version), update it to the migrating CLI's version, the same way the full migration pins `vite-plus` today (`catalog:` in monorepos, explicit version standalone). + +This keeps `vite-plus` and the vitest pins in lockstep by construction, because the executing CLI writes both from its own constants. + +### 5. Install and verification + +If any fixup mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` from #1588 so failures surface as warnings and a non-zero exit code. After install, verify the lockfile contains zero `@voidzero-dev/vite-plus-test` references; if any remain (for example, a transitive dependency the prune could not reach), emit a warning with the offending lockfile keys. + +### 6. Command routing: the chicken-and-egg problem + +`vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`): if the project has a local `vite-plus`, its (old) migration code runs, which knows nothing about the new shape. Meanwhile the user cannot cleanly get the new local `vite-plus` first, because installing it under the stale overrides produces the mixed-vitest state described above. + +The global `vp` binary is the natural escape hatch: users keep it current via `vp upgrade`, independent of any project. Proposal: + +**Global preflight (recommended).** Before delegating `migrate`, the global CLI runs the cheap stale-state scan itself (or always routes `migrate` for already-Vite+ projects through the global JS CLI). When stale wrapper state is detected, the **global** CLI's migration code executes the fixups, then proceeds with the existing partial migrations. Since the global JS CLI is the same `vite-plus` package at the global version, `VITEST_VERSION` and the writers are automatically consistent. + +Alternative routings are listed under Open Questions. + +### 7. UX + +Interactive: + +``` +$ vp migrate +│ This project already uses Vite+. +│ Detected configuration written by an older Vite+ version: +│ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper +◆ Upgrade the Vite+ dependency setup? +│ Rewrites catalog/overrides to upstream vitest 4.1.7, updates vite-plus, reinstalls. +│ ● Yes / ○ No +``` + +- One prompt for the whole fixup batch, not one per fixup; the bullet list names each detected fixup via its `summary`. +- `--no-interactive` applies the fixups (declining would leave the project broken-by-default; this matches migrate's existing convention of applying safe defaults). Declining interactively prints the manual steps and continues with the other partial migrations. +- The migration summary gains a section, fed by `MigrationReport`: + +``` +Upgraded Vite+ dependency setup + rewrote 2 stale vitest aliases (pnpm-workspace.yaml, packages/app/package.json) + added 9 @vitest/* pins + vite-plus: 0.6.0 -> 0.9.0 +``` + +### 8. Idempotency + +After a successful run, `detect()` returns false for every fixup (no wrapper aliases, no missing keys), so a re-run takes the existing "already using Vite+, happy coding" path. Fixups must be written so that partial failure (e.g. install failed after files were rewritten) is recoverable by simply re-running `vp migrate`. + +## Code Touchpoints + +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/migration/upgrade-fixups.ts` (new) | Fixup interface, registry, vitest-wrapper fixup | +| `packages/cli/src/migration/bin.ts` | Already-Vite+ path: run detection, prompt, apply, fold into the existing single-reinstall logic | +| `packages/cli/src/migration/migrator.ts` | Export/reshape `pruneLegacyWrapperAliases`, `pruneYamlMapLegacyWrapperAliases`, and the per-PM override writers so the fixup can call them outside the full-migration flow | +| `packages/cli/src/migration/report.ts` | `upgradeFixups` entries (id, counts, version bump) | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Routing change per the preflight decision | +| `docs/guide/upgrade.md` | New section: upgrading projects migrated by older Vite+ (`vp migrate` repairs the setup) | +| `docs/guide/migrate.md` | Note that running migrate on an existing Vite+ project also repairs older setups | + +## Testing Plan + +- **Unit** (`packages/cli/src/migration/__tests__/upgrade-fixups.spec.ts`): detection and apply for each stale shape: pnpm catalog + named catalogs, `pnpm.overrides` in package.json, npm/bun `overrides`, bun `workspaces.catalog`, yarn `resolutions`, wrapper aliases in dependency fields, pinned wrapper versions (`npm:@voidzero-dev/vite-plus-test@4.0.5`), hand-edited blocks missing only `@vitest/*` keys, user-authored `vitest: ^4.0.0` ranges preserved with warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): new fixtures whose inputs are committed old-shape projects, e.g. `migration-upgrade-stale-vitest-pnpm`, `-npm`, `-yarn`, `-bun`, `migration-upgrade-monorepo-catalog`, plus an idempotency fixture that runs `vp migrate` twice and snapshots the second run's "already using Vite+" output. Fixture inputs must be committed files, not generated by ignored local state. +- **E2E**: take a project migrated by the last pre-#1588 release, run new `vp migrate`, assert `vp test` passes and the lockfile has zero wrapper references. + +## Rollout and Complementary Actions + +1. Land after #1588 merges and ships in the same release if possible, so the first release without the wrapper is also the first that can repair old projects. +2. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp migrate' to update your project"` so users who never run migrate still get a pointer at install time. +3. Release notes and `docs/guide/upgrade.md` call out the one-command repair: `vp upgrade && vp migrate`. + +## Alternatives Considered + +- **Auto-heal in `vp install`**: detect stale aliases on every install and fix silently. Rejected as the primary mechanism: install should not rewrite config files unprompted, and the migration machinery (prompts, report, per-PM writers) already lives in migrate. A lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). +- **Hook into `vp update vite-plus`**: reconcile overrides whenever the vite-plus spec is bumped. More magical, splits migration logic across commands, and misses users who edit package.json by hand. The install-time warning covers discovery instead. +- **Version-marker file** (e.g. recording the migrating Vite+ version) to drive upgrade steps by version range. Rejected: state-based detection is robust to hand-edits and requires no new artifact in user repos. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler routing than a preflight, and arguably correct since migrate is a toolchain-level operation like `create`, but it changes behavior for users who intentionally pin a local version. Kept as an option in Open Question 1. + +## Open Questions + +1. **Routing**: global preflight scan (recommended) vs. always routing `migrate` through the global CLI for already-Vite+ projects? The preflight keeps local-first semantics for everything else but adds a Rust-side (or pre-delegation JS) scan; always-global is simpler but a behavior change. +2. Should `vp install` (and/or `vp doctor`-style checks, `vp outdated`) **warn** when stale wrapper aliases are present, pointing at `vp migrate`? This is the main discovery mechanism for users who do not think to run migrate again. +3. When the fixup finds a **user-authored `vitest` range** (not a wrapper alias) inside an otherwise vp-managed override block, should we still add the `@vitest/*` pins (risking a mixed tree against their chosen vitest) or skip the whole block with a warning? Current proposal: add nothing, warn, and explain the risk. +4. Should declining the fixup interactively be allowed to proceed with the other partial migrations (current proposal), or should migrate stop early since the project is in a known-broken state? +5. Is bumping the `vite-plus` spec to the migrating CLI's version acceptable in non-interactive mode, or should non-interactive runs require an explicit `--upgrade` flag the first time? (CI running `vp migrate --no-interactive` would otherwise get an unattended dependency bump.) +6. Do we want `vp migrate --check` (detection only, exit code signals drift) for CI, mirroring `vp upgrade --check`? From 52e03ee9411a747c4f96da271d92e88d3da5138c Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 09:08:55 +0800 Subject: [PATCH 02/32] docs(rfc): cover real 0.1.24->0.2.0 upgrade failure in vp migrate Rewrite the upgrade-path RFC against merged #1588. Correct the stale pre-merge claims (only vitest is pinned, not nine @vitest/* packages; VITEST_VERSION 4.1.9; age-gate exemption; coverage providers are peer-managed) and document what #1588 already ships (detectVitePlusBootstrapPending / ensureVitePlusBootstrap). Center the real failure found in node-modules/urllib: vp migrate did not upgrade 0.1.24 to 0.2.0. Three compounding blockers, plus coverage skew: - local-first delegation runs the stale 0.1.24 CLI, which writes pnpm-workspace.yaml overrides pinning vite/vitest to ^0.1.24 and the deleted wrapper, actively blocking later upgrades - vp update vite-plus --latest does not reconcile those pins, and the behind core alias (core@^0.1.24) is not caught by the wrapper prune - an empty pkg.pnpm ({}) misroutes the v0.2.0 detector/bootstrap so the real pnpm-workspace.yaml overrides are never repaired - @vitest/coverage-v8 stays at the old version; only a runtime guard warns Add design for routing escalation to the global CLI, behind-alias and empty-pnpm repair, coverage-provider alignment, a urllib-shaped fixture, and open questions to fix the misrouting and the release-notes flow. --- rfcs/migrate-upgrade-path.md | 335 ++++++++++++++++++----------------- 1 file changed, 176 insertions(+), 159 deletions(-) diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md index 887621abcb..f87e466f99 100644 --- a/rfcs/migrate-upgrade-path.md +++ b/rfcs/migrate-upgrade-path.md @@ -1,239 +1,256 @@ # RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects - Status: Draft (for discussion) -- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) +- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) - Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` ## Background -PR #1588 deletes the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. New migrations write a different dependency shape: `vitest` and nine `@vitest/*` internals are pinned to the bundled `VITEST_VERSION` in the package-manager override mechanism, instead of aliasing `vitest` to the wrapper. +PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. The managed dependency shape it writes is: -Every project migrated **before** #1588 carries the old shape on disk. Per package manager: +- `vite` stays aliased to `npm:@voidzero-dev/vite-plus-core@latest` (unchanged). +- `vitest` is pinned to the bundled `VITEST_VERSION` (currently `4.1.9`, in `packages/cli/src/utils/constants.ts`). The `@vitest/*` runtime family (`expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`) are EXACT dependencies of `vitest` itself, so a single `vitest` override cascades one consistent version to the whole tree. They are deliberately NOT pinned individually. +- The package-manager age gate gets `VITEST_AGE_GATE_EXEMPT_PACKAGES = ['vitest', '@vitest/*']` added (pnpm `minimumReleaseAgeExclude` / Yarn `npmPreapprovedPackages`) so the freshly published pinned version is not quarantined. +- Coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) are NOT managed at all: they are peer deps the project installs and versions itself. A runtime guard in `packages/cli/src/define-config.ts` fail-fasts when an installed provider's version skews from the bundled vitest (Vitest otherwise silently runs mixed versions and yields unreliable coverage). -| Package manager | Location | Stale entry | -| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- | -| pnpm | `pnpm-workspace.yaml` `catalog` (and named `catalogs`) | `vitest: npm:@voidzero-dev/vite-plus-test@latest` (or pinned) | -| pnpm (existing `pnpm` config) | `package.json` `pnpm.overrides` | same alias | -| npm | `package.json` `overrides` | `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` | -| bun | `package.json` `overrides` / `workspaces.catalog` | same alias | -| yarn | `package.json` `resolutions` | same alias | -| all | dependency fields (`devDependencies` etc.) | `vitest` aliased to the wrapper in some setups | -| all | lockfile | resolved `@voidzero-dev/vite-plus-test` entries | +So the canonical v0.2.0 shape, pnpm monorepo (`pnpm-workspace.yaml`): -Old projects also **lack** entries the new shape requires: - -- The nine `@vitest/*` override/catalog pins (`@vitest/expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`, `coverage-v8`, `coverage-istanbul`). -- The expanded pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` for those packages). -- The pnpm `allowBuilds` entries for browser-provider drivers. +```yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: 4.1.9 + vite-plus: latest +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: [vite, vitest] + allowedVersions: { vite: '*', vitest: '*' } +minimumReleaseAgeExclude: + - vite-plus + - '@voidzero-dev/*' + # ... oxlint/oxfmt families ... + - vitest + - '@vitest/*' +``` -### What breaks if we do nothing +npm/bun standalone (`package.json`): -The wrapper stays published on npm (existing versions are immutable), so installs do not hard-fail. The failure modes are quieter and worse: +```jsonc +"overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.9" +} +``` -1. **Permanently stale vitest.** The wrapper receives no further releases. The override forces every `vitest` in the tree, including the one `vite-plus` itself depends on, to the last wrapper version. Users never receive vitest updates or security fixes again, regardless of how often they update `vite-plus`. -2. **Mixed vitest copies.** The unpinned `@vitest/*` internals resolve to the newest 4.x while `vitest` is pinned to the old wrapper. Two physical vitest module graphs is the classic source of mock-hoisting and internal-state bugs. -3. **Peer conflicts.** New `vite-plus` ships `@vitest/browser-*` providers with exact `vitest` peers. The override forces a non-matching version into the tree. -4. **Dead-end update advice.** `docs/guide/upgrade.md` previously told users to run `vp update @voidzero-dev/vite-plus-test`, which now updates to a package that will never move. +### What #1588 already handles -#1588 already adds `pruneLegacyWrapperAliases` / `pruneYamlMapLegacyWrapperAliases` sweeps in `migrator.ts`, but they only run inside the **full migration** path (`rewritePackageJson` / `rewriteConfigs`). When a project already has `vite-plus` as a dependency, `bin.ts` takes the early-return path (`packages/cli/src/migration/bin.ts`, "Early return if already using Vite+") which only offers ESLint, Prettier, git hooks, baseUrl, and node-version migrations. The stale override shape is never touched. That gap is what this RFC closes. +PR #1588 did not stop at the rewrite functions. It also added an "existing Vite+ project" repair path that this RFC originally proposed: -## Goals +- `detectVitePlusBootstrapPending` (`migrator.ts`) inspects, per package manager, whether an already-Vite+ project's override shape is stale, including the case where `vitest` still points at the deleted `@voidzero-dev/vite-plus-test` wrapper (`isSemanticVitePlusOverrideSpec` treats a wrapper alias as NOT satisfied). +- `ensureVitePlusBootstrap` (`migrator.ts`) rewrites overrides/resolutions/catalog/peerDependencyRules to the canonical shape for npm, yarn, bun, and pnpm. +- `bin.ts` wires both into the "already using Vite+" early-return path and triggers one reinstall via `handleInstallResult`. -1. `vp migrate` on a project that already uses Vite+ detects state written by older Vite+ versions and repairs it automatically. -2. The first (and motivating) repair: replace stale `@voidzero-dev/vite-plus-test` aliases with the upstream-vitest shape and add the missing `@vitest/*` pins, across all four package managers, in standalone projects and monorepos. -3. Establish a small, ordered **upgrade-fixups registry** so future breaking changes in the managed dependency shape get the same treatment without redesigning the flow. -4. Idempotent: re-running `vp migrate` on an already-repaired project is a no-op. -5. Conservative: user-authored specs that are not wrapper aliases are preserved (same stance as #1588's prune sweeps). +This is proven by the `migration-already-vite-plus` snap fixture, whose input has `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` in `overrides` and whose output rewrites it to the bundled `vitest` version, even under `--no-interactive`. -## Non-Goals +### The real gap: upgrading a v0.1.x project (urllib, 0.1.24 -> 0.2.0) -- Changing the behavior of `vp migrate` for projects that do not have `vite-plus` yet (the full-migration path already handles stale aliases after #1588). -- A general project codemod system for arbitrary user code. Import rewrites from the original migration are not re-run; `vite-plus/test*` remains the stable public API, so no source files need to change. -- Replacing `vp update` / `vp outdated`. Those remain the way to bump versions day to day; `vp migrate` repairs **shape**, not routine version bumps. +Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade the vitest stack. Its `package.json`: -## Design +```jsonc +{ + "devDependencies": { + "@vitest/coverage-v8": "^4.1.8", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24", + }, + "overrides": {}, + "pnpm": {}, + "packageManager": "pnpm@11.7.0", +} +``` -### 1. Detection: state-based, not version-based +Three independent root causes, each enough to break the upgrade: -There is no reliable marker recording which Vite+ version performed the original migration, and config files may have been hand-edited since. Detection therefore inspects the actual state: +1. **Routing: the stale local CLI runs (primary cause).** `vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed in `node_modules`, so the global `vp v0.2.0` delegated to the **0.1.24** migrate CLI, which predates #1588 and has no bootstrap/upgrade logic at all. None of the repair above ever executed. This is the chicken-and-egg: the project that most needs the new upgrade code is exactly the project whose installed CLI is too old to contain it. -- Scan `package.json` (`overrides`, `resolutions`, `pnpm.overrides`, `workspaces.catalog(s)`, dependency fields) and `pnpm-workspace.yaml` (`catalog`, `catalogs`, `overrides`) for specs matching `npm:@voidzero-dev/vite-plus-test` or `npm:@voidzero-dev/vite-plus-test@*` (reuse `isLegacyWrapperSpec` from #1588). -- Independently, detect a **vp-managed override block** (identified by the `vite: npm:@voidzero-dev/vite-plus-core@...` alias) that is missing keys from the current `VITE_PLUS_OVERRIDE_PACKAGES`. This catches projects where someone hand-removed the wrapper alias but still lacks the `@vitest/*` pins. +2. **The v0.1.x inline-devDependency-alias shape is not repaired.** v0.1.x migration wrote the aliases directly into `devDependencies` (`vite`/`vitest` aliased to `@voidzero-dev/vite-plus-*@^0.1.24`) with a pinned `vite-plus: ^0.1.24` and empty `overrides`/`pnpm`. Even the v0.2.0 `ensureVitePlusBootstrap` does not fully fix this: + - `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent. A pinned `^0.1.24` is left untouched, so `vite-plus` resolves to the newest `0.1.x` and never reaches `0.2.0`. + - The inline `vite`/`vitest` alias entries in `devDependencies` are never rewritten, so `vitest` keeps naming the dead `@voidzero-dev/vite-plus-test` wrapper. + - Writing catalog/overrides on top of the surviving inline aliases produces a confusing half-migrated state rather than the canonical shape. -Either signal marks the project as needing the fixup. Detection is cheap (file reads, no network, no install). +3. **Coverage providers are never aligned.** `@vitest/coverage-v8: ^4.1.8` is intentionally outside `VITE_PLUS_OVERRIDE_PACKAGES`. Bootstrap does not touch it and the lockfile keeps `4.1.8`, so it lags the bundled `vitest@4.1.9`. The only feedback is the runtime skew warning/guard in `define-config.ts`, which fires when the user later runs `vp test --coverage`. The migration itself does nothing to bring the provider to `4.1.9`. -### 2. Upgrade-fixups registry +The user-visible symptom is exactly what was reported: after `vp migrate`, `vite-plus` is still `0.1.x` and `@vitest/coverage-v8` is still `4.1.8`, not the expected `4.1.9`. -A new module `packages/cli/src/migration/upgrade-fixups.ts`: +### Why the documented v0.2.0 upgrade flow still fails -```ts -interface UpgradeFixup { - /** Stable id, e.g. 'vitest-wrapper-removal' */ - id: string; - /** One-line description shown in the prompt and the summary */ - summary: string; - /** Cheap, read-only check against the workspace */ - detect(workspace: WorkspaceInfo): boolean; - /** Mutates config files; returns what changed for the report */ - apply(workspace: WorkspaceInfo, report: MigrationReport): Promise; -} +The v0.2.0 release notes document the upgrade as "bump `vite-plus` first, then migrate": -export const UPGRADE_FIXUPS: UpgradeFixup[] = [vitestWrapperRemovalFixup]; +```bash +vp update vite-plus --latest +vp migrate ``` -The already-using-Vite+ path in `bin.ts` runs `detect()` for each registered fixup, prompts once for the batch (see UX below), applies them in order, and triggers a single reinstall if any fixup mutated files. Future breaking changes (for example, if the `vite` alias shape ever changes) append a new entry instead of growing ad-hoc branches. +Following this on urllib still does not upgrade cleanly. The post-run state (directly observed) explains why: -### 3. Fixup #1: vitest wrapper removal +- urllib has a committed `pnpm-workspace.yaml` written by the old 0.1.x CLI that actively **pins** the stack to 0.1.x: -`apply()` reuses the #1588 machinery rather than introducing new rewrite logic: + ```yaml + overrides: + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper + ``` -1. **Prune wrapper aliases** everywhere they can appear, via `pruneLegacyWrapperAliases` (JSON records: `overrides`, `resolutions`, `pnpm.overrides`, dependency fields, bun `workspaces.catalog(s)`) and `pruneYamlMapLegacyWrapperAliases` (pnpm-workspace.yaml `catalog`, named `catalogs`, `overrides`). `vitest` keys are rewritten to `VITEST_VERSION` so existing `catalog:` references keep resolving; other wrapper-targeted keys are dropped. -2. **Reconcile the managed block to the canonical shape**: for the override mechanism the project already uses, ensure every key in `VITE_PLUS_OVERRIDE_PACKAGES` is present with the canonical value, and extend pnpm `peerDependencyRules` / `allowBuilds` the same way the full migration writes them (reuse the existing per-package-manager writers in `migrator.ts`). Existing keys whose value is a user-authored, non-wrapper spec are left alone and reported as a warning instead of being overwritten. -3. **Walk workspace packages** in monorepos: each package's `package.json` dependency fields get the same prune (mirrors the #1588 sweep at the dependency-field level). + So the stale CLI does not merely no-op; it has written overrides that block any later upgrade. Installed result: `vite-plus 0.1.24`, `vitest = @voidzero-dev/vite-plus-test 0.1.24`, `@vitest/coverage-v8 4.1.8`. -Before/after, pnpm monorepo (`pnpm-workspace.yaml`): +- `vp update vite-plus --latest` deliberately does NOT re-resolve these aliases/overrides (documented in `docs/guide/upgrade.md`), so the `^0.1.24` pins survive the bump. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so #1588's `pruneLegacyWrapperAliases` (which only matches the `@voidzero-dev/vite-plus-test` wrapper) would not even normalize it. -```yaml -# before (written by older vp migrate) -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: npm:@voidzero-dev/vite-plus-test@latest - vite-plus: latest -overrides: - vite: 'catalog:' - vitest: 'catalog:' -peerDependencyRules: - allowAny: [vite, vitest] - allowedVersions: { vite: '*', vitest: '*' } +- The `vp migrate` step delegates local-first to whatever `vite-plus` is installed; if the update did not actually move the installed CLI past 0.1.x (the override pins fight the bump), migrate re-runs the 0.1.x CLI and rewrites the same old-shape `pnpm-workspace.yaml`. -# after -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: 4.1.7 - '@vitest/expect': 4.1.7 - # ... runner, snapshot, spy, utils, mocker, pretty-format, coverage-v8, coverage-istanbul - vite-plus: latest -allowBuilds: - edgedriver: false - geckodriver: false -overrides: - vite: 'catalog:' - vitest: 'catalog:' - '@vitest/expect': 'catalog:' - # ... same set -peerDependencyRules: - allowAny: [vite, vitest, '@vitest/expect', ...] - allowedVersions: { vite: '*', vitest: '*', '@vitest/expect': '*', ... } -``` +- Even when the v0.2.0 CLI does run, urllib's `package.json` carries an empty `"pnpm": {}`. Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. The result is the worst case: a fresh `pnpm.overrides` block is written into `package.json` while the pinning `overrides` in `pnpm-workspace.yaml` are left intact, leaving two conflicting override sources. This is effectively a bug in the #1588 logic: an empty/partial `pkg.pnpm` should be treated as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. -Before/after, npm/bun standalone (`package.json`): +So three structural blockers compound: stale-CLI-written pins, `vp update` not reconciling them, and the empty-`pnpm` misrouting that prevents the workspace-yaml repair. Coverage skew remains on top of all three. -```jsonc -// before -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "npm:@voidzero-dev/vite-plus-test@latest" -} +## Goals -// after -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "4.1.7", - "@vitest/expect": "4.1.7" - // ... same set -} -``` +1. `vp migrate` upgrades a v0.1.x Vite+ project (e.g. `0.1.24`) to the current major (`0.2.0`) end to end: `vite-plus`, the `vite`/`vitest` aliases, and the coverage providers all land on versions consistent with the executing CLI. +2. Fix the routing so the upgrade is performed by a CLI new enough to contain the upgrade logic, instead of silently delegating to the stale local `vite-plus`. +3. Repair the v0.1.x inline-devDependency-alias shape (aliases in `devDependencies`, pinned `vite-plus`, empty `overrides`) into the canonical v0.2.0 shape, in addition to the override-based shape #1588 already handles. +4. Align coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) to the bundled `VITEST_VERSION` during migration, turning the runtime skew warning into a migration-time auto-fix. +5. Idempotent and conservative: a re-run on an upgraded project is a no-op, and user-authored, non-wrapper specs are preserved (same stance as #1588's prune sweeps). -yarn `resolutions` follows the npm shape. +## Non-Goals + +- Changing behavior for projects that do not have `vite-plus` yet; the full-migration path already writes the canonical shape. +- A general project codemod for user source. `vite-plus/test*` remains the stable public API, so no source imports need to change. +- Replacing `vp update` / `vp outdated` for routine version bumps; `vp migrate` repairs shape and performs the cross-major upgrade, not day-to-day patching. +- Pinning the `@vitest/*` runtime family individually. They cascade from the single `vitest` pin and must stay that way (see Background). Coverage providers are the one exception this RFC adds, because they are independently installed peers, not transitive deps of `vitest`. -### 4. Bumping `vite-plus` itself +## Design -The repaired shape pins `vitest` to the `VITEST_VERSION` baked into the CLI performing the migration. That pin is only correct if the project's `vite-plus` version bundles the same vitest. The fixup therefore also normalizes the `vite-plus` spec: +### 1. Routing: never let a stale local CLI silently own the upgrade -- If the spec is a dist-tag (`latest`) or a range satisfied by the CLI's own version, leave it; the reinstall resolves it forward. -- Otherwise (older pinned version), update it to the migrating CLI's version, the same way the full migration pins `vite-plus` today (`catalog:` in monorepos, explicit version standalone). +This is the crux. `vp migrate` must guarantee the migrate logic that runs is at least as new as the global `vp` performing the command. -This keeps `vite-plus` and the vitest pins in lockstep by construction, because the executing CLI writes both from its own constants. +Proposal: a cheap pre-delegation check in the global CLI (`crates/vite_global_cli`). Before `delegate_to_local_cli` for `migrate`: -### 5. Install and verification +- Read the local `vite-plus` version (from `node_modules/vite-plus/package.json`, already resolvable on the delegation path). +- Compare it to the global `vp` version. +- If the local `vite-plus` is older than the global `vp` (cross-major or otherwise behind), do NOT delegate to the local CLI. Run `migrate` from the **global** JS CLI instead (`delegate_to_global_cli`). The global CLI is the same `vite-plus` package at the global version, so `VITE_PLUS_VERSION` / `VITEST_VERSION` / the writers are all consistent and the upgrade targets `0.2.0`. + +This keeps local-first semantics for the normal case (local == global, or local newer) and only escalates when the local CLI is provably too old to perform the upgrade. The global CLI then re-pins `vite-plus` to its own version, so the very next `vp` invocation in the project picks up the upgraded local CLI. + +This is not optional polish: the urllib evidence shows the stale local CLI does not just fail to upgrade, it writes `pnpm-workspace.yaml` overrides that pin `vite`/`vitest` to `^0.1.24` and the deleted wrapper, actively blocking later upgrades. The documented "bump first" flow (`vp update vite-plus --latest && vp migrate`) does not reliably escape this, because `vp update` does not reconcile those pins (by design) and the bump can be fought by the pins themselves. Routing the upgrade to a CLI that is new enough to repair the shape is the only robust fix. + +Alternative (simpler, listed in Open Questions): always route `migrate` through the global CLI, dropping local-first for this one command, on the grounds that migrate is a toolchain-level operation like `create`. + +### 2. Detect the v0.1.x shape (state-based) -If any fixup mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` from #1588 so failures surface as warnings and a non-zero exit code. After install, verify the lockfile contains zero `@voidzero-dev/vite-plus-test` references; if any remain (for example, a transitive dependency the prune could not reach), emit a warning with the offending lockfile keys. +Extend the existing detection so `detectVitePlusBootstrapPending` (or a sibling) also flags: -### 6. Command routing: the chicken-and-egg problem +- A `vite-plus` dependency spec that is a concrete range/version **older than the executing CLI's major/version** (e.g. `^0.1.24` when the CLI is `0.2.0`), not just `catalog:`/absent. +- `vite`/`vitest` **inline alias** entries in any dependency field pointing at `npm:@voidzero-dev/vite-plus-core@*` or the `@voidzero-dev/vite-plus-test` wrapper, regardless of whether an `overrides`/`catalog` block exists. +- Installed coverage providers whose version does not satisfy the bundled `VITEST_VERSION`. -`vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`): if the project has a local `vite-plus`, its (old) migration code runs, which knows nothing about the new shape. Meanwhile the user cannot cleanly get the new local `vite-plus` first, because installing it under the stale overrides produces the mixed-vitest state described above. +Detection stays cheap (file reads, plus the already-available installed-version info), no network. + +### 3. Repair the v0.1.x shape + +Extend `ensureVitePlusBootstrap` (or the upgrade fixup it calls) so that, in addition to the override/catalog reconciliation it already does: + +1. **Re-pin `vite-plus`** to the executing CLI's target spec whenever the current spec resolves below the CLI version, not only when it is `catalog:`. For pnpm/bun monorepos this becomes `catalog:` with a `vite-plus: latest` (or the CLI version) catalog entry; for standalone it becomes the explicit version. This is what moves `0.1.24 -> 0.2.0`. +2. **Normalize inline and behind aliases.** Reuse the #1588 prune helpers (`pruneLegacyWrapperAliases`) at the dependency-field level for the dead `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper, and ADD normalization for **behind core aliases** that the prune does not catch: any `vite: npm:@voidzero-dev/vite-plus-core@` (e.g. `@^0.1.24`) is realigned to `@latest`. Apply this in both `package.json` dependency fields and the override/catalog/`pnpm-workspace.yaml` blocks, then move management into the canonical block so surviving entries match the v0.2.0 shape rather than carrying stale pins. +3. **Reconcile the managed block** to the canonical shape (the part #1588 already does): `overrides`/`resolutions`/`catalog` for `vite` and `vitest`, pnpm `peerDependencyRules`, and the `vitest` / `@vitest/*` age-gate exemptions. User-authored, non-wrapper specs are left alone and reported as a warning instead of being overwritten. +4. **Repair the right pnpm location.** Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs (fixes the misrouting in section Background). When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources; prune the stale `^0.1.24`/wrapper pins from whichever location holds them. + +### 4. Align coverage providers + +When migrating, detect `@vitest/coverage-v8` / `@vitest/coverage-istanbul` in any dependency field and rewrite their spec to the bundled `VITEST_VERSION` (e.g. `^4.1.8` -> `4.1.9`), so the installed provider matches the runner and the `define-config.ts` guard stays quiet. This is the migration-time counterpart to that runtime guard: the guard remains the safety net for projects that never re-run migrate, while migrate proactively fixes the version it already knows the correct value for. + +- Only rewrite providers that are already present; never add a coverage provider the project did not have. +- Reuse the same name resolution the runtime guard uses (`@vitest/coverage-`) so the set stays in sync. +- Report each aligned provider in the migration summary. + +### 5. Install and verification -The global `vp` binary is the natural escape hatch: users keep it current via `vp upgrade`, independent of any project. Proposal: +If any repair mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` so failures surface as warnings and a non-zero exit code (mirrors #1588). After install, verify: -**Global preflight (recommended).** Before delegating `migrate`, the global CLI runs the cheap stale-state scan itself (or always routes `migrate` for already-Vite+ projects through the global JS CLI). When stale wrapper state is detected, the **global** CLI's migration code executes the fixups, then proceeds with the existing partial migrations. Since the global JS CLI is the same `vite-plus` package at the global version, `VITEST_VERSION` and the writers are automatically consistent. +- Zero `@voidzero-dev/vite-plus-test` references remain in the lockfile. +- The resolved `vite-plus`, `vitest`, and any coverage provider are at the expected versions. -Alternative routings are listed under Open Questions. +Emit a warning listing any offending keys if a check fails (e.g. a transitive dep the prune could not reach). -### 7. UX +### 6. UX Interactive: ``` $ vp migrate -│ This project already uses Vite+. -│ Detected configuration written by an older Vite+ version: +│ This project uses an older Vite+ (0.1.24); the global CLI is 0.2.0. +│ Detected setup written by an older Vite+ version: +│ - vite-plus is pinned to 0.1.x │ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper -◆ Upgrade the Vite+ dependency setup? -│ Rewrites catalog/overrides to upstream vitest 4.1.7, updates vite-plus, reinstalls. +│ - @vitest/coverage-v8 (4.1.8) does not match the bundled vitest (4.1.9) +◆ Upgrade this project to Vite+ 0.2.0? +│ Re-pins vite-plus, rewrites the vitest setup to upstream vitest 4.1.9, +│ aligns coverage providers, and reinstalls. │ ● Yes / ○ No ``` -- One prompt for the whole fixup batch, not one per fixup; the bullet list names each detected fixup via its `summary`. -- `--no-interactive` applies the fixups (declining would leave the project broken-by-default; this matches migrate's existing convention of applying safe defaults). Declining interactively prints the manual steps and continues with the other partial migrations. -- The migration summary gains a section, fed by `MigrationReport`: +- One prompt for the whole upgrade, not one per change. +- `--no-interactive` applies the upgrade (declining would leave the project broken-by-default; matches migrate's convention of applying safe defaults). See Open Question 5 for whether an unattended cross-major bump in CI should require an explicit flag. +- Summary section, fed by `MigrationReport`: ``` -Upgraded Vite+ dependency setup - rewrote 2 stale vitest aliases (pnpm-workspace.yaml, packages/app/package.json) - added 9 @vitest/* pins - vite-plus: 0.6.0 -> 0.9.0 +Upgraded Vite+ 0.1.24 -> 0.2.0 + re-pinned vite-plus + rewrote stale vitest wrapper alias -> vitest 4.1.9 + aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 ``` -### 8. Idempotency +### 7. Idempotency -After a successful run, `detect()` returns false for every fixup (no wrapper aliases, no missing keys), so a re-run takes the existing "already using Vite+, happy coding" path. Fixups must be written so that partial failure (e.g. install failed after files were rewritten) is recoverable by simply re-running `vp migrate`. +After a successful upgrade, detection returns false (no wrapper aliases, `vite-plus` at target, providers aligned), so a re-run takes the existing "already using Vite+, happy coding" path. Repairs must be recoverable by re-running `vp migrate` if an install fails after files were rewritten. ## Code Touchpoints -| Area | Change | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `packages/cli/src/migration/upgrade-fixups.ts` (new) | Fixup interface, registry, vitest-wrapper fixup | -| `packages/cli/src/migration/bin.ts` | Already-Vite+ path: run detection, prompt, apply, fold into the existing single-reinstall logic | -| `packages/cli/src/migration/migrator.ts` | Export/reshape `pruneLegacyWrapperAliases`, `pruneYamlMapLegacyWrapperAliases`, and the per-PM override writers so the fixup can call them outside the full-migration flow | -| `packages/cli/src/migration/report.ts` | `upgradeFixups` entries (id, counts, version bump) | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Routing change per the preflight decision | -| `docs/guide/upgrade.md` | New section: upgrading projects migrated by older Vite+ (`vp migrate` repairs the setup) | -| `docs/guide/migrate.md` | Note that running migrate on an existing Vite+ project also repairs older setups | +| Area | Change | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`, version compare helper) | Pre-delegation local-vs-global version check; route `migrate` to the global CLI when the local `vite-plus` is older | +| `packages/cli/src/migration/migrator.ts` | Extend `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap`: re-pin a behind `vite-plus` (not only `catalog:`), normalize inline/behind `vite`/`vitest` aliases, align coverage providers, treat empty `pkg.pnpm` as "no pnpm config" so the `pnpm-workspace.yaml` path runs, and reconcile both override locations | +| `packages/cli/src/migration/bin.ts` | Surface the version bump and coverage alignment in the existing already-Vite+ path and summary | +| `packages/cli/src/migration/report.ts` | Report fields for version bump and coverage alignment | +| `docs/guide/upgrade.md` | Section: upgrading a v0.1.x project (`vp upgrade && vp migrate`), note that `vp migrate` re-pins and reinstalls | +| `docs/guide/migrate.md` | Note that migrate on an existing Vite+ project performs the cross-version upgrade | ## Testing Plan -- **Unit** (`packages/cli/src/migration/__tests__/upgrade-fixups.spec.ts`): detection and apply for each stale shape: pnpm catalog + named catalogs, `pnpm.overrides` in package.json, npm/bun `overrides`, bun `workspaces.catalog`, yarn `resolutions`, wrapper aliases in dependency fields, pinned wrapper versions (`npm:@voidzero-dev/vite-plus-test@4.0.5`), hand-edited blocks missing only `@vitest/*` keys, user-authored `vitest: ^4.0.0` ranges preserved with warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): new fixtures whose inputs are committed old-shape projects, e.g. `migration-upgrade-stale-vitest-pnpm`, `-npm`, `-yarn`, `-bun`, `migration-upgrade-monorepo-catalog`, plus an idempotency fixture that runs `vp migrate` twice and snapshots the second run's "already using Vite+" output. Fixture inputs must be committed files, not generated by ignored local state. -- **E2E**: take a project migrated by the last pre-#1588 release, run new `vp migrate`, assert `vp test` passes and the lockfile has zero wrapper references. +- **Unit** (`migrator.spec.ts`): the urllib shape (pnpm, inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `overrides`/`pnpm`, `@vitest/coverage-v8: ^4.1.8`) detected as pending and repaired to: `vite-plus` at target, no wrapper alias, coverage provider at `VITEST_VERSION`. Plus the npm/bun/yarn equivalents and a user-authored non-wrapper `vitest`/coverage range preserved with a warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): a committed `migration-upgrade-v0_1-inline-alias-pnpm` fixture that mirrors urllib EXACTLY (inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, AND a committed `pnpm-workspace.yaml` whose `overrides` pin `vite`/`vitest` to `@^0.1.24`/the wrapper, plus `@vitest/coverage-v8: ^4.1.8`). Assert the output has no `^0.1.24`/wrapper pins in either location, a single override source, and aligned coverage. Add standalone npm/yarn/bun variants and an idempotency fixture running `vp migrate` twice. Inputs must be committed files. +- **Routing test** (`crates/vite_global_cli`): with a local `vite-plus` older than the global `vp`, `vp migrate` runs the global migrate path; with local == global it stays local-first. +- **E2E**: a real 0.1.24-shaped project (urllib), run `vp migrate`, assert `vite-plus`, `vitest`, and `@vitest/coverage-v8` resolve to the expected versions and `vp test --coverage` passes with no skew warning. ## Rollout and Complementary Actions -1. Land after #1588 merges and ships in the same release if possible, so the first release without the wrapper is also the first that can repair old projects. -2. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp migrate' to update your project"` so users who never run migrate still get a pointer at install time. -3. Release notes and `docs/guide/upgrade.md` call out the one-command repair: `vp upgrade && vp migrate`. +1. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"` so users who never re-run migrate get a pointer at install time. +2. Release notes and `docs/guide/upgrade.md` document the one-command upgrade: `vp upgrade && vp migrate`. ## Alternatives Considered -- **Auto-heal in `vp install`**: detect stale aliases on every install and fix silently. Rejected as the primary mechanism: install should not rewrite config files unprompted, and the migration machinery (prompts, report, per-PM writers) already lives in migrate. A lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). -- **Hook into `vp update vite-plus`**: reconcile overrides whenever the vite-plus spec is bumped. More magical, splits migration logic across commands, and misses users who edit package.json by hand. The install-time warning covers discovery instead. -- **Version-marker file** (e.g. recording the migrating Vite+ version) to drive upgrade steps by version range. Rejected: state-based detection is robust to hand-edits and requires no new artifact in user repos. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler routing than a preflight, and arguably correct since migrate is a toolchain-level operation like `create`, but it changes behavior for users who intentionally pin a local version. Kept as an option in Open Question 1. +- **Auto-heal in `vp install`**: detect and fix on every install. Rejected as primary mechanism (install should not rewrite config files unprompted), but a lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). Note this would also have the stale-local-CLI problem unless the warning lives in the global routing layer. +- **Hook into `vp update vite-plus`**: reconcile shape on every bump. Splits migration logic across commands and misses hand edits. Per `docs/guide/upgrade.md`, `vp update` deliberately does not re-resolve the aliases, so this would be a behavior change. +- **Version-marker file** recording the migrating Vite+ version. Rejected: state-based detection is robust to hand-edits and needs no new artifact in user repos. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; arguably correct since migrate is toolchain-level. Changes behavior for users who intentionally pin a local version. Kept as Open Question 1. ## Open Questions -1. **Routing**: global preflight scan (recommended) vs. always routing `migrate` through the global CLI for already-Vite+ projects? The preflight keeps local-first semantics for everything else but adds a Rust-side (or pre-delegation JS) scan; always-global is simpler but a behavior change. -2. Should `vp install` (and/or `vp doctor`-style checks, `vp outdated`) **warn** when stale wrapper aliases are present, pointing at `vp migrate`? This is the main discovery mechanism for users who do not think to run migrate again. -3. When the fixup finds a **user-authored `vitest` range** (not a wrapper alias) inside an otherwise vp-managed override block, should we still add the `@vitest/*` pins (risking a mixed tree against their chosen vitest) or skip the whole block with a warning? Current proposal: add nothing, warn, and explain the risk. -4. Should declining the fixup interactively be allowed to proceed with the other partial migrations (current proposal), or should migrate stop early since the project is in a known-broken state? -5. Is bumping the `vite-plus` spec to the migrating CLI's version acceptable in non-interactive mode, or should non-interactive runs require an explicit `--upgrade` flag the first time? (CI running `vp migrate --no-interactive` would otherwise get an unattended dependency bump.) -6. Do we want `vp migrate --check` (detection only, exit code signals drift) for CI, mirroring `vp upgrade --check`? +1. **Routing**: pre-delegation local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? The check preserves local-first for the normal case; always-global is simpler but a behavior change. Either way, what is the comparison rule (any-older, or only cross-major)? +2. Should `vp install` / `vp outdated` **warn** when a stale wrapper alias or a behind `vite-plus` is present, pointing at `vp migrate`? Main discovery path for users who do not re-run migrate. To work for stale-local-CLI projects, the warning must live in the global routing layer. +3. When the project has a **user-authored, non-wrapper `vitest` range** (someone opted out of the managed pin), should migrate still re-pin to `VITEST_VERSION` and align coverage, or skip with a warning? Current proposal: preserve the user's `vitest`, warn, and skip coverage alignment for that project to avoid forcing a mixed tree. +4. Coverage alignment policy: always rewrite to the exact bundled `VITEST_VERSION`, or to a compatible caret range? Exact matches the runtime guard's expectation (the guard wants an exact-version match); a caret could drift again. Current proposal: exact. +5. Should a cross-major bump under `--no-interactive` (CI) be automatic, or require an explicit `--upgrade` flag the first time, so CI does not get an unattended major bump? +6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? +7. The empty-`pkg.pnpm` misrouting (Background) is arguably a standalone bug in #1588 worth fixing immediately, independent of the rest of this RFC. Should it ship as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? +8. Should the v0.2.0 release notes upgrade flow be corrected? As written (`vp update vite-plus --latest && vp migrate`) it does not reliably upgrade projects with stale pinning overrides; the recommended flow may need to be `vp upgrade` (global) then `vp migrate`, once routing escalates to the global CLI. From de71b55b4eec3caa73d5b909528c35819fb007b4 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 20:45:46 +0800 Subject: [PATCH 03/32] docs(rfc): align vp migrate upgrade with the v0.2.1 prompt spec Rewrite the upgrade-path RFC to make vp migrate reliably reproduce the manual 'Upgrading from 0.1.x to 0.2.1' prompt, so the release notes' 'do not run vp migrate; not reliable enough yet' disclaimer can be dropped. The prompt corrects a wrong assumption in the prior draft: the upgrade is a usage-based decision, not 'always pin vitest'. - Common case (no direct vitest usage): remove vitest from deps and every resolution mechanism; do not pin. It arrives transitively through vite-plus, so future vp update keeps it correct with no pin to drift. - Direct-usage case (a @vitest/* package listed, e.g. urllib's coverage-v8, or a direct vitest/@vitest import): pin vitest to the bundled 4.1.9 and align every @vitest/* and vitest-browser-* so the tree resolves to a single vitest copy. Also: pin vite-plus and the vite->core alias to the EXACT target version in every workspace package; remove wrapper-only peerDependencyRules / yarn packageExtensions; fix the empty-pnpm misrouting and dual override sources; verify single vitest version; honor git hooks and minimal-edit constraints. --- rfcs/migrate-upgrade-path.md | 276 ++++++++++++++--------------------- 1 file changed, 106 insertions(+), 170 deletions(-) diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-upgrade-path.md index f87e466f99..29207563db 100644 --- a/rfcs/migrate-upgrade-path.md +++ b/rfcs/migrate-upgrade-path.md @@ -2,60 +2,28 @@ - Status: Draft (for discussion) - Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) +- Spec source: the ["Upgrading from 0.1.x to 0.2.1 Prompt"](https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.1) in the v0.2.1 release notes - Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` -## Background +## Summary -PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. The managed dependency shape it writes is: +The v0.2.1 release notes ship a careful, manual AI-agent prompt for upgrading a project from v0.1.x and explicitly say: -- `vite` stays aliased to `npm:@voidzero-dev/vite-plus-core@latest` (unchanged). -- `vitest` is pinned to the bundled `VITEST_VERSION` (currently `4.1.9`, in `packages/cli/src/utils/constants.ts`). The `@vitest/*` runtime family (`expect`, `runner`, `snapshot`, `spy`, `utils`, `mocker`, `pretty-format`) are EXACT dependencies of `vitest` itself, so a single `vitest` override cascades one consistent version to the whole tree. They are deliberately NOT pinned individually. -- The package-manager age gate gets `VITEST_AGE_GATE_EXEMPT_PACKAGES = ['vitest', '@vitest/*']` added (pnpm `minimumReleaseAgeExclude` / Yarn `npmPreapprovedPackages`) so the freshly published pinned version is not quarantined. -- Coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) are NOT managed at all: they are peer deps the project installs and versions itself. A runtime guard in `packages/cli/src/define-config.ts` fail-fasts when an installed provider's version skews from the bundled vitest (Vitest otherwise silently runs mixed versions and yields unreliable coverage). +> Do not run `vp migrate` for this upgrade; it is not reliable enough yet. Make the changes yourself by editing the project's files, then verify by running the tools. -So the canonical v0.2.0 shape, pnpm monorepo (`pnpm-workspace.yaml`): +That prompt is the authoritative description of the correct end state. This RFC's goal is to make `vp migrate` reliably reproduce that end state so the disclaimer can be removed. The prompt also corrects a key assumption in earlier drafts of this RFC: the upgrade is NOT "always pin `vitest`". It is a usage-based decision that, in the common case, REMOVES `vitest` from the project entirely and lets it arrive transitively through `vite-plus`. -```yaml -catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: 4.1.9 - vite-plus: latest -overrides: - vite: 'catalog:' - vitest: 'catalog:' -peerDependencyRules: - allowAny: [vite, vitest] - allowedVersions: { vite: '*', vitest: '*' } -minimumReleaseAgeExclude: - - vite-plus - - '@voidzero-dev/*' - # ... oxlint/oxfmt families ... - - vitest - - '@vitest/*' -``` - -npm/bun standalone (`package.json`): +## Background -```jsonc -"overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "4.1.9" -} -``` +PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. Today `ensureVitePlusBootstrap` (`migrator.ts`) unconditionally writes a managed `vitest` entry (pinned to `VITEST_VERSION`, currently `4.1.9`) into the project's override/catalog block for every already-Vite+ project, alongside the `vite` -> `npm:@voidzero-dev/vite-plus-core@latest` alias. `@vitest/*` runtime internals are NOT pinned (they are exact deps of `vitest`); coverage providers (`@vitest/coverage-v8` / `-istanbul`) are NOT managed and only get a runtime skew guard in `define-config.ts`. ### What #1588 already handles -PR #1588 did not stop at the rewrite functions. It also added an "existing Vite+ project" repair path that this RFC originally proposed: - -- `detectVitePlusBootstrapPending` (`migrator.ts`) inspects, per package manager, whether an already-Vite+ project's override shape is stale, including the case where `vitest` still points at the deleted `@voidzero-dev/vite-plus-test` wrapper (`isSemanticVitePlusOverrideSpec` treats a wrapper alias as NOT satisfied). -- `ensureVitePlusBootstrap` (`migrator.ts`) rewrites overrides/resolutions/catalog/peerDependencyRules to the canonical shape for npm, yarn, bun, and pnpm. -- `bin.ts` wires both into the "already using Vite+" early-return path and triggers one reinstall via `handleInstallResult`. - -This is proven by the `migration-already-vite-plus` snap fixture, whose input has `"vitest": "npm:@voidzero-dev/vite-plus-test@latest"` in `overrides` and whose output rewrites it to the bundled `vitest` version, even under `--no-interactive`. +PR #1588 added an "existing Vite+ project" repair path: `detectVitePlusBootstrapPending` + `ensureVitePlusBootstrap`, wired into the "already using Vite+" branch of `bin.ts` with one reinstall via `handleInstallResult`. It rewrites a stale `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper alias to the bundled vitest, proven by the `migration-already-vite-plus` snap fixture (even under `--no-interactive`). This is the foundation to build on, but as the prompt and the urllib evidence below show, it does the wrong thing in two ways: it pins `vitest` even when the project does not use it, and it misses several stale shapes. -### The real gap: upgrading a v0.1.x project (urllib, 0.1.24 -> 0.2.0) +### The real gap: upgrading a v0.1.x project (urllib) -Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade the vitest stack. Its `package.json`: +`vp migrate` on a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade. Its `package.json`: ```jsonc { @@ -63,194 +31,162 @@ Running `vp migrate` in a real 0.1.24 project (`node-modules/urllib`) did NOT up "@vitest/coverage-v8": "^4.1.8", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", "vite-plus": "^0.1.24", - "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" }, "overrides": {}, "pnpm": {}, - "packageManager": "pnpm@11.7.0", + "packageManager": "pnpm@11.7.0" } ``` -Three independent root causes, each enough to break the upgrade: - -1. **Routing: the stale local CLI runs (primary cause).** `vp migrate` is delegated **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed in `node_modules`, so the global `vp v0.2.0` delegated to the **0.1.24** migrate CLI, which predates #1588 and has no bootstrap/upgrade logic at all. None of the repair above ever executed. This is the chicken-and-egg: the project that most needs the new upgrade code is exactly the project whose installed CLI is too old to contain it. - -2. **The v0.1.x inline-devDependency-alias shape is not repaired.** v0.1.x migration wrote the aliases directly into `devDependencies` (`vite`/`vitest` aliased to `@voidzero-dev/vite-plus-*@^0.1.24`) with a pinned `vite-plus: ^0.1.24` and empty `overrides`/`pnpm`. Even the v0.2.0 `ensureVitePlusBootstrap` does not fully fix this: - - `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent. A pinned `^0.1.24` is left untouched, so `vite-plus` resolves to the newest `0.1.x` and never reaches `0.2.0`. - - The inline `vite`/`vitest` alias entries in `devDependencies` are never rewritten, so `vitest` keeps naming the dead `@voidzero-dev/vite-plus-test` wrapper. - - Writing catalog/overrides on top of the surviving inline aliases produces a confusing half-migrated state rather than the canonical shape. - -3. **Coverage providers are never aligned.** `@vitest/coverage-v8: ^4.1.8` is intentionally outside `VITE_PLUS_OVERRIDE_PACKAGES`. Bootstrap does not touch it and the lockfile keeps `4.1.8`, so it lags the bundled `vitest@4.1.9`. The only feedback is the runtime skew warning/guard in `define-config.ts`, which fires when the user later runs `vp test --coverage`. The migration itself does nothing to bring the provider to `4.1.9`. +plus a committed `pnpm-workspace.yaml` written by the old CLI that actively pins the stack to 0.1.x: -The user-visible symptom is exactly what was reported: after `vp migrate`, `vite-plus` is still `0.1.x` and `@vitest/coverage-v8` is still `4.1.8`, not the expected `4.1.9`. +```yaml +overrides: + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper +``` -### Why the documented v0.2.0 upgrade flow still fails +Observed blockers, each sufficient on its own: -The v0.2.0 release notes document the upgrade as "bump `vite-plus` first, then migrate": +1. **Routing: the stale local CLI runs.** `vp migrate` delegates **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed, so the global `vp v0.2.x` delegated to the **0.1.24** CLI, which predates #1588 and has no upgrade logic. Worse, that old CLI rewrites the old-shape `pnpm-workspace.yaml`, pinning `vite`/`vitest` to `^0.1.24` and the dead wrapper, which then blocks any later upgrade. The documented `vp update vite-plus --latest && vp migrate` flow does not escape this, because `vp update` deliberately does not reconcile those pins (`docs/guide/upgrade.md`). -```bash -vp update vite-plus --latest -vp migrate -``` +2. **The v0.1.x shapes are not repaired by the v0.2.x bootstrap.** `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent, so a pinned `^0.1.24` is left untouched and never reaches the target. The inline `vite`/`vitest` aliases in `devDependencies` are never rewritten. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so the wrapper-only `pruneLegacyWrapperAliases` does not normalize it. -Following this on urllib still does not upgrade cleanly. The post-run state (directly observed) explains why: +3. **Empty `"pnpm": {}` misroutes the repair.** Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. A fresh override block lands in `package.json` while the pinning overrides in `pnpm-workspace.yaml` survive, leaving two conflicting override sources. This is effectively a standalone bug in #1588. -- urllib has a committed `pnpm-workspace.yaml` written by the old 0.1.x CLI that actively **pins** the stack to 0.1.x: +### What the v0.2.1 prompt specifies (the correct end state) - ```yaml - overrides: - vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x - vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper - ``` +The prompt encodes the upgrade as these steps (paraphrased; see the release for verbatim text): - So the stale CLI does not merely no-op; it has written overrides that block any later upgrade. Installed result: `vite-plus 0.1.24`, `vitest = @voidzero-dev/vite-plus-test 0.1.24`, `@vitest/coverage-v8 4.1.8`. +1. **Set `vite-plus` to the exact target version (`0.2.1`) and reinstall**, in every workspace package that depends on it. "Changing the spec to `0.2.1` is what moves the lockfile off the old resolution; a reinstall that leaves the spec unchanged would keep the old version." Exact, not a range or `latest`. +2. **Remove the `@voidzero-dev/vite-plus-test` wrapper everywhere** (package.json, lockfile, pnpm-workspace.yaml / .yarnrc.yml catalogs, source imports). Then a **usage-based decision**: + - The project depends on vitest directly ONLY IF a source/test file imports from `vitest` or `@vitest/...`, OR a `@vitest/*` package is in its deps (e.g. a coverage provider). Imports from `vite-plus/test` do NOT count. + - **Common case (no direct usage): remove vitest configuration entirely.** Delete the `vitest` entry from dependencies in whatever form (wrapper alias, `catalog:`, plain version), and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, pnpm `overrides`/`catalog` in package.json or pnpm-workspace.yaml, any catalog). Do NOT add a pinned `vitest`; it arrives transitively through `vite-plus`. + - **Direct-usage case: pin upstream vitest to the bundled version (`4.1.9`) and align the whole ecosystem.** Set every `@vitest/*` the project lists (`coverage-v8`, `ui`, `browser`, ...) to that same version, and update other integration packages (`vitest-browser-*`) to a compatible release. "Leaving an ecosystem package on an older version pulls in a second copy of vitest, which Vitest rejects at runtime." + - Delete dependency-resolution config that existed only for the wrapper/old vitest: pnpm `peerDependencyRules` (`allowedVersions` / `ignoreMissing`) referencing `vitest` / `@vitest/*` / the wrapper, and yarn `packageExtensions` equivalents. Leave unrelated rules. +3. **Keep the `vite` -> core override, pinned to the exact target**: `vite` -> `npm:@voidzero-dev/vite-plus-core@0.2.1`, in whatever override/resolution/catalog form the project already uses. Core is released in lockstep with `vite-plus`. +4. **Leave `vite-plus/test*` imports unchanged**; only repoint direct `@voidzero-dev/vite-plus-test` imports to `vite-plus/test`. +5. **Reinstall and verify**: no `@voidzero-dev/vite-plus-test` references remain outside `node_modules`; the tree resolves to a **single** `vitest` version (no duplicates); tests pass (native Vitest banner); the `vp check` workflow passes. -- `vp update vite-plus --latest` deliberately does NOT re-resolve these aliases/overrides (documented in `docs/guide/upgrade.md`), so the `^0.1.24` pins survive the bump. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so #1588's `pruneLegacyWrapperAliases` (which only matches the `@voidzero-dev/vite-plus-test` wrapper) would not even normalize it. +Constraints: do not bypass git hooks (report pre-existing failures instead); make the smallest set of edits; end with a short summary. -- The `vp migrate` step delegates local-first to whatever `vite-plus` is installed; if the update did not actually move the installed CLI past 0.1.x (the override pins fight the bump), migrate re-runs the 0.1.x CLI and rewrites the same old-shape `pnpm-workspace.yaml`. +Two insights from this change the design: -- Even when the v0.2.0 CLI does run, urllib's `package.json` carries an empty `"pnpm": {}`. Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. The result is the worst case: a fresh `pnpm.overrides` block is written into `package.json` while the pinning `overrides` in `pnpm-workspace.yaml` are left intact, leaving two conflicting override sources. This is effectively a bug in the #1588 logic: an empty/partial `pkg.pnpm` should be treated as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. - -So three structural blockers compound: stale-CLI-written pins, `vp update` not reconciling them, and the empty-`pnpm` misrouting that prevents the workspace-yaml repair. Coverage skew remains on top of all three. +- **The common case is removal, not pinning.** Removing `vitest` (rather than pinning it to an exact version) is what lets future `vp update vite-plus` keep vitest correct automatically: there is no project-level pin to drift. urllib is NOT the common case (it lists `@vitest/coverage-v8`), so it takes the direct-usage branch: pin `vitest` to `4.1.9` and set `@vitest/coverage-v8` to `4.1.9`, which is exactly the version it was missing. +- **Exactness moves the lockfile.** The upgrade must write exact target versions for `vite-plus` and the core alias, in every workspace package, or the lockfile keeps resolving the old version. ## Goals -1. `vp migrate` upgrades a v0.1.x Vite+ project (e.g. `0.1.24`) to the current major (`0.2.0`) end to end: `vite-plus`, the `vite`/`vitest` aliases, and the coverage providers all land on versions consistent with the executing CLI. -2. Fix the routing so the upgrade is performed by a CLI new enough to contain the upgrade logic, instead of silently delegating to the stale local `vite-plus`. -3. Repair the v0.1.x inline-devDependency-alias shape (aliases in `devDependencies`, pinned `vite-plus`, empty `overrides`) into the canonical v0.2.0 shape, in addition to the override-based shape #1588 already handles. -4. Align coverage providers (`@vitest/coverage-v8` / `@vitest/coverage-istanbul`) to the bundled `VITEST_VERSION` during migration, turning the runtime skew warning into a migration-time auto-fix. -5. Idempotent and conservative: a re-run on an upgraded project is a no-op, and user-authored, non-wrapper specs are preserved (same stance as #1588's prune sweeps). +1. `vp migrate` reliably reproduces the v0.2.1 prompt's end state for a v0.1.x project, so the "do not run `vp migrate`" disclaimer can be dropped. +2. Run the upgrade with a CLI new enough to contain this logic (fix the local-first routing that runs a stale 0.1.x CLI). +3. Implement the usage-based vitest decision: remove vitest entirely in the common case; pin + align the ecosystem in the direct-usage case. +4. Pin `vite-plus` and the `vite`->core alias to the exact target version, in every workspace package, so the lockfile moves. +5. Repair all observed stale shapes: inline/behind aliases, the empty-`pnpm` misrouting, dual override sources, and wrapper-only peer config. +6. Verify the end state (no wrapper refs, single vitest version) and respect the prompt's constraints (git hooks, minimal edits, summary). Idempotent on re-run. ## Non-Goals -- Changing behavior for projects that do not have `vite-plus` yet; the full-migration path already writes the canonical shape. -- A general project codemod for user source. `vite-plus/test*` remains the stable public API, so no source imports need to change. -- Replacing `vp update` / `vp outdated` for routine version bumps; `vp migrate` repairs shape and performs the cross-major upgrade, not day-to-day patching. -- Pinning the `@vitest/*` runtime family individually. They cascade from the single `vitest` pin and must stay that way (see Background). Coverage providers are the one exception this RFC adds, because they are independently installed peers, not transitive deps of `vitest`. +- Changing behavior for projects that do not yet use `vite-plus` (the full-migration path already writes the canonical shape). +- Rewriting user source beyond repointing direct `@voidzero-dev/vite-plus-test` imports; `vite-plus/test*` stays the stable public API. +- Pinning the `@vitest/*` runtime internals individually (they cascade from `vitest`). The ecosystem alignment in the direct-usage case targets the packages the project itself lists, not transitive internals. ## Design -### 1. Routing: never let a stale local CLI silently own the upgrade - -This is the crux. `vp migrate` must guarantee the migrate logic that runs is at least as new as the global `vp` performing the command. - -Proposal: a cheap pre-delegation check in the global CLI (`crates/vite_global_cli`). Before `delegate_to_local_cli` for `migrate`: +### 1. Run the right vp (routing) -- Read the local `vite-plus` version (from `node_modules/vite-plus/package.json`, already resolvable on the delegation path). -- Compare it to the global `vp` version. -- If the local `vite-plus` is older than the global `vp` (cross-major or otherwise behind), do NOT delegate to the local CLI. Run `migrate` from the **global** JS CLI instead (`delegate_to_global_cli`). The global CLI is the same `vite-plus` package at the global version, so `VITE_PLUS_VERSION` / `VITEST_VERSION` / the writers are all consistent and the upgrade targets `0.2.0`. +The upgrade logic must execute from a CLI at least as new as the target. The prompt's manual workaround is "after any install, re-resolve vp so you always run the version currently in the project." Automate the same idea: -This keeps local-first semantics for the normal case (local == global, or local newer) and only escalates when the local CLI is provably too old to perform the upgrade. The global CLI then re-pins `vite-plus` to its own version, so the very next `vp` invocation in the project picks up the upgraded local CLI. +- In the global CLI (`crates/vite_global_cli`), before delegating `migrate` local-first, read the local `vite-plus` version and compare to the global `vp`. If local is older, run `migrate` from the **global** JS CLI (`delegate_to_global_cli`) instead of the stale local one. The global CLI's constants (target version, `VITEST_VERSION`) are then self-consistent. +- The upgrade re-pins `vite-plus` to the global version and reinstalls, so the next `vp` in the project resolves to the upgraded local CLI. -This is not optional polish: the urllib evidence shows the stale local CLI does not just fail to upgrade, it writes `pnpm-workspace.yaml` overrides that pin `vite`/`vitest` to `^0.1.24` and the deleted wrapper, actively blocking later upgrades. The documented "bump first" flow (`vp update vite-plus --latest && vp migrate`) does not reliably escape this, because `vp update` does not reconcile those pins (by design) and the bump can be fought by the pins themselves. Routing the upgrade to a CLI that is new enough to repair the shape is the only robust fix. +This is mandatory, not polish: the stale local CLI does not just no-op, it writes pinning overrides that block the upgrade. Simpler alternative (Open Questions): always route `migrate` through the global CLI. -Alternative (simpler, listed in Open Questions): always route `migrate` through the global CLI, dropping local-first for this one command, on the grounds that migrate is a toolchain-level operation like `create`. +### 2. Bump `vite-plus` to the exact target, everywhere, and reinstall -### 2. Detect the v0.1.x shape (state-based) +For every workspace package that depends on `vite-plus`, set the spec to the exact executing-CLI version (e.g. `0.2.1`), not a range or `catalog:`/`latest` placeholder. Extend `ensureVitePlusDependencySpecs` to re-pin a concrete behind spec (`^0.1.24`), not only `catalog:`/absent. Then reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`) so the lockfile moves off the old resolution. -Extend the existing detection so `detectVitePlusBootstrapPending` (or a sibling) also flags: +### 3. Remove the wrapper and apply the usage-based vitest decision -- A `vite-plus` dependency spec that is a concrete range/version **older than the executing CLI's major/version** (e.g. `^0.1.24` when the CLI is `0.2.0`), not just `catalog:`/absent. -- `vite`/`vitest` **inline alias** entries in any dependency field pointing at `npm:@voidzero-dev/vite-plus-core@*` or the `@voidzero-dev/vite-plus-test` wrapper, regardless of whether an `overrides`/`catalog` block exists. -- Installed coverage providers whose version does not satisfy the bundled `VITEST_VERSION`. +This replaces `ensureVitePlusBootstrap`'s unconditional "write `vitest` into overrides" with the prompt's logic: -Detection stays cheap (file reads, plus the already-available installed-version info), no network. +1. **Detect direct vitest usage**: a source/test file imports from `vitest` or `@vitest/...` (not `vite-plus/test`), OR the project lists any `@vitest/*` package in a dependency field. (Source scan can reuse the migration's existing import walker.) +2. **Common case (no direct usage): purge vitest.** Remove the `vitest` dependency entry in any form, and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, `pnpm.overrides`, `pnpm-workspace.yaml` `overrides`/`catalog`, bun `workspaces.catalog`, yarn `resolutions`/`.yarnrc.yml` catalog). Add no pin. +3. **Direct-usage case: pin and align.** Set `vitest` (the dependency and/or override the project uses) to `VITEST_VERSION`, and set every `@vitest/*` package the project lists to the same version; bump `vitest-browser-*` and similar integration packages to a compatible release. This subsumes the earlier "coverage provider alignment" goal: `@vitest/coverage-v8: ^4.1.8` -> `4.1.9`. +4. **Behind/inline aliases**: rewrite `vite: npm:@voidzero-dev/vite-plus-core@` to the exact target (`@0.2.1`) wherever it appears, including inline `devDependencies` aliases; reuse `pruneLegacyWrapperAliases` for the dead wrapper and add normalization for behind core aliases. -### 3. Repair the v0.1.x shape +### 4. Pin the `vite` -> core override to the exact target -Extend `ensureVitePlusBootstrap` (or the upgrade fixup it calls) so that, in addition to the override/catalog reconciliation it already does: +Keep the `vite` -> `npm:@voidzero-dev/vite-plus-core@` mapping, set to the exact executing version, in whichever override/resolution/catalog form the project already uses. This is a deliberate change from the current `@latest` convention (see Open Questions) and matches the prompt's lockstep requirement. -1. **Re-pin `vite-plus`** to the executing CLI's target spec whenever the current spec resolves below the CLI version, not only when it is `catalog:`. For pnpm/bun monorepos this becomes `catalog:` with a `vite-plus: latest` (or the CLI version) catalog entry; for standalone it becomes the explicit version. This is what moves `0.1.24 -> 0.2.0`. -2. **Normalize inline and behind aliases.** Reuse the #1588 prune helpers (`pruneLegacyWrapperAliases`) at the dependency-field level for the dead `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper, and ADD normalization for **behind core aliases** that the prune does not catch: any `vite: npm:@voidzero-dev/vite-plus-core@` (e.g. `@^0.1.24`) is realigned to `@latest`. Apply this in both `package.json` dependency fields and the override/catalog/`pnpm-workspace.yaml` blocks, then move management into the canonical block so surviving entries match the v0.2.0 shape rather than carrying stale pins. -3. **Reconcile the managed block** to the canonical shape (the part #1588 already does): `overrides`/`resolutions`/`catalog` for `vite` and `vitest`, pnpm `peerDependencyRules`, and the `vitest` / `@vitest/*` age-gate exemptions. User-authored, non-wrapper specs are left alone and reported as a warning instead of being overwritten. -4. **Repair the right pnpm location.** Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs (fixes the misrouting in section Background). When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources; prune the stale `^0.1.24`/wrapper pins from whichever location holds them. +### 5. Clean wrapper-only resolution config and fix the pnpm location -### 4. Align coverage providers +- Remove pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` / `ignoreMissing`) and yarn `packageExtensions` entries that reference `vitest`, `@vitest/*`, or the wrapper, when they exist only to accommodate the old setup. Leave unrelated rules. +- Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources. -When migrating, detect `@vitest/coverage-v8` / `@vitest/coverage-istanbul` in any dependency field and rewrite their spec to the bundled `VITEST_VERSION` (e.g. `^4.1.8` -> `4.1.9`), so the installed provider matches the runner and the `define-config.ts` guard stays quiet. This is the migration-time counterpart to that runtime guard: the guard remains the safety net for projects that never re-run migrate, while migrate proactively fixes the version it already knows the correct value for. +### 6. Reinstall and verify -- Only rewrite providers that are already present; never add a coverage provider the project did not have. -- Reuse the same name resolution the runtime guard uses (`@vitest/coverage-`) so the set stays in sync. -- Report each aligned provider in the migration summary. +After edits, reinstall once (reusing `handleInstallResult`), then assert the prompt's post-conditions and surface failures as warnings + non-zero exit: -### 5. Install and verification +- No `@voidzero-dev/vite-plus-test` reference anywhere outside `node_modules` (package.json, lockfile, catalogs, sources). +- The dependency tree resolves to a **single** `vitest` version (no duplicate copies). This is the check that catches a missed ecosystem package in the direct-usage branch. +- `vite-plus`, the core alias, and (if present) the aligned `@vitest/*` packages resolve to the expected versions. -If any repair mutated files, run a single `vp install` with `--no-frozen-lockfile` (pnpm/yarn) or `--force` (npm/bun), reusing `handleInstallResult` so failures surface as warnings and a non-zero exit code (mirrors #1588). After install, verify: - -- Zero `@voidzero-dev/vite-plus-test` references remain in the lockfile. -- The resolved `vite-plus`, `vitest`, and any coverage provider are at the expected versions. - -Emit a warning listing any offending keys if a check fails (e.g. a transitive dep the prune could not reach). - -### 6. UX - -Interactive: - -``` -$ vp migrate -│ This project uses an older Vite+ (0.1.24); the global CLI is 0.2.0. -│ Detected setup written by an older Vite+ version: -│ - vite-plus is pinned to 0.1.x -│ - vitest is aliased to the removed @voidzero-dev/vite-plus-test wrapper -│ - @vitest/coverage-v8 (4.1.8) does not match the bundled vitest (4.1.9) -◆ Upgrade this project to Vite+ 0.2.0? -│ Re-pins vite-plus, rewrites the vitest setup to upstream vitest 4.1.9, -│ aligns coverage providers, and reinstalls. -│ ● Yes / ○ No -``` +### 7. Constraints and UX -- One prompt for the whole upgrade, not one per change. -- `--no-interactive` applies the upgrade (declining would leave the project broken-by-default; matches migrate's convention of applying safe defaults). See Open Question 5 for whether an unattended cross-major bump in CI should require an explicit flag. -- Summary section, fed by `MigrationReport`: +Honor the prompt's constraints: do not bypass git hooks (if a pre-existing failure blocks the run, report it rather than forcing through); make the smallest set of edits and do not reformat unrelated files; end with a summary. Interactive run prompts once for the whole upgrade; `--no-interactive` applies it. Summary, fed by `MigrationReport`: ``` -Upgraded Vite+ 0.1.24 -> 0.2.0 - re-pinned vite-plus - rewrote stale vitest wrapper alias -> vitest 4.1.9 - aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 +Upgraded Vite+ 0.1.24 -> 0.2.1 + re-pinned vite-plus and vite->core to 0.2.1 (1 package) + removed @voidzero-dev/vite-plus-test wrapper + project uses vitest directly (@vitest/coverage-v8): pinned vitest 4.1.9, aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 + verified: no wrapper refs, single vitest version ``` -### 7. Idempotency +### 8. Idempotency -After a successful upgrade, detection returns false (no wrapper aliases, `vite-plus` at target, providers aligned), so a re-run takes the existing "already using Vite+, happy coding" path. Repairs must be recoverable by re-running `vp migrate` if an install fails after files were rewritten. +After a successful upgrade, detection returns false (target version pinned, no wrapper, single vitest), so a re-run hits the "already using Vite+, happy coding" path. Repairs must be recoverable by re-running if an install fails after files were rewritten. ## Code Touchpoints -| Area | Change | -| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`, version compare helper) | Pre-delegation local-vs-global version check; route `migrate` to the global CLI when the local `vite-plus` is older | -| `packages/cli/src/migration/migrator.ts` | Extend `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap`: re-pin a behind `vite-plus` (not only `catalog:`), normalize inline/behind `vite`/`vitest` aliases, align coverage providers, treat empty `pkg.pnpm` as "no pnpm config" so the `pnpm-workspace.yaml` path runs, and reconcile both override locations | -| `packages/cli/src/migration/bin.ts` | Surface the version bump and coverage alignment in the existing already-Vite+ path and summary | -| `packages/cli/src/migration/report.ts` | Report fields for version bump and coverage alignment | -| `docs/guide/upgrade.md` | Section: upgrading a v0.1.x project (`vp upgrade && vp migrate`), note that `vp migrate` re-pins and reinstalls | -| `docs/guide/migrate.md` | Note that migrate on an existing Vite+ project performs the cross-version upgrade | +| Area | Change | +| ---- | ------ | +| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Local-vs-global version check; route `migrate` to the global CLI when local `vite-plus` is older | +| `packages/cli/src/migration/migrator.ts` | Replace unconditional vitest pinning with the usage-based decision; exact-version pin of `vite-plus` + core alias for every workspace package; behind/inline alias normalization; empty-`pnpm` fix and dual-source reconciliation; wrapper-only peer-config cleanup | +| `packages/cli/src/migration/detector.ts` | Detect direct vitest usage (source imports + listed `@vitest/*`) | +| `packages/cli/src/migration/bin.ts` | Drive the upgrade in the already-Vite+ path; verify single-vitest post-condition; summary | +| `packages/cli/src/migration/report.ts` | Report version bump, removal-vs-pin decision, ecosystem alignment, verification | +| `docs/guide/upgrade.md` / release notes | Replace the manual prompt + "do not run `vp migrate`" with `vp upgrade && vp migrate` once reliable | ## Testing Plan -- **Unit** (`migrator.spec.ts`): the urllib shape (pnpm, inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `overrides`/`pnpm`, `@vitest/coverage-v8: ^4.1.8`) detected as pending and repaired to: `vite-plus` at target, no wrapper alias, coverage provider at `VITEST_VERSION`. Plus the npm/bun/yarn equivalents and a user-authored non-wrapper `vitest`/coverage range preserved with a warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): a committed `migration-upgrade-v0_1-inline-alias-pnpm` fixture that mirrors urllib EXACTLY (inline `vite`/`vitest` aliases in `devDependencies`, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, AND a committed `pnpm-workspace.yaml` whose `overrides` pin `vite`/`vitest` to `@^0.1.24`/the wrapper, plus `@vitest/coverage-v8: ^4.1.8`). Assert the output has no `^0.1.24`/wrapper pins in either location, a single override source, and aligned coverage. Add standalone npm/yarn/bun variants and an idempotency fixture running `vp migrate` twice. Inputs must be committed files. -- **Routing test** (`crates/vite_global_cli`): with a local `vite-plus` older than the global `vp`, `vp migrate` runs the global migrate path; with local == global it stays local-first. -- **E2E**: a real 0.1.24-shaped project (urllib), run `vp migrate`, assert `vite-plus`, `vitest`, and `@vitest/coverage-v8` resolve to the expected versions and `vp test --coverage` passes with no skew warning. +- **Unit** (`migrator.spec.ts`): + - urllib shape (pnpm, inline `vite`/`vitest` aliases, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, committed `pnpm-workspace.yaml` pinning to `^0.1.24`/wrapper, `@vitest/coverage-v8: ^4.1.8`) -> direct-usage branch: `vite-plus`/core pinned to target, `vitest` pinned `4.1.9`, `@vitest/coverage-v8` -> `4.1.9`, no wrapper, single override source. + - Common-case shape (uses only `vite-plus/test`, no `@vitest/*` dep): `vitest` removed from deps and all resolution mechanisms, no pin added. + - npm/bun/yarn variants; user-authored non-wrapper `vitest`/coverage range preserved with a warning. +- **Snap tests** (`packages/cli/snap-tests-global/`): committed `migration-upgrade-v0_1-*` fixtures for both branches (direct-usage = urllib mirror, common-case = removal), per package manager, plus an idempotency fixture running `vp migrate` twice. Inputs must be committed files. +- **Routing test** (`crates/vite_global_cli`): local `vite-plus` older than global `vp` runs the global migrate path; equal stays local-first. +- **E2E**: real urllib, run the upgrade, assert no wrapper refs, single `vitest@4.1.9`, `@vitest/coverage-v8@4.1.9`, and `vp run cov` passes with no skew warning. -## Rollout and Complementary Actions +## Rollout -1. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"` so users who never re-run migrate get a pointer at install time. -2. Release notes and `docs/guide/upgrade.md` document the one-command upgrade: `vp upgrade && vp migrate`. +1. Land the empty-`pnpm` misrouting fix (Open Question 3) as a standalone bugfix with a regression test, independent of the rest. +2. Ship the full upgrade behavior, then update the v0.2.x release notes / `docs/guide/upgrade.md` to recommend `vp upgrade && vp migrate` and remove the "do not run `vp migrate`" disclaimer. +3. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"`. ## Alternatives Considered -- **Auto-heal in `vp install`**: detect and fix on every install. Rejected as primary mechanism (install should not rewrite config files unprompted), but a lightweight **warning** in `vp install` pointing at `vp migrate` is proposed as a follow-up (Open Question 2). Note this would also have the stale-local-CLI problem unless the warning lives in the global routing layer. -- **Hook into `vp update vite-plus`**: reconcile shape on every bump. Splits migration logic across commands and misses hand edits. Per `docs/guide/upgrade.md`, `vp update` deliberately does not re-resolve the aliases, so this would be a behavior change. -- **Version-marker file** recording the migrating Vite+ version. Rejected: state-based detection is robust to hand-edits and needs no new artifact in user repos. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; arguably correct since migrate is toolchain-level. Changes behavior for users who intentionally pin a local version. Kept as Open Question 1. +- **Keep #1588's always-pin behavior** (write `vitest: VITEST_VERSION` for every project). Rejected: the prompt removes vitest in the common case precisely so future `vp update vite-plus` keeps vitest correct without a project pin to drift. Always-pinning creates per-release maintenance and redundant config. +- **Auto-heal in `vp install`**: rejected as primary mechanism (install should not rewrite config unprompted); a discovery warning pointing at `vp migrate` is a follow-up (Open Question 2). It must live in the global routing layer to reach stale-local-CLI projects. +- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; changes behavior for users who pin a local version. Open Question 1. ## Open Questions -1. **Routing**: pre-delegation local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? The check preserves local-first for the normal case; always-global is simpler but a behavior change. Either way, what is the comparison rule (any-older, or only cross-major)? -2. Should `vp install` / `vp outdated` **warn** when a stale wrapper alias or a behind `vite-plus` is present, pointing at `vp migrate`? Main discovery path for users who do not re-run migrate. To work for stale-local-CLI projects, the warning must live in the global routing layer. -3. When the project has a **user-authored, non-wrapper `vitest` range** (someone opted out of the managed pin), should migrate still re-pin to `VITEST_VERSION` and align coverage, or skip with a warning? Current proposal: preserve the user's `vitest`, warn, and skip coverage alignment for that project to avoid forcing a mixed tree. -4. Coverage alignment policy: always rewrite to the exact bundled `VITEST_VERSION`, or to a compatible caret range? Exact matches the runtime guard's expectation (the guard wants an exact-version match); a caret could drift again. Current proposal: exact. -5. Should a cross-major bump under `--no-interactive` (CI) be automatic, or require an explicit `--upgrade` flag the first time, so CI does not get an unattended major bump? +1. **Routing**: local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? Comparison rule: any-older or only cross-major? +2. Should `vp install` / `vp outdated` warn when a stale wrapper alias or behind `vite-plus` is present, pointing at `vp migrate`? To reach stale-local-CLI projects the warning must live in the global routing layer. +3. The empty-`pkg.pnpm` misrouting is a standalone #1588 bug. Ship it as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? +4. **Exact vs `latest`**: the prompt pins `vite-plus` and the core alias to the exact target; the current migrate convention writes `@latest` / `catalog: latest`. Should the upgrade path write exact versions (recommended, guarantees the lockfile moves and matches the prompt), and should normal migrate adopt the same? +5. **Removal default under `--no-interactive`**: removing `vitest` and resolution config is more invasive than pinning. Acceptable unattended in CI, or gated behind an explicit flag the first time? 6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? -7. The empty-`pkg.pnpm` misrouting (Background) is arguably a standalone bug in #1588 worth fixing immediately, independent of the rest of this RFC. Should it ship as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? -8. Should the v0.2.0 release notes upgrade flow be corrected? As written (`vp update vite-plus --latest && vp migrate`) it does not reliably upgrade projects with stale pinning overrides; the recommended flow may need to be `vp upgrade` (global) then `vp migrate`, once routing escalates to the global CLI. +7. **Direct-usage detection fidelity**: is "any `@vitest/*` listed, or any direct `vitest`/`@vitest` import" sufficient, or do we also need to catch indirect integration packages (`vitest-browser-*`, framework test plugins) that imply vitest usage without a direct import? From ad76e8d3997e97b735f614b6985b28161ab9ddd6 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 22:52:46 +0800 Subject: [PATCH 04/32] fix(migrate): make vp migrate upgrade v0.1.x projects to v0.2.x Running vp migrate on a v0.1.x project (e.g. node-modules/urllib) did not upgrade the vitest stack. Four compounding causes, each fixed: - Routing: vp migrate delegates local-first, so a stale local vite-plus (0.1.x, predating the wrapper removal) ran and left the project unmigrated. The global CLI now compares the local vite-plus version to its own and, when local is older, runs migrate from the global CLI so the new upgrade logic executes (delegate_migrate in js_executor). - Empty pnpm field: an empty "pnpm": {} in package.json is truthy, so the bootstrap ignored an existing pnpm-workspace.yaml and left its stale overrides. pnpmConfigLivesInPackageJson routes to the workspace file when one exists so its overrides get reconciled. - Behind vite-plus spec: a pinned ^0.1.24 was left untouched and never moved off 0.1.x. ensureVitePlusDependencySpecs now re-pins a non-protocol-pinned spec to the target so the lockfile moves. - Coverage skew: a listed @vitest/coverage-v8 / -istanbul stayed behind the bundled vitest. It is now aligned to VITEST_VERSION (and the bootstrap detector flags the skew). Adds reproduction tests for each (migrator.spec.ts) and a unit test for the version-comparison routing helper. --- .../vite_global_cli/src/commands/migrate.rs | 11 +- crates/vite_global_cli/src/js_executor.rs | 63 ++++++++++ .../src/migration/__tests__/migrator.spec.ts | 119 ++++++++++++++++++ packages/cli/src/migration/migrator.ts | 114 +++++++++++++++-- 4 files changed, 292 insertions(+), 15 deletions(-) diff --git a/crates/vite_global_cli/src/commands/migrate.rs b/crates/vite_global_cli/src/commands/migrate.rs index a458bbfad4..414b1e2e18 100644 --- a/crates/vite_global_cli/src/commands/migrate.rs +++ b/crates/vite_global_cli/src/commands/migrate.rs @@ -4,11 +4,18 @@ use std::process::ExitStatus; use vite_path::AbsolutePathBuf; -use crate::error::Error; +use crate::{error::Error, js_executor::JsExecutor}; /// Execute the `migrate` command by delegating to local or global vite-plus. +/// +/// Routes through [`JsExecutor::delegate_migrate`], which escalates to the +/// global CLI when the project's local `vite-plus` is older than this global +/// `vp` (the upgrade scenario). Otherwise it keeps local-first semantics. pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result { - super::delegate::execute(cwd, "migrate", args).await + let mut executor = JsExecutor::new(None); + let mut full_args = vec!["migrate".to_string()]; + full_args.extend(args.iter().cloned()); + executor.delegate_migrate(&cwd, &full_args).await } #[cfg(test)] diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 585512d92e..0af79b0399 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -247,6 +247,32 @@ impl JsExecutor { self.run_js_entry_output(project_path, &node_binary, &bin_prefix, args).await } + /// Delegate `migrate`, escalating to the global CLI when the project's local + /// `vite-plus` is older than this global `vp`. A stale local CLI predates the + /// upgrade logic and would otherwise run (and leave the project unmigrated), + /// so the newer global CLI must perform the upgrade; it re-pins `vite-plus`, + /// so the next invocation resolves the upgraded local CLI. When local == global + /// (or local is newer, or none is installed) keep local-first semantics + /// (`delegate_to_local_cli` already falls back to the global bin when no local + /// vite-plus is resolvable). + pub async fn delegate_migrate( + &mut self, + project_path: &AbsolutePath, + args: &[String], + ) -> Result { + let escalate = resolve_local_vite_plus_version(project_path) + .is_some_and(|local| local_vite_plus_is_older(&local, env!("CARGO_PKG_VERSION"))); + if escalate { + tracing::debug!( + "Local vite-plus is older than global vp {}; running migrate from the global CLI", + env!("CARGO_PKG_VERSION") + ); + self.delegate_to_global_cli(project_path, args).await + } else { + self.delegate_to_local_cli(project_path, args).await + } + } + /// Delegate to the global vite-plus CLI entrypoint directly. /// /// Unlike [`delegate_to_local_cli`], this bypasses project-local resolution and always runs @@ -364,6 +390,31 @@ impl JsExecutor { } } +/// Resolve the version of the project-local `vite-plus`, if one is installed. +fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { + use oxc_resolver::{ResolveOptions, Resolver}; + + let resolver = Resolver::new(ResolveOptions { + condition_names: vec!["import".into(), "node".into()], + ..ResolveOptions::default() + }); + let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; + let content = std::fs::read_to_string(resolved.path()).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value.get("version")?.as_str().map(str::to_string) +} + +/// True when `local` is a parseable semver strictly older than `global`. +/// +/// Returns false if either version fails to parse (be conservative: never +/// escalate on a version we can't understand). +fn local_vite_plus_is_older(local: &str, global: &str) -> bool { + match (node_semver::Version::parse(local), node_semver::Version::parse(global)) { + (Ok(local_v), Ok(global_v)) => local_v < global_v, + _ => false, + } +} + /// Check whether a project directory has at least one valid version source. /// /// Uses `is_valid_version` (no warning side effects) to avoid duplicate @@ -427,6 +478,18 @@ mod tests { use super::*; + #[test] + fn test_local_vite_plus_is_older() { + // Older local should escalate. + assert!(local_vite_plus_is_older("0.1.24", "0.2.1")); + // Equal versions keep local-first semantics. + assert!(!local_vite_plus_is_older("0.2.1", "0.2.1")); + // Newer local keeps local-first semantics. + assert!(!local_vite_plus_is_older("0.3.0", "0.2.1")); + // Unparseable versions are conservative: never escalate. + assert!(!local_vite_plus_is_older("latest", "0.2.1")); + } + #[test] fn test_js_executor_new() { let executor = JsExecutor::new(None); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d240d78559..7c67bfde0d 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1380,6 +1380,125 @@ describe('ensureVitePlusBootstrap', () => { expect(workspace.catalog['vite-plus']).toBe('latest'); }); + it('reconciles stale pnpm-workspace.yaml overrides when package.json has an empty pnpm field (urllib shape)', () => { + // urllib 0.1.x shape: an empty `pnpm: {}` in package.json AND a committed + // pnpm-workspace.yaml whose overrides pin vite/vitest to the deleted + // @voidzero-dev/vite-plus-test wrapper. The empty `pnpm: {}` is truthy, so the + // bootstrap used to take the package.json path and IGNORE the workspace.yaml, + // leaving the dead wrapper override in place (and a second, conflicting + // override source in package.json). Because a pnpm-workspace.yaml exists, the + // workspace.yaml is the real config location and must be reconciled. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'urllib', + devDependencies: { + '@vitest/coverage-v8': '^4.1.8', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, + pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + // The deleted wrapper alias must no longer survive in the workspace.yaml. + const workspaceRaw = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf-8'); + expect(workspaceRaw).not.toContain('@voidzero-dev/vite-plus-test'); + + // And the project must not be left pending (no stale wrapper override anywhere). + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('aligns coverage providers to the bundled vitest version (urllib coverage-v8 symptom)', () => { + // A coverage provider is a project-installed peer that Vitest pins to an + // exact runner version; a skewed copy makes Vitest run mixed versions. The + // upgrade must bump it to the bundled vitest version, not leave it behind. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('re-pins a behind vite-plus spec so the upgrade moves off the old version (urllib)', () => { + // urllib pinned vite-plus to a concrete 0.1.x range. A spec that stays at + // ^0.1.24 keeps the lockfile on the old resolution; the upgrade must re-pin + // it to the migrating toolchain target (here the mocked VITE_PLUS_VERSION + // 'latest', materialized as `catalog:` in a pnpm-workspace.yaml project) so + // the reinstall resolves the new version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'urllib', + devDependencies: { + 'vite-plus': '^0.1.24', + vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + }, + pnpm: {}, + devEngines: { + packageManager: { name: 'pnpm', version: '11.7.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + " vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24'", + " vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24'", + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + // vite-plus must no longer be pinned to the old 0.1.x range. + expect(pkg.devDependencies['vite-plus']).not.toContain('0.1.24'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + it('uses a concrete vite-plus version when pnpm config stays in package.json', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ef6e6c94f1..2d1fad1b7f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -105,6 +105,13 @@ const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; // forcing pins dropped, while their catalog entries are PRESERVED. const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; +// Coverage providers are project-installed peers (NOT bundled by vite-plus). +// Vitest pins each to an exact runner version, and the `define-config.ts` guard +// fail-fasts when an installed provider skews from the bundled vitest (Vitest +// otherwise runs mixed versions and yields unreliable coverage). The upgrade +// aligns any the project lists to the bundled `VITEST_VERSION`. +const VITEST_COVERAGE_PROVIDERS = ['@vitest/coverage-v8', '@vitest/coverage-istanbul'] as const; + // Provider names whose stale pnpm overrides / resolutions are dropped during // migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned // opt-in providers. The provider DEP is preserved, but a leftover @@ -3354,6 +3361,54 @@ function readBunCatalogDependencyResolver(pkg: { fromWorkspaces(catalogSpec, dependencyName) ?? fromPkg(catalogSpec, dependencyName); } +// Decide where a pnpm project keeps its overrides / peer rules. A truthy +// `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no +// config, and when a real `pnpm-workspace.yaml` exists the workspace file is +// the actual config source. Treat the config as living in package.json only +// when `pkg.pnpm` has entries, or when it is present-but-empty AND there is no +// `pnpm-workspace.yaml` to own the config instead. +function pnpmConfigLivesInPackageJson( + pkg: BootstrapPackageJson, + projectPath: string, +): boolean { + if (pkg.pnpm == null) { + return false; + } + return ( + Object.keys(pkg.pnpm).length > 0 || + !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) + ); +} + +// Pin any coverage provider the project lists to the bundled vitest version. +// Returns true if any spec changed. Providers are plain dependency entries +// (not overrides), so this is package-manager agnostic. +function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + let changed = false; + for (const provider of VITEST_COVERAGE_PROVIDERS) { + for (const dependencies of dependencyGroups) { + if (dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION) { + dependencies[provider] = VITEST_VERSION; + changed = true; + } + } + } + return changed; +} + +// True when the project lists a coverage provider at a version other than the +// bundled vitest, so the bootstrap should run to realign it. +function vitestCoverageProvidersPending(pkg: BootstrapPackageJson): boolean { + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + return VITEST_COVERAGE_PROVIDERS.some((provider) => + dependencyGroups.some( + (dependencies) => + dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION, + ), + ); +} + export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, @@ -3372,6 +3427,12 @@ export function detectVitePlusBootstrapPending( return true; } + // A coverage provider skewed from the bundled vitest needs realigning, + // independent of the package manager's override shape. + if (vitestCoverageProvidersPending(pkg)) { + return true; + } + if (packageManager === undefined) { return true; } @@ -3390,11 +3451,11 @@ export function detectVitePlusBootstrapPending( return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); } if (packageManager === PackageManager.pnpm) { - if (pkg.pnpm) { + if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm.peerDependencyRules) + !overridesSatisfyVitePlus(pkg.pnpm?.overrides) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules) ); } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); @@ -3410,13 +3471,31 @@ export function detectVitePlusBootstrapPending( function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: string): boolean { let changed = false; - if (version !== 'catalog:') { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const dependencies of dependencyGroups) { - if (dependencies?.[VITE_PLUS_NAME]?.startsWith('catalog:')) { - dependencies[VITE_PLUS_NAME] = version; - changed = true; - } + // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so + // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the + // full-migration rule at `shouldNormalizeExistingVitePlus`/`canonicalVitePlusSpec`: + // only vanilla version ranges are rewritten; deliberate protocol pins + // (workspace:, link:, file:, npm:, github:, git, http) are preserved. + const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + for (const dependencies of dependencyGroups) { + const spec = dependencies?.[VITE_PLUS_NAME]; + if (spec === undefined || spec === version) { + continue; + } + // Concrete target (e.g. `latest`): also rewrite an existing `catalog:` + // pin onto the concrete version — `isProtocolPinnedSpec` matches + // `catalog:`, so handle it explicitly before the generic plain-range case. + if (version !== 'catalog:' && spec.startsWith('catalog:')) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; + continue; + } + // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target + // (`catalog:` for catalog-supporting projects, otherwise the concrete + // version). Already-`catalog:` / other protocol pins are left untouched. + if (!isProtocolPinnedSpec(spec)) { + dependencies[VITE_PLUS_NAME] = version; + changed = true; } } if (pkg.devDependencies?.[VITE_PLUS_NAME]) { @@ -3507,7 +3586,9 @@ export function ensureVitePlusBootstrap( catalogs?: Record>; } >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && !pkg.pnpm; + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + !pnpmConfigLivesInPackageJson(pkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); @@ -3515,6 +3596,7 @@ export function ensureVitePlusBootstrap( pkg, supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, ); + packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; } @@ -3537,7 +3619,13 @@ export function ensureVitePlusBootstrap( pkg.overrides = ensured.overrides; packageJsonChanged = true; } - } else if (workspaceInfo.packageManager === PackageManager.pnpm && pkg.pnpm) { + } else if ( + workspaceInfo.packageManager === PackageManager.pnpm && + pnpmConfigLivesInPackageJson(pkg, projectPath) + ) { + // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, + // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. + pkg.pnpm ??= {}; const ensured = ensureOverrideEntries(pkg.pnpm.overrides); if (ensured.changed) { pkg.pnpm.overrides = ensured.overrides; @@ -3552,7 +3640,7 @@ export function ensureVitePlusBootstrap( if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; - if (!pkg.pnpm) { + if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); const before = fs.existsSync(pnpmWorkspaceYamlPath) ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') From 6c752099b3ca9e48310cfadeba9bc8fb7e7bd0a7 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 18 Jun 2026 23:41:29 +0800 Subject: [PATCH 05/32] feat(migrate): manage vitest only when the project uses it directly Per the v0.2.1 upgrade spec, vite-plus consumes upstream vitest transitively, so a project-level vitest pin should exist only when the project actually uses vitest. Make the managed override set usage-aware: - A project uses vitest directly when it lists a vitest ecosystem dep (@vitest/* or vitest-*), imports vitest/@vitest in source, or runs vitest browser mode. - Common case (no direct usage): remove vitest entirely from deps and every resolution mechanism (overrides, resolutions, pnpm overrides, pnpm-workspace.yaml overrides/catalog, bun catalog, yarn resolutions) and from pnpm peerDependencyRules. It then arrives transitively, so a future vp update vite-plus keeps it correct with no pin to drift. - Direct-usage case: keep vitest pinned to the bundled version and align the coverage providers, as before. vite handling is unchanged (always managed). Removal is gated on VITEST_IS_MANAGED_OVERRIDE so force-override/CI mode never strips a user's own vitest. Also fixes two issues found while reviewing this change: - ensureVitePlusDependencySpecs triggered a tsgolint TS18048 after the earlier re-pin rewrite; narrow the dependency group before assigning. - Folding browser mode into the usage signal keeps an injected direct vitest:catalog: from dangling against a vitest-less catalog. --- .../src/migration/__tests__/migrator.spec.ts | 249 +++++++-- packages/cli/src/migration/migrator.ts | 526 +++++++++++++++--- 2 files changed, 643 insertions(+), 132 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 7c67bfde0d..14ac250967 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1229,11 +1229,13 @@ describe('ensureVitePlusBootstrap', () => { devEngines: { packageManager: { name: string } }; }; expect(pkg.overrides.vite).toContain('@voidzero-dev/vite-plus-core'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is NOT managed — + // it arrives transitively through vite-plus, so no override is written. + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devEngines.packageManager.name).toBe(PackageManager.npm); }); - it('rewrites the stale vitest wrapper override without pinning the @vitest/* family for npm projects', () => { + it('removes the stale vitest wrapper override for a non-vitest npm project', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -1252,16 +1254,16 @@ describe('ensureVitePlusBootstrap', () => { const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); // The `vite` alias still points at the live `@voidzero-dev/vite-plus-core` - // package, so it satisfies the migration and is left untouched. The `vitest` - // alias points at the DELETED `@voidzero-dev/vite-plus-test` wrapper, so it is - // rewritten to the bundled vitest version. The `@vitest/*` family is NOT pinned: - // it resolves transitively from `vitest`'s own exact deps. + // package, so it satisfies the migration and is left untouched. The project + // does NOT use vitest directly (no @vitest/* dep, no vitest source), so the + // stale `vitest` wrapper override (the DELETED `@voidzero-dev/vite-plus-test`) + // is REMOVED entirely — vitest arrives transitively through vite-plus. expect(result.changed).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { overrides: Record; }; expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@0.1.0'); - expect(pkg.overrides.vitest).toBe('4.1.9'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.overrides['@vitest/expect']).toBeUndefined(); expect(pkg.overrides['@vitest/coverage-v8']).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); @@ -1294,7 +1296,9 @@ describe('ensureVitePlusBootstrap', () => { dependencies: Record; }; expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(pkg.dependencies.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): the direct `vitest` dep + // is removed — it arrives transitively through vite-plus. + expect(pkg.dependencies.vitest).toBeUndefined(); }); it('normalizes catalog vite-plus pins for npm projects', () => { @@ -1459,6 +1463,129 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { + // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that + // does NOT use vitest directly must NOT carry a managed `vitest` override — + // it arrives transitively through vite-plus. A pre-existing stale wrapper + // override (`npm:@voidzero-dev/vite-plus-test@*`) is REMOVED entirely while + // the `vite` alias stays. The bootstrap is idempotent: a second detect is + // false. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toBeUndefined(); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('keeps vitest managed for a direct-usage npm project (@vitest/coverage-v8) and aligns coverage', () => { + // The project lists `@vitest/coverage-v8`, so it USES vitest directly: the + // managed `vitest` override is kept (re-pinned to the bundled vitest version, + // off the stale wrapper) AND the coverage provider is aligned to that version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-v8': '^4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + // vitest stays managed (the stale wrapper is re-pinned to the bundled version). + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + // Coverage provider aligned to the same bundled vitest version. + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + + it('removes managed vitest catalog/override/peer entries from pnpm-workspace.yaml in the common case', () => { + // pnpm-workspace.yaml common-case removal: a project with no @vitest/* dep + // and no vitest source must have every managed `vitest` entry (catalog, + // override, peer rule) stripped from the workspace file so vitest resolves + // transitively through vite-plus. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vitest: npm:@voidzero-dev/vite-plus-test@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' - vitest', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; + }; + // Managed `vitest` is gone from every sink; `vite` stays managed. + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.overrides.vite).toBe('catalog:'); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); + expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*' }); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + it('re-pins a behind vite-plus spec so the upgrade moves off the old version (urllib)', () => { // urllib pinned vite-plus to a concrete 0.1.x range. A spec that stays at // ^0.1.24 keeps the lockfile on the old resolution; the upgrade must re-pin @@ -1653,7 +1780,9 @@ describe('ensureVitePlusBootstrap', () => { }; expect(yarnrc.nodeLinker).toBe('node-modules'); expect(yarnrc.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(yarnrc.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no catalog entry is written for it. + expect(yarnrc.catalog.vitest).toBeUndefined(); expect(yarnrc.catalog['vite-plus']).toBe('latest'); }); @@ -1687,13 +1816,19 @@ describe('ensureVitePlusBootstrap', () => { expect(result.changed).toBe(true); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + // Common case (no @vitest/* dep, no vitest source): the pre-existing managed + // `vitest` catalog/override/peer entries are REMOVED — only `vite` stays + // managed. vitest arrives transitively through vite-plus. const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; peerDependencyRules: { allowAny: string[]; allowedVersions: Record }; }; - expect(workspace.peerDependencyRules.allowAny).toEqual(['vite', 'vitest']); + expect(workspace.catalog.vitest).toBeUndefined(); + expect(workspace.overrides.vitest).toBeUndefined(); + expect(workspace.peerDependencyRules.allowAny).toEqual(['vite']); expect(workspace.peerDependencyRules.allowedVersions).toEqual({ vite: '*', - vitest: '*', }); }); @@ -1905,9 +2040,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const overrides = pnpm.overrides as Record; expect(overrides['some-pkg']).toBe('1.0.0'); expect(overrides.vite).toBeDefined(); - // vitest is pinned via overrides so downstream projects resolve a single - // vitest copy (the one vp-cli ships). - expect(overrides.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is written — it arrives transitively through vite-plus. + expect(overrides.vitest).toBeUndefined(); // peerDependencyRules should be present expect(pnpm.peerDependencyRules).toBeDefined(); @@ -1953,9 +2088,10 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { const yaml = readYaml(path.join(tmpDir, 'pnpm-workspace.yaml')); expect(yaml).toContain("vite: 'catalog:'"); - // vitest is now a managed override key — it resolves through the catalog - // like vite does. - expect(yaml).toContain("vitest: 'catalog:'"); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is written — it arrives transitively through + // vite-plus. + expect(yaml).not.toContain('vitest'); }); it('rewrites named catalogs in pnpm-workspace.yaml without adding new entries', () => { @@ -1998,16 +2134,16 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now a managed override key — it is added to overrides as a - // `catalog:` reference, and its catalog entry is rewritten to the pinned - // vitest version vp-cli ships. - expect(yaml.overrides.vitest).toBe('catalog:'); - expect(yaml.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no override is added and the pre-existing managed `vitest` catalog + // entries (default + named) are REMOVED — it arrives transitively through + // vite-plus. + expect(yaml.overrides.vitest).toBeUndefined(); + expect(yaml.catalog?.vitest).toBeUndefined(); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(yaml.catalogs.vite7.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7['vite-plus']).toBe('latest'); - // Named catalog vitest entries are also pinned to the managed override version. - expect(yaml.catalogs.test.vitest).toBe('4.1.9'); + expect(yaml.catalogs.test.vitest).toBeUndefined(); expect(yaml.catalogs.test.tsdown).toBeUndefined(); expect(yaml.catalogs.test['vite-plus']).toBeUndefined(); @@ -2018,11 +2154,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`); only the catalog file itself - // is later rewritten to the pinned vp-cli version. The peer range stays - // as the user wrote it. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer a managed override key, so the peer entry is left as + // the user wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -3093,8 +3227,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); - // vitest is now injected into overrides as a managed override key. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is injected. + expect(yaml.overrides.vitest).toBeUndefined(); expect(yaml.overrides.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); @@ -3138,8 +3273,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { overrides: Record; }; expect(yaml.overrides.vite).toBe('catalog:'); - // vitest is now a managed override key — added to overrides as catalog: ref. - expect(yaml.overrides.vitest).toBe('catalog:'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so no `vitest` override is added. + expect(yaml.overrides.vitest).toBeUndefined(); }); it('does not resolve peer dependency catalog specs to migrated aliases', () => { @@ -3171,9 +3307,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.peerDependencies.vite).toBe('*'); - // vitest is now a managed override key — peer dep catalog refs that - // resolve to the override target are coerced to '*'. - expect(pkg.peerDependencies.vitest).toBe('*'); + // `vitest` is no longer a managed override key (common case: no @vitest/* + // dep, no vitest source), so its peer entry is left as the user wrote it. + expect(pkg.peerDependencies.vitest).toBe('catalog:'); }); it('adds vitest only to the monorepo package that uses browser mode', () => { @@ -4358,9 +4494,9 @@ describe('rewriteMonorepo yarn catalog', () => { expect(yarnrc.nodeLinker).toBe('node-modules'); expect(yarnrc.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(yarnrc.catalogs.vite7.react).toBe('^18.0.0'); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(yarnrc.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED. + expect(yarnrc.catalogs.test.vitest).toBeUndefined(); expect(yarnrc.catalogs.test.oxlint).toBeUndefined(); const pkg = readJson(path.join(tmpDir, 'package.json')) as { @@ -4369,10 +4505,9 @@ describe('rewriteMonorepo yarn catalog', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). The peer range stays as the - // user wrote it; only the catalog file itself is later rewritten. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer managed, so the peer entry is left as the user + // wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:test'); }); }); @@ -4465,9 +4600,9 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.workspaces.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - // vitest is now a managed override key — pre-existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.catalog.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing catalog `vitest` entry is REMOVED. + expect(pkg.catalog.vitest).toBeUndefined(); expect(pkg.catalog.tsdown).toBeUndefined(); expect(pkg.catalog.react).toBe('^19.0.0'); expect(pkg.catalog['vite-plus']).toBeUndefined(); @@ -4527,17 +4662,17 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.catalogs.build.react).toBe('^19.0.0'); expect(pkg.catalogs.build.tsdown).toBeUndefined(); - // vitest is now a managed override key — existing catalog entries are - // rewritten to the pinned version and `overrides.vitest` is injected - // as a `catalog:` ref so bun resolves it through the catalog. - expect(pkg.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED and no + // `overrides.vitest` is injected. + expect(pkg.catalogs.test.vitest).toBeUndefined(); expect(pkg.overrides.vite).toBe('catalog:build'); - expect(pkg.overrides.vitest).toBe('catalog:'); + expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // vitest peer `catalog:test` is resolved against the pre-rewrite catalog - // (which still holds the user's `^4.0.0`). Peer range stays as-is. - expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + // `vitest` is no longer managed, so the peer entry is left as the user + // wrote it (untouched). + expect(pkg.peerDependencies.vitest).toBe('catalog:test'); }); it('rewrites workspaces named catalogs and writes default catalog beside them', () => { @@ -4572,9 +4707,9 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.workspaces.catalogs.build.oxlint).toBeUndefined(); - // vitest is a managed override key — existing catalog entries are - // rewritten to the pinned vp-cli vitest version. - expect(pkg.workspaces.catalogs.test.vitest).toBe('4.1.9'); + // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, + // so the pre-existing named-catalog `vitest` entry is REMOVED. + expect(pkg.workspaces.catalogs.test.vitest).toBeUndefined(); expect(pkg.workspaces.catalogs.test.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.overrides.vite).toBe('catalog:'); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 2d1fad1b7f..59997159de 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -490,6 +490,132 @@ const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { vitest: '*', }; +// The managed override/catalog packages vite-plus writes and the detector +// requires. `vite` is ALWAYS managed (aliased to vite-plus-core). `vitest` is +// managed ONLY when the project uses vitest DIRECTLY — vite-plus consumes +// upstream vitest itself, so a non-vitest project gets it transitively through +// vite-plus and must NOT carry a managed `vitest` pin (which would drift on a +// future `vp update vite-plus`). When `usesVitest` is false the common-case +// removal logic ACTIVELY strips any lingering `vitest` entry. +function managedOverridePackages(usesVitest: boolean): Record { + if (usesVitest) { + return VITE_PLUS_OVERRIDE_PACKAGES; + } + // Drop only `vitest`; every other managed key (e.g. `vite`, and in + // force-override/CI mode the `@voidzero-dev/vite-plus-core` file: alias) stays. + return Object.fromEntries( + Object.entries(VITE_PLUS_OVERRIDE_PACKAGES).filter(([key]) => key !== 'vitest'), + ); +} + +// True iff a dependency field lists a vitest ecosystem package — any name that +// contains `vitest` other than bare `vitest` itself (e.g. `@vitest/coverage-v8`, +// `@vitest/browser-playwright`, `vitest-browser-svelte`). A bare `vitest` +// dependency alone is deliberately NOT a signal — a prior migration may have +// injected it transitively-redundantly, so it must not keep the project pinned +// to a managed `vitest`. This mirrors the `isVitestAdjacent` signal used later +// when deciding to inject a direct `vitest`, so the two stay consistent. +function projectListsVitestEcosystemDep(pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}): boolean { + const dependencyGroups = [ + pkg.dependencies, + pkg.devDependencies, + pkg.optionalDependencies, + pkg.peerDependencies, + ]; + return dependencyGroups.some((deps) => + deps ? Object.keys(deps).some((name) => name !== 'vitest' && name.includes('vitest')) : false, + ); +} + +// True iff the project uses vitest DIRECTLY — via a vitest ecosystem dependency +// (see `projectListsVitestEcosystemDep`), a source file referencing vitest (the +// `vitest` substring matches `vitest` / `@vitest/` import specifiers and not +// `vite-plus/test`), or vitest browser mode (whose published `vite-plus/test/browser*` +// shims carry no `vitest` substring but still need vitest resolvable). Drives +// whether the migration keeps `vitest` managed or removes it entirely; the +// browser-mode arm keeps it aligned with the direct-`vitest` injection below so +// an injected `catalog:` spec never dangles against a vitest-less catalog. +function projectUsesVitestDirectly( + projectPath: string, + pkg: { + dependencies?: Record; + optionalDependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, +): boolean { + return ( + projectListsVitestEcosystemDep(pkg) || + sourceTreeReferencesAny(projectPath, ['vitest']) || + usesVitestBrowserMode(projectPath) + ); +} + +// Common case (`!usesVitest`): vite-plus consumes upstream vitest itself, so a +// lingering `vitest` entry — a managed pin, a stale `npm:@voidzero-dev/vite-plus-test@*` +// wrapper alias, or a `catalog:` reference — must be REMOVED from every sink so +// it arrives transitively through vite-plus and a future `vp update vite-plus` +// keeps it correct with no pin to drift. The `@vitest/*` family is left +// untouched (those are direct-usage signals handled elsewhere). +// +// The removal only applies when `vitest` is a key vite-plus actually manages in +// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` +// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` +// entry there is the user's own and must be left untouched. +const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; + +// Remove a managed `vitest` key from a flat string-valued record (dependency +// field, npm/bun overrides, yarn resolutions, pnpm.overrides, a catalog object). +// Only a STRING value is removed: a managed pin, `catalog:` reference, or wrapper +// alias is always a string, whereas a nested object value (npm/bun `overrides`) +// is a user override scoped under `vitest` and must be left intact. Returns true +// iff an entry was removed. +function removeManagedVitestEntry(record: Record | undefined): boolean { + if (VITEST_IS_MANAGED_OVERRIDE && typeof record?.vitest === 'string') { + delete record.vitest; + return true; + } + return false; +} + +// Remove a managed `vitest` scalar key from a YAMLMap (pnpm-workspace.yaml +// `overrides`, `catalog`, and each named `catalogs` entry). +function removeYamlMapVitestEntry(map: unknown): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(map instanceof YAMLMap)) { + return; + } + const target = map.items.find( + (item) => item.key instanceof Scalar && item.key.value === 'vitest', + )?.key; + if (target) { + map.delete(target); + } +} + +// Remove the managed `vitest` entry from pnpm peerDependencyRules (its +// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both +// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read +// back from pnpm-workspace.yaml. +function removeVitestPeerDependencyRule(peerDependencyRules: { + allowAny?: string[]; + allowedVersions?: Record; +}): void { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return; + } + if (Array.isArray(peerDependencyRules.allowAny)) { + peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest'); + } + if (peerDependencyRules.allowedVersions) { + delete peerDependencyRules.allowedVersions.vitest; + } +} + // Plugins Oxlint resolves natively (no JS import). Source: // `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. // Anything else in the merged `lint.plugins[]` after migration is a @@ -1495,6 +1621,10 @@ export function rewriteStandaloneProject( let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; + // Whether the project uses vitest directly (an `@vitest/*` dep or a source + // reference). Computed inside the callback (where `pkg` is available) and + // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. + let usesVitest = false; // Determined inside editJsonFile callback to avoid a redundant file read let usePnpmWorkspaceYaml = false; editJsonFile<{ @@ -1517,6 +1647,8 @@ export function rewriteStandaloneProject( }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); + usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. pruneLegacyWrapperAliases(pkg.resolutions); @@ -1531,15 +1663,21 @@ export function rewriteStandaloneProject( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no direct vitest): strip a lingering managed `vitest` from + // the npm/bun `overrides` and yarn `resolutions` sinks so it isn't re-pinned. + if (!usesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm || packageManager === PackageManager.bun) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; if (packageManager === PackageManager.bun) { // Bun walks transitive peer-deps before resolving overrides; vitest @@ -1562,18 +1700,26 @@ export function rewriteStandaloneProject( shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); } - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (!usePnpmWorkspaceYaml) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override + its peer + // rules before re-merging. + if (!usesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + if (pkg.pnpm?.peerDependencyRules) { + removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules); + } + } // Project already has pnpm config in package.json -- keep using it. pkg.pnpm = { ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, ...(isForceOverrideMode() ? { [VITE_PLUS_NAME]: VITE_PLUS_VERSION } : {}), }, peerDependencyRules: { @@ -1623,6 +1769,7 @@ export function rewriteStandaloneProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + usesVitest, ); // ensure vite-plus is in devDependencies @@ -1640,7 +1787,12 @@ export function rewriteStandaloneProject( }); if (shouldRewritePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml(projectPath, pnpmMajorVersion, shouldAllowBrowserProviderBuilds); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserProviderBuilds, + usesVitest, + ); } // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml @@ -1655,7 +1807,7 @@ export function rewriteStandaloneProject( } if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest); } else if (packageManager === PackageManager.bun) { ensureBunfigPeerSuppression(projectPath); } @@ -1702,17 +1854,24 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packages, ); + // The SHARED workspace sinks (catalog / overrides / peer rules) keep `vitest` + // managed iff ANY package in the workspace uses vitest directly. + const workspaceUsesVitest = workspaceUsesVitestDirectly( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( workspaceInfo.rootDir, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, ); } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir); + rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest); } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir); + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest); } rewriteRootWorkspacePackageJson( workspaceInfo.rootDir, @@ -1722,6 +1881,7 @@ export function rewriteMonorepo( workspaceInfo.packages, pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, + workspaceUsesVitest, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -1833,6 +1993,7 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), + projectUsesVitestDirectly(projectPath, pkg), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -1877,15 +2038,17 @@ function rewritePnpmWorkspaceYaml( projectPath: string, pnpmMajorVersion: number | undefined, shouldAllowBrowserBuilds: boolean, + usesVitest: boolean, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { fs.writeFileSync(pnpmWorkspaceYamlPath, ''); } + const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -1912,13 +2075,14 @@ function rewritePnpmWorkspaceYaml( } } } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): actively strip any lingering managed + // `vitest` override so it arrives transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['overrides'])); + } + for (const key of Object.keys(managed)) { const currentVersion = getYamlMapScalarStringValue(overrides, key); - const version = getCatalogDependencySpec( - currentVersion, - VITE_PLUS_OVERRIDE_PACKAGES[key], - true, - ); + const version = getCatalogDependencySpec(currentVersion, managed[key], true); doc.setIn(['overrides', scalarString(key)], scalarString(version)); } // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" @@ -1937,8 +2101,12 @@ function rewritePnpmWorkspaceYaml( if (!allowAny) { allowAny = new YAMLSeq>(); } + // Common case: drop any lingering managed `vitest` allowAny entry. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest'); + } const existing = new Set(allowAny.items.map((n) => n.value)); - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + for (const key of Object.keys(managed)) { if (!existing.has(key)) { allowAny.add(scalarString(key)); } @@ -1953,7 +2121,11 @@ function rewritePnpmWorkspaceYaml( if (!allowedVersions) { allowedVersions = new YAMLMap, Scalar>(); } - for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop any lingering managed `vitest` allowedVersions entry. + if (!usesVitest) { + removeYamlMapVitestEntry(allowedVersions); + } + for (const key of Object.keys(managed)) { // - vite: '*' allowedVersions.set(scalarString(key), scalarString('*')); } @@ -2011,10 +2183,17 @@ function cleanupPnpmOverridesForWorkspaceYaml( // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before the exact-key sweep below. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); - // Remove Vite-managed keys from pnpm.overrides + // Remove Vite-managed keys from pnpm.overrides. `vitest` is always swept so a + // lingering managed `vitest` override is dropped in the common case (when it + // is NOT in `overrideKeys` because the project does not use vitest directly) — + // it is deleted but NOT captured as a moved catalog override. + const sweepKeys = + overrideKeys.includes('vitest') || !VITEST_IS_MANAGED_OVERRIDE + ? overrideKeys + : [...overrideKeys, 'vitest']; const catalogOverrides: Record = {}; const overrides = pkg.pnpm?.overrides; - for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { + for (const key of [...sweepKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) { const value = overrides?.[key]; if (value) { if (overrideKeys.includes(key) && value.startsWith('catalog:')) { @@ -2042,8 +2221,10 @@ function cleanupPnpmOverridesForWorkspaceYaml( remaining = { ...remaining, ...pkg.pnpm.overrides }; } delete pkg.pnpm?.overrides; - // Only remove Vite-managed peerDependencyRules entries, preserve custom ones - cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, overrideKeys); + // Only remove Vite-managed peerDependencyRules entries, preserve custom ones. + // `vitest` is always swept (common case: dropped even though it is not in the + // managed `overrideKeys`). + cleanupPeerDependencyRules(pkg.pnpm?.peerDependencyRules, sweepKeys); if (pkg.pnpm?.peerDependencyRules && Object.keys(pkg.pnpm.peerDependencyRules).length === 0) { delete pkg.pnpm.peerDependencyRules; } @@ -2124,6 +2305,32 @@ function workspaceUsesWebdriverio( return false; } +// Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root +// owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, +// bun catalog): `vitest` stays managed there iff ANY package in the workspace — +// the root or any sub-package — uses vitest directly (an `@vitest/*` dep or a +// source reference). See `projectUsesVitestDirectly`. +function workspaceUsesVitestDirectly( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): boolean { + const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(rootDir, rootPkg)) { + return true; + } + if (!packages) { + return false; + } + for (const pkg of packages) { + const packageDir = path.join(rootDir, pkg.path); + const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; + if (projectUsesVitestDirectly(packageDir, subPkg)) { + return true; + } + } + return false; +} + function readPackageJsonIfExists(packageJsonPath: string): DependencyBag | undefined { if (!fs.existsSync(packageJsonPath)) { return undefined; @@ -2494,7 +2701,7 @@ function applyYarnWorkspaceHoistingFix( } } -function rewriteYarnrcYml(projectPath: string): void { +function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { fs.writeFileSync(yarnrcYmlPath, ''); @@ -2525,7 +2732,7 @@ function rewriteYarnrcYml(projectPath: string): void { } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc); + rewriteCatalog(doc, usesVitest); }); } @@ -2677,8 +2884,14 @@ function pruneYamlMapLegacyWrapperAliases(map: unknown): void { } } -function rewriteCatalog(doc: YamlDocument): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): remove any lingering managed `vitest` + // catalog entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeYamlMapVitestEntry(doc.getIn(['catalog'])); + } + for (const [key, value] of Object.entries(managed)) { // ERR_PNPM_CATALOG_IN_OVERRIDES  Could not resolve a catalog in the overrides: The entry for 'vite' in catalog 'default' declares a dependency using the 'file' protocol // ignore setting catalog if value starts with 'file:' if (value.startsWith('file:')) { @@ -2707,7 +2920,12 @@ function rewriteCatalog(doc: YamlDocument): void { if (typeof catalogName !== 'string' || !(item.value instanceof YAMLMap)) { continue; } - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: strip a lingering managed `vitest` entry from this named + // catalog (existing entries only — named catalogs are never grown here). + if (!usesVitest) { + removeYamlMapVitestEntry(item.value); + } + for (const [key, value] of Object.entries(managed)) { const catalogPath = ['catalogs', catalogName, key]; if (!value.startsWith('file:') && doc.hasIn(catalogPath)) { doc.setIn(catalogPath, scalarString(value)); @@ -2727,8 +2945,18 @@ function rewriteCatalog(doc: YamlDocument): void { } } -function rewriteCatalogObject(catalog: Record, addMissing: boolean): void { - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { +function rewriteCatalogObject( + catalog: Record, + addMissing: boolean, + usesVitest: boolean, +): void { + const managed = managedOverridePackages(usesVitest); + // Common case (no direct vitest): strip a lingering managed `vitest` catalog + // entry so it resolves transitively through vite-plus. + if (!usesVitest) { + removeManagedVitestEntry(catalog); + } + for (const [key, value] of Object.entries(managed)) { if (value.startsWith('file:') || (!addMissing && !(key in catalog))) { continue; } @@ -2742,9 +2970,12 @@ function rewriteCatalogObject(catalog: Record, addMissing: boole } } -function rewriteCatalogsObject(catalogs: Record>): void { +function rewriteCatalogsObject( + catalogs: Record>, + usesVitest: boolean, +): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false); + rewriteCatalogObject(catalog, false, usesVitest); } } @@ -2790,11 +3021,12 @@ function ensureBunfigPeerSuppression(projectPath: string): void { * unlike pnpm which uses pnpm-workspace.yaml. * @see https://bun.sh/docs/pm/catalogs */ -function rewriteBunCatalog(projectPath: string): void { +function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(usesVitest); editJsonFile<{ workspaces?: NpmWorkspaces; @@ -2812,30 +3044,30 @@ function rewriteBunCatalog(projectPath: string): void { ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - rewriteCatalogObject(catalog, true); + rewriteCatalogObject(catalog, true, usesVitest); pruneLegacyWrapperAliases(catalog); if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false); + rewriteCatalogObject(pkg.catalog, false, usesVitest); pruneLegacyWrapperAliases(pkg.catalog); } } else { pkg.catalog = catalog; if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false); + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest); pruneLegacyWrapperAliases(workspacesObj.catalog); } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs); + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs); + rewriteCatalogsObject(pkg.catalogs, usesVitest); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -2844,7 +3076,13 @@ function rewriteBunCatalog(projectPath: string): void { // bun overrides support catalog: references const overrides: Record = { ...pkg.overrides }; pruneLegacyWrapperAliases(overrides); - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case (no direct vitest): strip a lingering managed `vitest` + // override (string-valued only — a nested user override is left intact; + // removeManagedVitestEntry also no-ops when vitest is not a managed key). + if (!usesVitest && typeof overrides.vitest === 'string') { + removeManagedVitestEntry(overrides); + } + for (const [key, value] of Object.entries(managed)) { const current = overrides[key] as unknown; // A nested object value is a user override scoped under this managed key, // not a version pin — leave it intact (getCatalogDependencySpec expects a @@ -2877,11 +3115,16 @@ function rewriteRootWorkspacePackageJson( packages?: WorkspacePackage[], pnpmMajorVersion?: number, shouldAllowBrowserBuilds = false, + // Workspace-wide direct-vitest signal: the root resolution/override sinks are + // shared by every package, so `vitest` stays managed here iff ANY package uses + // vitest directly. + workspaceUsesVitest = true, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } + const managed = managedOverridePackages(workspaceUsesVitest); let remainingPnpmOverrides: Record | undefined; editJsonFile<{ @@ -2915,17 +3158,23 @@ function rewriteRootWorkspacePackageJson( // the bundled-vitest-aligned 4.1.9. (The pnpm sinks are pruned below.) dropRemovePackageOverrideKeys(pkg.resolutions); dropRemovePackageOverrideKeys(pkg.overrides); + // Common case (no workspace-wide direct vitest): strip a lingering managed + // `vitest` from the shared root sinks so it isn't re-pinned. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.resolutions); + removeManagedVitestEntry(pkg.overrides); + } if (packageManager === PackageManager.yarn) { pkg.resolutions = { ...pkg.resolutions, // FIXME: yarn don't support catalog on resolutions // https://github.com/yarnpkg/berry/issues/6979 - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.npm) { pkg.overrides = { ...pkg.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, }; } else if (packageManager === PackageManager.bun) { // bun overrides are handled in rewriteBunCatalog() with catalog: references @@ -2943,12 +3192,16 @@ function rewriteRootWorkspacePackageJson( ), }; } else if (packageManager === PackageManager.pnpm) { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); + const overrideKeys = Object.keys(managed); if (isForceOverrideMode()) { // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`) // whose target is a removed package, before re-merging the user's // overrides into the new pnpm config. dropRemovePackageOverrideKeys(pkg.pnpm?.overrides); + // Common case: drop a lingering managed `vitest` override before merging. + if (!workspaceUsesVitest) { + removeManagedVitestEntry(pkg.pnpm?.overrides); + } // In force-override mode, keep overrides in package.json pnpm.overrides // because pnpm ignores pnpm-workspace.yaml overrides when pnpm.overrides // exists in package.json (even with unrelated entries like rollup). @@ -2956,7 +3209,7 @@ function rewriteRootWorkspacePackageJson( ...pkg.pnpm, overrides: { ...pkg.pnpm?.overrides, - ...VITE_PLUS_OVERRIDE_PACKAGES, + ...managed, [VITE_PLUS_NAME]: VITE_PLUS_VERSION, }, }; @@ -3215,9 +3468,15 @@ function overrideSpecSatisfiesVitePlus( function overridesSatisfyVitePlus( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).every((dependencyName) => + // Common case: a lingering managed `vitest` override is NOT satisfied — it + // must be removed, so the bootstrap stays pending until it is. + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && overrides?.vitest !== undefined) { + return false; + } + return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => overrideSpecSatisfiesVitePlus( dependencyName, overrides?.[dependencyName], @@ -3255,16 +3514,36 @@ function pnpmPeerDependencyRulesSatisfyVitePlus( peerDependencyRules: | { allowAny?: string[]; allowedVersions?: Record } | undefined, + usesVitest: boolean, ): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); const allowAny = new Set(peerDependencyRules?.allowAny ?? []); const allowedVersions = peerDependencyRules?.allowedVersions ?? {}; + // Common case: a lingering managed `vitest` peer rule is NOT satisfied. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + (allowAny.has('vitest') || allowedVersions.vitest !== undefined) + ) { + return false; + } + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*'); } -function npmVitePlusManagedDependenciesPending(pkg: BootstrapPackageJson): boolean { +function npmVitePlusManagedDependenciesPending( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return Object.keys(VITE_PLUS_OVERRIDE_PACKAGES).some((dependencyName) => + // Common case: a lingering managed `vitest` install dep is pending removal. + if ( + !usesVitest && + VITEST_IS_MANAGED_OVERRIDE && + dependencyGroups.some((dependencies) => dependencies?.vitest !== undefined) + ) { + return true; + } + return Object.keys(managedOverridePackages(usesVitest)).some((dependencyName) => dependencyGroups.some( (dependencies) => dependencies?.[dependencyName] !== undefined && @@ -3309,7 +3588,7 @@ function readPnpmWorkspacePeerDependencyRules( return doc?.peerDependencyRules; } -function yarnrcSatisfiesVitePlus(projectPath: string): boolean { +function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { return false; @@ -3321,7 +3600,7 @@ function yarnrcSatisfiesVitePlus(projectPath: string): boolean { return ( !!doc && Object.hasOwn(doc, 'nodeLinker') && - overridesSatisfyVitePlus(doc.catalog) && + overridesSatisfyVitePlus(doc.catalog, usesVitest) && (VITE_PLUS_VERSION.startsWith('file:') || doc.catalog?.[VITE_PLUS_NAME] === VITE_PLUS_VERSION) ); } @@ -3437,32 +3716,47 @@ export function detectVitePlusBootstrapPending( return true; } + // `vitest` is managed only when the project uses it directly; otherwise a + // lingering managed `vitest` entry is treated as pending so the bootstrap + // removes it (and a second detect after removal returns false). + const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + if (packageManager === PackageManager.yarn) { - return !overridesSatisfyVitePlus(pkg.resolutions) || !yarnrcSatisfiesVitePlus(projectPath); + return ( + !overridesSatisfyVitePlus(pkg.resolutions, usesVitest) || + !yarnrcSatisfiesVitePlus(projectPath, usesVitest) + ); } if (packageManager === PackageManager.npm) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.overrides) || - npmVitePlusManagedDependenciesPending(pkg) + !overridesSatisfyVitePlus(pkg.overrides, usesVitest) || + npmVitePlusManagedDependenciesPending(pkg, usesVitest) ); } if (packageManager === PackageManager.bun) { - return !overridesSatisfyVitePlus(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + return !overridesSatisfyVitePlus( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); } if (packageManager === PackageManager.pnpm) { if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || - !overridesSatisfyVitePlus(pkg.pnpm?.overrides) || - !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules) + !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || + !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) ); } const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); return ( defaultCatalogVitePlusDependencyPending(pkg, resolver) || - !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), resolver) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !overridesSatisfyVitePlus(readPnpmWorkspaceOverrides(projectPath), usesVitest, resolver) || + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ); } @@ -3478,7 +3772,10 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin // (workspace:, link:, file:, npm:, github:, git, http) are preserved. const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; for (const dependencies of dependencyGroups) { - const spec = dependencies?.[VITE_PLUS_NAME]; + if (dependencies === undefined) { + continue; + } + const spec = dependencies[VITE_PLUS_NAME]; if (spec === undefined || spec === version) { continue; } @@ -3510,11 +3807,18 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin function ensureOverrideEntries( overrides: Record | undefined, + usesVitest: boolean, catalogDependencyResolver?: CatalogDependencyResolver, ): { overrides: Record; changed: boolean } { const next = { ...overrides }; let changed = false; - for (const [dependencyName, overrideSpec] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: drop a lingering managed `vitest` override. + if (!usesVitest && removeManagedVitestEntry(next)) { + changed = true; + } + for (const [dependencyName, overrideSpec] of Object.entries( + managedOverridePackages(usesVitest), + )) { if ( !overrideSpecSatisfiesVitePlus( dependencyName, @@ -3529,10 +3833,21 @@ function ensureOverrideEntries( return { overrides: next, changed }; } -function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolean { +function ensureNpmVitePlusManagedDependencies( + pkg: BootstrapPackageJson, + usesVitest: boolean, +): boolean { let changed = false; const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - for (const [dependencyName, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + // Common case: strip a lingering managed `vitest` install dep. + if (!usesVitest) { + for (const dependencies of dependencyGroups) { + if (removeManagedVitestEntry(dependencies)) { + changed = true; + } + } + } + for (const [dependencyName, version] of Object.entries(managedOverridePackages(usesVitest))) { for (const dependencies of dependencyGroups) { if ( dependencies?.[dependencyName] !== undefined && @@ -3546,14 +3861,29 @@ function ensureNpmVitePlusManagedDependencies(pkg: BootstrapPackageJson): boolea return changed; } -function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson): boolean { - const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); +function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { + const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); pkg.pnpm ??= {}; + // Common case: drop a lingering managed `vitest` peer rule from the source + // shape before re-deriving the managed rules. + const seed = { ...pkg.pnpm.peerDependencyRules } as { + allowAny?: string[]; + allowedVersions?: Record; + }; + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) { + if (Array.isArray(seed.allowAny)) { + seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest'); + } + if (seed.allowedVersions) { + seed.allowedVersions = { ...seed.allowedVersions }; + delete seed.allowedVersions.vitest; + } + } const peerDependencyRules = { - ...pkg.pnpm.peerDependencyRules, - allowAny: [...new Set([...(pkg.pnpm.peerDependencyRules?.allowAny ?? []), ...overrideKeys])], + ...seed, + allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])], allowedVersions: { - ...pkg.pnpm.peerDependencyRules?.allowedVersions, + ...seed.allowedVersions, ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])), }, }; @@ -3579,6 +3909,16 @@ export function ensureVitePlusBootstrap( return result; } + // Whether the project uses vitest directly (an `@vitest/*` dep or a source + // reference). Read up front so it is available to the post-callback + // pnpm-workspace.yaml / .yarnrc.yml / bun catalog rewrites too. `vitest` stays + // managed only when true; otherwise the bootstrap REMOVES any lingering + // managed `vitest` entry from every sink. + const usesVitest = projectUsesVitestDirectly( + projectPath, + readJsonFile(packageJsonPath) as BootstrapPackageJson, + ); + editJsonFile< BootstrapPackageJson & { workspaces?: NpmWorkspaces; @@ -3598,23 +3938,27 @@ export function ensureVitePlusBootstrap( ); packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg) || packageJsonChanged; + packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; } if (workspaceInfo.packageManager === PackageManager.yarn) { - const ensured = ensureOverrideEntries(pkg.resolutions); + const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); if (ensured.changed) { pkg.resolutions = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.npm) { - const ensured = ensureOverrideEntries(pkg.overrides); + const ensured = ensureOverrideEntries(pkg.overrides, usesVitest); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; } } else if (workspaceInfo.packageManager === PackageManager.bun) { - const ensured = ensureOverrideEntries(pkg.overrides, readBunCatalogDependencyResolver(pkg)); + const ensured = ensureOverrideEntries( + pkg.overrides, + usesVitest, + readBunCatalogDependencyResolver(pkg), + ); if (ensured.changed) { pkg.overrides = ensured.overrides; packageJsonChanged = true; @@ -3626,12 +3970,12 @@ export function ensureVitePlusBootstrap( // `pnpmConfigLivesInPackageJson` guarantees `pkg.pnpm` is present here, // but it may be an empty object (no pnpm-workspace.yaml case), so seed it. pkg.pnpm ??= {}; - const ensured = ensureOverrideEntries(pkg.pnpm.overrides); + const ensured = ensureOverrideEntries(pkg.pnpm.overrides, usesVitest); if (ensured.changed) { pkg.pnpm.overrides = ensured.overrides; packageJsonChanged = true; } - packageJsonChanged = ensurePnpmPeerDependencyRules(pkg) || packageJsonChanged; + packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; } result.packageJson = packageJsonChanged; @@ -3650,16 +3994,20 @@ export function ensureVitePlusBootstrap( defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), + usesVitest, catalogDependencyResolver, ) || - !pnpmPeerDependencyRulesSatisfyVitePlus(readPnpmWorkspacePeerDependencyRules(projectPath)) + !pnpmPeerDependencyRulesSatisfyVitePlus( + readPnpmWorkspacePeerDependencyRules(projectPath), + usesVitest, + ) ) { // Bootstrap only completes the catalog / overrides / peer rules for a // project that already uses Vite+. Build-script allowance stays owned // by the full migration paths, so pass an undefined pnpm major to skip // it (mirrors the single-arg call this path used before the signature // grew the build-allowance parameters). - rewritePnpmWorkspaceYaml(projectPath, undefined, false); + rewritePnpmWorkspaceYaml(projectPath, undefined, false, usesVitest); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); @@ -3674,12 +4022,12 @@ export function ensureVitePlusBootstrap( const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath); + rewriteYarnrcYml(projectPath, usesVitest); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath); + rewriteBunCatalog(projectPath, usesVitest); const after = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageJson = result.packageJson || before !== after; } @@ -3939,6 +4287,12 @@ export function rewritePackageJson( // `@vitest/browser-webdriverio` → true). A provider with no dep declared but // imported in source still gets kept/injected. providerSourceModes?: Partial>, + // Whether the project uses vitest DIRECTLY (an `@vitest/*` dep or a source + // reference). `vitest` is managed (and a managed dep/override pin kept) only + // when true; in the common case (`false`) a lingering managed `vitest` entry + // is REMOVED so it arrives transitively through vite-plus. Defaults to true to + // preserve legacy behavior for callers that don't compute the signal. + usesVitestDirectly = true, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -3977,7 +4331,29 @@ export function rewritePackageJson( needVitePlus = true; } } - for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + const managed = managedOverridePackages(usesVitestDirectly); + // Common case (no direct vitest): vite-plus consumes upstream vitest itself, + // so ACTIVELY REMOVE any lingering managed `vitest` dependency (a managed pin, + // a `catalog:` reference, or a stale wrapper alias already normalized above) — + // it arrives transitively through vite-plus and a future `vp update vite-plus` + // keeps it correct with no pin to drift. The `@vitest/*` family and unrelated + // keys are untouched. (Browser-mode / vitest-adjacent projects re-add a direct + // `vitest` below; those are direct-usage signals, so this never strips one a + // surviving consumer needs.) + if (!usesVitestDirectly) { + // Only the INSTALL groups — a `peerDependencies` `vitest` is a declaration + // about consumers (coerced to `*` via PUBLIC_PEER_DEPENDENCY_FALLBACKS), + // not an install pin, so it is left as-is. + for (const { dependencyField, dependencies } of dependencyGroups) { + if (dependencyField === 'peerDependencies') { + continue; + } + if (removeManagedVitestEntry(dependencies)) { + needVitePlus = true; + } + } + } + for (const [key, version] of Object.entries(managed)) { for (const { dependencyField, dependencies } of dependencyGroups) { if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { From a3f23d67a286d5e1e40036943d624c500a68e8be Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 17:28:30 +0800 Subject: [PATCH 06/32] feat(migrate): align the full @vitest/* ecosystem to the bundled vitest Every official @vitest/* package is versioned in lockstep with vitest and carries an exact 'vitest: ' peer (verified against the registry), so any the project lists must match what vite-plus ships or Vitest runs mixed copies. The previous code only aligned the two coverage providers, leaving @vitest/ui and @vitest/web-worker behind. Replace the hardcoded coverage-provider list with a predicate (isAlignableVitestEcosystemPackage): align any @vitest/* dependency to VITEST_VERSION except @vitest/eslint-plugin, which versions on its own line with a 'vitest: *' peer. Third-party integrations (vitest-browser-*) are not @vitest/* and keep their existing handling (range peer, own versioning, kept with a managed vitest + override). Rename alignVitestCoverageProviders -> alignVitestEcosystemPackages and vitestCoverageProvidersPending -> vitestEcosystemPackagesPending. Add a test asserting @vitest/ui and @vitest/web-worker align while @vitest/eslint-plugin is left untouched. --- .../src/migration/__tests__/migrator.spec.ts | 33 ++++++++++ packages/cli/src/migration/migrator.ts | 64 +++++++++++-------- ...e-path.md => migrate-existing-projects.md} | 0 3 files changed, 71 insertions(+), 26 deletions(-) rename rfcs/{migrate-upgrade-path.md => migrate-existing-projects.md} (100%) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 14ac250967..4901be6724 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1463,6 +1463,39 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('aligns the full @vitest/* ecosystem (ui, web-worker) but leaves @vitest/eslint-plugin alone', () => { + // Every official @vitest/* package carries an exact `vitest` peer, so each + // must match the bundled vitest. @vitest/eslint-plugin versions on its own + // line (`vitest: *` peer) and must NOT be pinned to the vitest version. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/ui': '^4.1.0', + '@vitest/web-worker': '^4.1.0', + '@vitest/eslint-plugin': '^1.0.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.0.0'); + }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that // does NOT use vitest directly must NOT carry a managed `vitest` override — diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 59997159de..6f7e05710f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -105,12 +105,19 @@ const PLAYWRIGHT_PROVIDER = '@vitest/browser-playwright'; // forcing pins dropped, while their catalog entries are PRESERVED. const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as const; -// Coverage providers are project-installed peers (NOT bundled by vite-plus). -// Vitest pins each to an exact runner version, and the `define-config.ts` guard -// fail-fasts when an installed provider skews from the bundled vitest (Vitest -// otherwise runs mixed versions and yields unreliable coverage). The upgrade -// aligns any the project lists to the bundled `VITEST_VERSION`. -const VITEST_COVERAGE_PROVIDERS = ['@vitest/coverage-v8', '@vitest/coverage-istanbul'] as const; +// Official `@vitest/*` packages are versioned in lockstep with vitest and carry +// an EXACT `vitest` peer (verified against the registry: `@vitest/coverage-v8`, +// `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, the browser +// family, and the runtime internals all pin `vitest: `), so any the +// project lists must match the bundled vitest or Vitest runs mixed copies (the +// `define-config.ts` coverage guard fail-fasts on exactly this skew). +// `@vitest/eslint-plugin` is the exception: it versions on its own line with a +// `vitest: *` peer, so it must NOT be pinned to the vitest version. +const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); + +function isAlignableVitestEcosystemPackage(name: string): boolean { + return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); +} // Provider names whose stale pnpm overrides / resolutions are dropped during // migration: everything vite-plus owns (REMOVE_PACKAGES) plus the user-owned @@ -3659,16 +3666,19 @@ function pnpmConfigLivesInPackageJson( ); } -// Pin any coverage provider the project lists to the bundled vitest version. -// Returns true if any spec changed. Providers are plain dependency entries -// (not overrides), so this is package-manager agnostic. -function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { +// Pin every alignable `@vitest/*` package the project lists to the bundled +// vitest version. Returns true if any spec changed. These are plain dependency +// entries (not overrides), so this is package-manager agnostic. +function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; let changed = false; - for (const provider of VITEST_COVERAGE_PROVIDERS) { - for (const dependencies of dependencyGroups) { - if (dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION) { - dependencies[provider] = VITEST_VERSION; + for (const dependencies of dependencyGroups) { + if (!dependencies) { + continue; + } + for (const name of Object.keys(dependencies)) { + if (isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION) { + dependencies[name] = VITEST_VERSION; changed = true; } } @@ -3676,15 +3686,17 @@ function alignVitestCoverageProviders(pkg: BootstrapPackageJson): boolean { return changed; } -// True when the project lists a coverage provider at a version other than the -// bundled vitest, so the bootstrap should run to realign it. -function vitestCoverageProvidersPending(pkg: BootstrapPackageJson): boolean { +// True when the project lists an alignable `@vitest/*` package at a version +// other than the bundled vitest, so the bootstrap should run to realign it. +function vitestEcosystemPackagesPending(pkg: BootstrapPackageJson): boolean { const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return VITEST_COVERAGE_PROVIDERS.some((provider) => - dependencyGroups.some( - (dependencies) => - dependencies?.[provider] !== undefined && dependencies[provider] !== VITEST_VERSION, - ), + return dependencyGroups.some((dependencies) => + dependencies + ? Object.keys(dependencies).some( + (name) => + isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION, + ) + : false, ); } @@ -3706,9 +3718,9 @@ export function detectVitePlusBootstrapPending( return true; } - // A coverage provider skewed from the bundled vitest needs realigning, - // independent of the package manager's override shape. - if (vitestCoverageProvidersPending(pkg)) { + // A `@vitest/*` ecosystem package skewed from the bundled vitest needs + // realigning, independent of the package manager's override shape. + if (vitestEcosystemPackagesPending(pkg)) { return true; } @@ -3936,7 +3948,7 @@ export function ensureVitePlusBootstrap( pkg, supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, ); - packageJsonChanged = alignVitestCoverageProviders(pkg) || packageJsonChanged; + packageJsonChanged = alignVitestEcosystemPackages(pkg) || packageJsonChanged; if (workspaceInfo.packageManager === PackageManager.npm) { packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; } diff --git a/rfcs/migrate-upgrade-path.md b/rfcs/migrate-existing-projects.md similarity index 100% rename from rfcs/migrate-upgrade-path.md rename to rfcs/migrate-existing-projects.md From 85b9ae382082e4f91a4712744f4399bebedcece8 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 17:28:49 +0800 Subject: [PATCH 07/32] docs(rfc): revise migrate RFC for vitest provisioning and ecosystem rules Reflect the validated design after the urllib (#832-834) and snap-test findings: - vite-plus declares vitest as a dependency, so vitest is provided transitively; by default the migration removes any project-level vitest instead of pinning it. - Exception: keep a managed vitest (devDep + override) when a non-exact vitest peer must be collapsed (third-party vitest-browser-*, browser mode, or a direct vitest source import), verified by migration-vitest-peer-dep. - Align every official @vitest/* (exact peer) to the bundled version; add a verified ecosystem table; exclude @vitest/eslint-plugin. Also lead with the two-command upgrade UX (vp upgrade && vp migrate), present the rules as a table, drop the resolved open questions, and rename the file to migrate-existing-projects.md with a clearer title. --- rfcs/migrate-existing-projects.md | 225 +++++++----------------------- 1 file changed, 54 insertions(+), 171 deletions(-) diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 29207563db..f2b8385864 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -1,192 +1,75 @@ -# RFC: `vp migrate` Upgrade Path for Existing Vite+ Projects +# RFC: Migrating Existing Vite+ Projects to a New Version -- Status: Draft (for discussion) -- Depends on: [#1588 refactor: replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) -- Spec source: the ["Upgrading from 0.1.x to 0.2.1 Prompt"](https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.1) in the v0.2.1 release notes -- Related: [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md), `docs/guide/upgrade.md` +- Status: Partially implemented on `rfc/migrate-upgrade-path` (commits `03689668`, `3e5a5137`); vitest-removal simplification and browser-mode verification pending (see Follow-ups) +- Depends on: [#1588 replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) +- Related: `docs/guide/upgrade.md`, [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md) -## Summary +## Goal: upgrade in two commands -The v0.2.1 release notes ship a careful, manual AI-agent prompt for upgrading a project from v0.1.x and explicitly say: +Any later Vite+ upgrade is two commands: upgrade the global CLI, then migrate the project. -> Do not run `vp migrate` for this upgrade; it is not reliable enough yet. Make the changes yourself by editing the project's files, then verify by running the tools. - -That prompt is the authoritative description of the correct end state. This RFC's goal is to make `vp migrate` reliably reproduce that end state so the disclaimer can be removed. The prompt also corrects a key assumption in earlier drafts of this RFC: the upgrade is NOT "always pin `vitest`". It is a usage-based decision that, in the common case, REMOVES `vitest` from the project entirely and lets it arrive transitively through `vite-plus`. - -## Background - -PR #1588 (shipped in v0.2.0) deleted the bundled `@voidzero-dev/vite-plus-test` wrapper and consumes upstream `vitest` directly. Today `ensureVitePlusBootstrap` (`migrator.ts`) unconditionally writes a managed `vitest` entry (pinned to `VITEST_VERSION`, currently `4.1.9`) into the project's override/catalog block for every already-Vite+ project, alongside the `vite` -> `npm:@voidzero-dev/vite-plus-core@latest` alias. `@vitest/*` runtime internals are NOT pinned (they are exact deps of `vitest`); coverage providers (`@vitest/coverage-v8` / `-istanbul`) are NOT managed and only get a runtime skew guard in `define-config.ts`. - -### What #1588 already handles - -PR #1588 added an "existing Vite+ project" repair path: `detectVitePlusBootstrapPending` + `ensureVitePlusBootstrap`, wired into the "already using Vite+" branch of `bin.ts` with one reinstall via `handleInstallResult`. It rewrites a stale `vitest: npm:@voidzero-dev/vite-plus-test@*` wrapper alias to the bundled vitest, proven by the `migration-already-vite-plus` snap fixture (even under `--no-interactive`). This is the foundation to build on, but as the prompt and the urllib evidence below show, it does the wrong thing in two ways: it pins `vitest` even when the project does not use it, and it misses several stale shapes. - -### The real gap: upgrading a v0.1.x project (urllib) - -`vp migrate` on a real 0.1.24 project (`node-modules/urllib`) did NOT upgrade. Its `package.json`: - -```jsonc -{ - "devDependencies": { - "@vitest/coverage-v8": "^4.1.8", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", - "vite-plus": "^0.1.24", - "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" - }, - "overrides": {}, - "pnpm": {}, - "packageManager": "pnpm@11.7.0" -} -``` - -plus a committed `pnpm-workspace.yaml` written by the old CLI that actively pins the stack to 0.1.x: - -```yaml -overrides: - vite: 'npm:@voidzero-dev/vite-plus-core@^0.1.24' # forces core to 0.1.x - vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24' # forces the deleted wrapper +```bash +vp upgrade # update the global `vp` binary +vp migrate # bring the project up to the new toolchain ``` -Observed blockers, each sufficient on its own: - -1. **Routing: the stale local CLI runs.** `vp migrate` delegates **local-first** (`crates/vite_global_cli/src/commands/delegate.rs`). urllib has `vite-plus@0.1.24` installed, so the global `vp v0.2.x` delegated to the **0.1.24** CLI, which predates #1588 and has no upgrade logic. Worse, that old CLI rewrites the old-shape `pnpm-workspace.yaml`, pinning `vite`/`vitest` to `^0.1.24` and the dead wrapper, which then blocks any later upgrade. The documented `vp update vite-plus --latest && vp migrate` flow does not escape this, because `vp update` deliberately does not reconcile those pins (`docs/guide/upgrade.md`). - -2. **The v0.1.x shapes are not repaired by the v0.2.x bootstrap.** `ensureVitePlusDependencySpecs` only re-pins `vite-plus` when its spec is `catalog:` or absent, so a pinned `^0.1.24` is left untouched and never reaches the target. The inline `vite`/`vitest` aliases in `devDependencies` are never rewritten. The `vite` override is a **behind core alias** (`core@^0.1.24`), not the dead wrapper, so the wrapper-only `pruneLegacyWrapperAliases` does not normalize it. - -3. **Empty `"pnpm": {}` misroutes the repair.** Both `detectVitePlusBootstrapPending` and `ensureVitePlusBootstrap` branch on `if (pkg.pnpm)`, and `{}` is truthy, so they inspect `pkg.pnpm.overrides` (empty) and take the `if (!pkg.pnpm)` -> false path that **skips the `pnpm-workspace.yaml` rewrite entirely**. A fresh override block lands in `package.json` while the pinning overrides in `pnpm-workspace.yaml` survive, leaving two conflicting override sources. This is effectively a standalone bug in #1588. - -### What the v0.2.1 prompt specifies (the correct end state) - -The prompt encodes the upgrade as these steps (paraphrased; see the release for verbatim text): - -1. **Set `vite-plus` to the exact target version (`0.2.1`) and reinstall**, in every workspace package that depends on it. "Changing the spec to `0.2.1` is what moves the lockfile off the old resolution; a reinstall that leaves the spec unchanged would keep the old version." Exact, not a range or `latest`. -2. **Remove the `@voidzero-dev/vite-plus-test` wrapper everywhere** (package.json, lockfile, pnpm-workspace.yaml / .yarnrc.yml catalogs, source imports). Then a **usage-based decision**: - - The project depends on vitest directly ONLY IF a source/test file imports from `vitest` or `@vitest/...`, OR a `@vitest/*` package is in its deps (e.g. a coverage provider). Imports from `vite-plus/test` do NOT count. - - **Common case (no direct usage): remove vitest configuration entirely.** Delete the `vitest` entry from dependencies in whatever form (wrapper alias, `catalog:`, plain version), and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, pnpm `overrides`/`catalog` in package.json or pnpm-workspace.yaml, any catalog). Do NOT add a pinned `vitest`; it arrives transitively through `vite-plus`. - - **Direct-usage case: pin upstream vitest to the bundled version (`4.1.9`) and align the whole ecosystem.** Set every `@vitest/*` the project lists (`coverage-v8`, `ui`, `browser`, ...) to that same version, and update other integration packages (`vitest-browser-*`) to a compatible release. "Leaving an ecosystem package on an older version pulls in a second copy of vitest, which Vitest rejects at runtime." - - Delete dependency-resolution config that existed only for the wrapper/old vitest: pnpm `peerDependencyRules` (`allowedVersions` / `ignoreMissing`) referencing `vitest` / `@vitest/*` / the wrapper, and yarn `packageExtensions` equivalents. Leave unrelated rules. -3. **Keep the `vite` -> core override, pinned to the exact target**: `vite` -> `npm:@voidzero-dev/vite-plus-core@0.2.1`, in whatever override/resolution/catalog form the project already uses. Core is released in lockstep with `vite-plus`. -4. **Leave `vite-plus/test*` imports unchanged**; only repoint direct `@voidzero-dev/vite-plus-test` imports to `vite-plus/test`. -5. **Reinstall and verify**: no `@voidzero-dev/vite-plus-test` references remain outside `node_modules`; the tree resolves to a **single** `vitest` version (no duplicates); tests pass (native Vitest banner); the `vp check` workflow passes. - -Constraints: do not bypass git hooks (report pre-existing failures instead); make the smallest set of edits; end with a short summary. - -Two insights from this change the design: - -- **The common case is removal, not pinning.** Removing `vitest` (rather than pinning it to an exact version) is what lets future `vp update vite-plus` keep vitest correct automatically: there is no project-level pin to drift. urllib is NOT the common case (it lists `@vitest/coverage-v8`), so it takes the direct-usage branch: pin `vitest` to `4.1.9` and set `@vitest/coverage-v8` to `4.1.9`, which is exactly the version it was missing. -- **Exactness moves the lockfile.** The upgrade must write exact target versions for `vite-plus` and the core alias, in every workspace package, or the lockfile keeps resolving the old version. +Both are needed, and the order matters. `vp migrate` normally runs the project's **local** `vite-plus`, which on an old project predates the new upgrade logic (and would even rewrite config that pins the project to the old version). So `vp upgrade` first makes a new-enough CLI available, and `vp migrate` then escalates to it (see Routing) and applies the rules below. `vp update vite-plus` alone is not enough: it bumps the dependency but does not reconcile the override/catalog config. -## Goals +`vp migrate` is idempotent: on an already-current project it reports "already using Vite+" and changes nothing. -1. `vp migrate` reliably reproduces the v0.2.1 prompt's end state for a v0.1.x project, so the "do not run `vp migrate`" disclaimer can be dropped. -2. Run the upgrade with a CLI new enough to contain this logic (fix the local-first routing that runs a stale 0.1.x CLI). -3. Implement the usage-based vitest decision: remove vitest entirely in the common case; pin + align the ecosystem in the direct-usage case. -4. Pin `vite-plus` and the `vite`->core alias to the exact target version, in every workspace package, so the lockfile moves. -5. Repair all observed stale shapes: inline/behind aliases, the empty-`pnpm` misrouting, dual override sources, and wrapper-only peer config. -6. Verify the end state (no wrapper refs, single vitest version) and respect the prompt's constraints (git hooks, minimal edits, summary). Idempotent on re-run. +## Migrate rules -## Non-Goals +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so a project never needs its own `vitest`. It resolves transitively, and an ecosystem package resolves its exact `vitest` peer against it. Verified on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)): with the direct `vitest` removed, coverage stays green on all three. The complementary forced-single case (a third-party `vitest-browser-svelte` keeps a managed `vitest`) is covered by the `migration-vitest-peer-dep` snap test. -- Changing behavior for projects that do not yet use `vite-plus` (the full-migration path already writes the canonical shape). -- Rewriting user source beyond repointing direct `@voidzero-dev/vite-plus-test` imports; `vite-plus/test*` stays the stable public API. -- Pinning the `@vitest/*` runtime internals individually (they cascade from `vitest`). The ecosystem alignment in the direct-usage case targets the packages the project itself lists, not transitive internals. +| Area | Rule | +| ---- | ---- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, forced-single exception | Keep a managed `vitest` (add to `devDependencies` **and** override/pin it to the bundled version) when the project has a **non-exact** `vitest` peer to collapse: a third-party integration on a range peer (`vitest-browser-react` / `-vue` / `-svelte`, ...), vitest browser mode, or a direct `vitest` source import. The override forces the range down to `vite-plus`'s exact version (one copy); the `devDependencies` entry satisfies the peer deterministically. Official `@vitest/*` (exact peer) do NOT trigger this, their exact peer already dedupes to `vite-plus`'s vitest. | +| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`, since each carries an **exact** `vitest` peer. Exclude `@vitest/eslint-plugin` (separate version line, `vitest: *` peer). Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | -## Design +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. -### 1. Run the right vp (routing) +**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). That predates `vite-plus` declaring `vitest`+`@vitest/browser` as dependencies and may now be obsolete, but it is not yet confirmed across package managers, so the browser-mode injection stays until a urllib-style 3-PM check clears it. -The upgrade logic must execute from a CLI at least as new as the target. The prompt's manual workaround is "after any install, re-resolve vp so you always run the version currently in the project." Automate the same idea: +## Vitest ecosystem packages -- In the global CLI (`crates/vite_global_cli`), before delegating `migrate` local-first, read the local `vite-plus` version and compare to the global `vp`. If local is older, run `migrate` from the **global** JS CLI (`delegate_to_global_cli`) instead of the stale local one. The global CLI's constants (target version, `VITEST_VERSION`) are then self-consistent. -- The upgrade re-pins `vite-plus` to the global version and reinstalls, so the next `vp` in the project resolves to the upgraded local CLI. +How each package the `vitest` ecosystem rule covers is handled, verified against the registry at `4.1.9`. The code rule: align any `@vitest/*` the project lists to `VITEST_VERSION`, except `@vitest/eslint-plugin`; the browser packages additionally follow their bundled/opt-in handling. -This is mandatory, not polish: the stale local CLI does not just no-op, it writes pinning overrides that block the upgrade. Simpler alternative (Open Questions): always route `migrate` through the global CLI. +| Package | `vitest` peer | Handling | +| ------- | ------------- | -------- | +| `@vitest/coverage-v8` | `4.1.9` (exact) | align to `VITEST_VERSION` | +| `@vitest/coverage-istanbul` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/ui` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/web-worker` | `4.1.9` | align to `VITEST_VERSION` | +| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`) | +| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`) | +| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` peer | +| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` peer | +| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` | none | transitive deps of `vitest`; `vite-plus` provides them, the project does not list them | +| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, **and** a managed `vitest` is kept (devDep + override) to force a single copy against the range peer | -### 2. Bump `vite-plus` to the exact target, everywhere, and reinstall +## Implementation -For every workspace package that depends on `vite-plus`, set the spec to the exact executing-CLI version (e.g. `0.2.1`), not a range or `catalog:`/`latest` placeholder. Extend `ensureVitePlusDependencySpecs` to re-pin a concrete behind spec (`^0.1.24`), not only `catalog:`/absent. Then reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`) so the lockfile moves off the old resolution. - -### 3. Remove the wrapper and apply the usage-based vitest decision - -This replaces `ensureVitePlusBootstrap`'s unconditional "write `vitest` into overrides" with the prompt's logic: - -1. **Detect direct vitest usage**: a source/test file imports from `vitest` or `@vitest/...` (not `vite-plus/test`), OR the project lists any `@vitest/*` package in a dependency field. (Source scan can reuse the migration's existing import walker.) -2. **Common case (no direct usage): purge vitest.** Remove the `vitest` dependency entry in any form, and remove `vitest` from every resolution mechanism (`overrides`, `resolutions`, `pnpm.overrides`, `pnpm-workspace.yaml` `overrides`/`catalog`, bun `workspaces.catalog`, yarn `resolutions`/`.yarnrc.yml` catalog). Add no pin. -3. **Direct-usage case: pin and align.** Set `vitest` (the dependency and/or override the project uses) to `VITEST_VERSION`, and set every `@vitest/*` package the project lists to the same version; bump `vitest-browser-*` and similar integration packages to a compatible release. This subsumes the earlier "coverage provider alignment" goal: `@vitest/coverage-v8: ^4.1.8` -> `4.1.9`. -4. **Behind/inline aliases**: rewrite `vite: npm:@voidzero-dev/vite-plus-core@` to the exact target (`@0.2.1`) wherever it appears, including inline `devDependencies` aliases; reuse `pruneLegacyWrapperAliases` for the dead wrapper and add normalization for behind core aliases. - -### 4. Pin the `vite` -> core override to the exact target - -Keep the `vite` -> `npm:@voidzero-dev/vite-plus-core@` mapping, set to the exact executing version, in whichever override/resolution/catalog form the project already uses. This is a deliberate change from the current `@latest` convention (see Open Questions) and matches the prompt's lockstep requirement. - -### 5. Clean wrapper-only resolution config and fix the pnpm location - -- Remove pnpm `peerDependencyRules` (`allowAny` / `allowedVersions` / `ignoreMissing`) and yarn `packageExtensions` entries that reference `vitest`, `@vitest/*`, or the wrapper, when they exist only to accommodate the old setup. Leave unrelated rules. -- Treat an empty/partial `pkg.pnpm` (e.g. `"pnpm": {}`) as "no package.json pnpm config" so the `pnpm-workspace.yaml` path runs. When both a `package.json` `pnpm.overrides` and a `pnpm-workspace.yaml` `overrides` exist, reconcile both so the project is not left with two conflicting override sources. - -### 6. Reinstall and verify - -After edits, reinstall once (reusing `handleInstallResult`), then assert the prompt's post-conditions and surface failures as warnings + non-zero exit: - -- No `@voidzero-dev/vite-plus-test` reference anywhere outside `node_modules` (package.json, lockfile, catalogs, sources). -- The dependency tree resolves to a **single** `vitest` version (no duplicate copies). This is the check that catches a missed ecosystem package in the direct-usage branch. -- `vite-plus`, the core alias, and (if present) the aligned `@vitest/*` packages resolve to the expected versions. - -### 7. Constraints and UX - -Honor the prompt's constraints: do not bypass git hooks (if a pre-existing failure blocks the run, report it rather than forcing through); make the smallest set of edits and do not reformat unrelated files; end with a summary. Interactive run prompts once for the whole upgrade; `--no-interactive` applies it. Summary, fed by `MigrationReport`: - -``` -Upgraded Vite+ 0.1.24 -> 0.2.1 - re-pinned vite-plus and vite->core to 0.2.1 (1 package) - removed @voidzero-dev/vite-plus-test wrapper - project uses vitest directly (@vitest/coverage-v8): pinned vitest 4.1.9, aligned @vitest/coverage-v8 4.1.8 -> 4.1.9 - verified: no wrapper refs, single vitest version -``` +| Area | Change | +| ---- | ------ | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/migrator.ts` | Managed override set (`managedOverridePackages`); `vitest` removal across every sink; coverage-provider alignment; behind `vite-plus`/`vite` re-pin; empty-`pnpm` routing fix. | -### 8. Idempotency +Covered by unit tests in `migrator.spec.ts` (vitest removal, coverage alignment, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. -After a successful upgrade, detection returns false (target version pinned, no wrapper, single vitest), so a re-run hits the "already using Vite+, happy coding" path. Repairs must be recoverable by re-running if an install fails after files were rewritten. +Not yet reflected in code: the current implementation still *pins* `vitest` when the project lists a vitest ecosystem package, rather than removing it. The "vitest itself: never project-managed" rule above (validated by the urllib 3-PM PRs) makes that pin unnecessary; collapsing it into unconditional removal is the next code change. -## Code Touchpoints +## Follow-ups (not in this change) -| Area | Change | -| ---- | ------ | -| `crates/vite_global_cli/src/commands/migrate.rs` (+ `delegate.rs`) | Local-vs-global version check; route `migrate` to the global CLI when local `vite-plus` is older | -| `packages/cli/src/migration/migrator.ts` | Replace unconditional vitest pinning with the usage-based decision; exact-version pin of `vite-plus` + core alias for every workspace package; behind/inline alias normalization; empty-`pnpm` fix and dual-source reconciliation; wrapper-only peer-config cleanup | -| `packages/cli/src/migration/detector.ts` | Detect direct vitest usage (source imports + listed `@vitest/*`) | -| `packages/cli/src/migration/bin.ts` | Drive the upgrade in the already-Vite+ path; verify single-vitest post-condition; summary | -| `packages/cli/src/migration/report.ts` | Report version bump, removal-vs-pin decision, ecosystem alignment, verification | -| `docs/guide/upgrade.md` / release notes | Replace the manual prompt + "do not run `vp migrate`" with `vp upgrade && vp migrate` once reliable | - -## Testing Plan - -- **Unit** (`migrator.spec.ts`): - - urllib shape (pnpm, inline `vite`/`vitest` aliases, pinned `vite-plus: ^0.1.24`, empty `"pnpm": {}`, committed `pnpm-workspace.yaml` pinning to `^0.1.24`/wrapper, `@vitest/coverage-v8: ^4.1.8`) -> direct-usage branch: `vite-plus`/core pinned to target, `vitest` pinned `4.1.9`, `@vitest/coverage-v8` -> `4.1.9`, no wrapper, single override source. - - Common-case shape (uses only `vite-plus/test`, no `@vitest/*` dep): `vitest` removed from deps and all resolution mechanisms, no pin added. - - npm/bun/yarn variants; user-authored non-wrapper `vitest`/coverage range preserved with a warning. -- **Snap tests** (`packages/cli/snap-tests-global/`): committed `migration-upgrade-v0_1-*` fixtures for both branches (direct-usage = urllib mirror, common-case = removal), per package manager, plus an idempotency fixture running `vp migrate` twice. Inputs must be committed files. -- **Routing test** (`crates/vite_global_cli`): local `vite-plus` older than global `vp` runs the global migrate path; equal stays local-first. -- **E2E**: real urllib, run the upgrade, assert no wrapper refs, single `vitest@4.1.9`, `@vitest/coverage-v8@4.1.9`, and `vp run cov` passes with no skew warning. - -## Rollout - -1. Land the empty-`pnpm` misrouting fix (Open Question 3) as a standalone bugfix with a regression test, independent of the rest. -2. Ship the full upgrade behavior, then update the v0.2.x release notes / `docs/guide/upgrade.md` to recommend `vp upgrade && vp migrate` and remove the "do not run `vp migrate`" disclaimer. -3. `npm deprecate @voidzero-dev/vite-plus-test "Merged into vite-plus; run 'vp upgrade && vp migrate' to upgrade your project"`. - -## Alternatives Considered - -- **Keep #1588's always-pin behavior** (write `vitest: VITEST_VERSION` for every project). Rejected: the prompt removes vitest in the common case precisely so future `vp update vite-plus` keeps vitest correct without a project pin to drift. Always-pinning creates per-release maintenance and redundant config. -- **Auto-heal in `vp install`**: rejected as primary mechanism (install should not rewrite config unprompted); a discovery warning pointing at `vp migrate` is a follow-up (Open Question 2). It must live in the global routing layer to reach stale-local-CLI projects. -- **Always delegate `migrate` to the global CLI** (drop local-first for this command). Simpler than the version check; changes behavior for users who pin a local version. Open Question 1. - -## Open Questions - -1. **Routing**: local-vs-global version check (recommended) vs. always routing `migrate` through the global CLI? Comparison rule: any-older or only cross-major? -2. Should `vp install` / `vp outdated` warn when a stale wrapper alias or behind `vite-plus` is present, pointing at `vp migrate`? To reach stale-local-CLI projects the warning must live in the global routing layer. -3. The empty-`pkg.pnpm` misrouting is a standalone #1588 bug. Ship it as a separate fix first, with a regression test for the `"pnpm": {}` + `pnpm-workspace.yaml` shape? -4. **Exact vs `latest`**: the prompt pins `vite-plus` and the core alias to the exact target; the current migrate convention writes `@latest` / `catalog: latest`. Should the upgrade path write exact versions (recommended, guarantees the lockfile moves and matches the prompt), and should normal migrate adopt the same? -5. **Removal default under `--no-interactive`**: removing `vitest` and resolution config is more invasive than pinning. Acceptable unattended in CI, or gated behind an explicit flag the first time? -6. Do we want `vp migrate --check` (detection only, exit code signals an available upgrade) for CI, mirroring `vp upgrade --check`? -7. **Direct-usage detection fidelity**: is "any `@vitest/*` listed, or any direct `vitest`/`@vitest` import" sufficient, or do we also need to catch indirect integration packages (`vitest-browser-*`, framework test plugins) that imply vitest usage without a direct import? +- Refine the code so `vitest` is removed even when a vitest ecosystem package is present (keep only the ecosystem-package alignment), per the validated rule. +- Verify vitest browser mode across pnpm/npm/yarn with no direct `vitest`; remove the browser-mode injection if it is obsolete. +- Regenerate `snap-tests-global/migration-*` and add an end-to-end check on a real `0.1.x` project. +- Update `docs/guide/upgrade.md` / the release-notes prompt to the `vp upgrade && vp migrate` flow once shipped, and `npm deprecate @voidzero-dev/vite-plus-test`. +- Optional `vp migrate --check` (detection-only, exit code signals an available upgrade) for CI. From d5d13c184f009dc5b32be5b7a6aa46d64699a850 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 20:29:34 +0800 Subject: [PATCH 08/32] fix(migrate): make upgrade provisioning peer-safe --- .../migration-add-git-hooks/snap.txt | 4 - .../migration-agent-claude/snap.txt | 2 +- .../snap.txt | 2 +- .../snap.txt | 2 +- .../migration-already-vite-plus/snap.txt | 3 +- .../snap.txt | 4 - .../migration-baseurl-tsconfig/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-composed-husky-prepare/snap.txt | 4 - .../migration-env-prefix-lint-staged/snap.txt | 4 - .../migration-eslint-lint-staged/snap.txt | 4 - .../migration-eslint-lintstagedrc/snap.txt | 4 - .../migration-eslint-npx-wrapper/snap.txt | 4 - .../snap.txt | 2 +- .../migration-eslint-rerun-mjs/snap.txt | 2 +- .../migration-eslint-rerun/snap.txt | 2 +- .../migration-eslint/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-existing-husky/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-from-tsdown/snap.txt | 4 - .../snap.txt | 4 - .../migration-husky-catalog-version/snap.txt | 4 - .../snap.txt | 4 - .../migration-husky-latest-dist-tag/snap.txt | 4 - .../migration-husky-or-prepare/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-lazy-plugins-await/snap.txt | 4 - .../migration-lint-staged-in-scripts/snap.txt | 4 - .../migration-lint-staged-merge-fail/snap.txt | 4 - .../migration-lint-staged-ts-config/snap.txt | 4 - .../migration-lintstagedrc-json/snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../snap.txt | 4 - .../migration-merge-vite-config-js/snap.txt | 4 - .../migration-monorepo-bun/snap.txt | 7 +- .../snap.txt | 4 - .../migration-monorepo-pnpm/snap.txt | 7 - .../migration-monorepo-yarn4/snap.txt | 7 +- .../migration-no-agent/snap.txt | 2 +- .../migration-no-git-repo/snap.txt | 4 - .../migration-no-hooks-with-husky/snap.txt | 4 - .../migration-no-hooks/snap.txt | 4 - .../migration-other-hook-tool/snap.txt | 4 - .../snap.txt | 4 - .../migration-oxlintrc-jsonc/snap.txt | 4 - .../snap.txt | 6 +- .../snap.txt | 4 - .../migration-prettier-eslint-combo/snap.txt | 4 - .../snap.txt | 4 - .../migration-prettier-lint-staged/snap.txt | 4 - .../migration-prettier-pkg-json/snap.txt | 4 - .../migration-prettier-rerun/snap.txt | 2 +- .../migration-prettier/snap.txt | 4 - .../migration-skip-vite-dependency/snap.txt | 4 - .../snap.txt | 4 - .../migration-standalone-npm/snap.txt | 4 +- .../migration-standalone-pnpm/snap.txt | 5 - .../migration-subpath/snap.txt | 4 - .../snap.txt | 4 - .../migration-vite-version/snap.txt | 4 - .../src/migration/__tests__/migrator.spec.ts | 200 +++++++- packages/cli/src/migration/bin.ts | 1 + packages/cli/src/migration/migrator.ts | 427 +++++++++++++----- rfcs/migrate-existing-projects.md | 74 +-- 73 files changed, 549 insertions(+), 424 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index 3ecc5a9256..f59ebbe259 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt index 5e0ff8a6ac..13653cb4e3 100644 --- a/packages/cli/snap-tests-global/migration-agent-claude/snap.txt +++ b/packages/cli/snap-tests-global/migration-agent-claude/snap.txt @@ -1,7 +1,7 @@ > vp migrate --agent claude --no-interactive # migration with --agent claude should write CLAUDE.md ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > cat CLAUDE.md | head -3 # verify CLAUDE.md was created diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt index a497792e14..f0a1a3747b 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt @@ -15,7 +15,7 @@ }, "devDependencies": { "vite": "^7.0.0", - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt index 81dfa7d245..62d1b178c6 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt @@ -14,7 +14,7 @@ }, "devDependencies": { "vite": "^7.0.0", - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index aa0b62ec9d..d917553c49 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -15,8 +15,7 @@ "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 1192b20cdd..6483161a6f 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -57,15 +57,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 5d31e92cc2..553bbf5680 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -60,15 +60,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index 377a73d062..43f904efba 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index 060b656fab..9077f28eb6 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .config/husky/pre-commit # pre-commit hook should be in custom dir vp staged diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index 62670a4322..ee4d3f501a 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 1739bfda66..2351276fc8 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index 46060a3184..8f84735ba4 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxlint config and staged config merged into vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index dbeea0338d..4ac2df74b8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index eae3f8790e..cfb60af6e8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -32,17 +32,13 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt index 0771255168..751dadc781 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt index dc0441dd50..0476a84c93 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt index fa4bf5b15c..60f25d1c4e 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt @@ -12,7 +12,7 @@ "lint": "vp lint ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index fea606b7f3..a6795c9c48 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > cat vite.config.ts # check oxlint config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index d2c1c68a13..5bfc30a07b 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 5d26bc1549..7d7556c838 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index d848a1259c..1bd78cc26e 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index cb5a7637e8..625779fd04 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook rewritten to vp staged vp staged diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index 940fa1c0aa..8ca67d4068 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining to vite.config.ts) > cat vite.config.ts # check staged config migrated to vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index e6c009ca2b..1e8305dbd6 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -27,18 +27,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index 5a898b0f28..df00607092 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .vite-hooks/pre-commit # check pre-commit hook vp staged diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index 16087a6ade..dff04522cd 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -52,18 +52,14 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 547d4c1772..1045b7499e 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -54,18 +54,14 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > vp migrate --no-interactive # run migration again to check if it is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 3a30efa064..5dd710ab9f 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > git config --local core.hooksPath # should still be .custom-hooks .custom-hooks diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 72ce13481b..132c4aff73 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -35,18 +35,14 @@ catalog: husky: ^9.1.7 lint-staged: ^16.2.6 vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index efd29b79ed..553fb5694d 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index 40ab64b83a..fa4e63bf77 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -29,15 +29,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index 2e91e7579e..a5cec54506 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index 3b7b394d0c..f8da4e6f7a 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index 20e45a6e33..acac9f1da0 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -33,15 +33,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt index 95a4844c9a..bbdf28e64a 100644 --- a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt +++ b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt @@ -36,15 +36,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index bfa1bb00f7..7960ec688f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index ecdad850c3..360d056826 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -36,18 +36,14 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite config should be unchanged (merge failed) const config = { plugins: [] }; diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 287d37346a..4d1ecec73f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -31,18 +31,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat lint-staged.config.ts # check TS config is not modified export default { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index af8f1dd1f5..5a82a70583 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -100,18 +100,14 @@ Documentation: https://viteplus.dev/guide/migrate > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check staged config migrated to vite.config.ts import { defineConfig } from 'vite-plus'; diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index 9b0e74b5d9..bd3f0f4a87 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -33,18 +33,14 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .lintstagedrc.json # config file should be preserved when merge fails { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 5d9403d2b3..9154c73bdb 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -44,15 +44,11 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index ba09d0e639..6d03dc48a1 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -28,18 +28,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -f .lintstagedrc.json && echo 'lintstagedrc.json still exists' || echo 'lintstagedrc.json was deleted' # should still exist lintstagedrc.json still exists diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index 38385db3b2..f2ba7beff2 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -58,15 +58,11 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 8a36eae8d9..28d20df0c0 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -45,7 +45,6 @@ export default defineConfig({ ], "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" } }, @@ -63,13 +62,11 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "bun@", "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" } } @@ -89,7 +86,6 @@ export default defineConfig({ "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } @@ -107,7 +103,6 @@ export default defineConfig({ }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index b32ff43659..ec987b96f3 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -39,22 +39,18 @@ packages: catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: '@vitejs/plugin-react>vite': 'npm:vite@' 'supertest>superagent': vite: 'catalog:' - vitest: 'catalog:' react-click-away-listener>react: peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 4181032dc4..0aae76b6b2 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -66,7 +66,6 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "resolutions": { @@ -83,20 +82,16 @@ catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest minimumReleaseAge: 1440 overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' minimumReleaseAgeExclude: - vite-plus - '@voidzero-dev/*' @@ -125,7 +120,6 @@ minimumReleaseAgeExclude: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -146,7 +140,6 @@ minimumReleaseAgeExclude: }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index 6bd15e4900..1ee2f91b8b 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -60,13 +60,11 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" } } @@ -77,7 +75,6 @@ npmPreapprovedPackages: - '@vitest/*' catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest > cat packages/app/package.json # check app package.json @@ -96,7 +93,6 @@ catalog: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -117,7 +113,6 @@ catalog: }, "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-no-agent/snap.txt b/packages/cli/snap-tests-global/migration-no-agent/snap.txt index ca1dc7f635..844536aae3 100644 --- a/packages/cli/snap-tests-global/migration-no-agent/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-agent/snap.txt @@ -1,7 +1,7 @@ > vp migrate --no-agent --no-interactive # migration with --no-agent should skip agent instructions ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten > ls -la | grep -E '(AGENTS|CLAUDE)' || echo 'No agent file created' # verify no agent file was created No agent file created diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index b7f357bd28..39fbe1bd62 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -25,18 +25,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo 'hooks dir exists' || echo 'no hooks dir' hooks dir exists diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index ec9d22ab50..7299b0296f 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -32,18 +32,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # verify no .husky directory No .husky directory diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index 99b7a3d9fb..f9dcc0b68b 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -23,18 +23,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test -d .vite-hooks && echo '.vite-hooks directory exists' || echo 'No .vite-hooks directory' # verify no .vite-hooks directory No .vite-hooks directory diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index f1fa202e1a..f741059b24 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -35,15 +35,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index d1718df00c..458a11d2f3 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -55,15 +55,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index c96dc92a64..1da41f704e 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -57,15 +57,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 59f7b0f5ed..1633a2fd74 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -28,7 +28,7 @@ "globals": "^17.6.0", "typescript": "~6.0.2", "vite": "^8.0.12", - "vite-plus": "^0.1.24" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { @@ -42,18 +42,14 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # vite imports should be rewritten import { defineConfig } from 'vite-plus' diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index 3d367ba88e..efae8980d3 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -30,18 +30,14 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat .husky/pre-commit # hook file should be unchanged (still has bootstrap) . "$(dirname -- "$0")/_/husky.sh" diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index a52152e82e..aab95fdf19 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -34,18 +34,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f eslint.config.mjs # check eslint config is removed > test ! -f .prettierrc.json # check prettier config is removed diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index efc29708b4..7b49f4cde1 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -32,17 +32,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index 3c99c1aaea..c7ef71ca50 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -29,18 +29,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config and staged config merged into vite.config.ts import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index 14e83cdefa..b199e2a1b1 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -30,18 +30,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > cat vite.config.ts # check oxfmt config merged into vite.config.ts with semi/singleQuote settings import { defineConfig } from "vite-plus"; diff --git a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt index 9bc156a317..df5a602fc3 100644 --- a/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt @@ -14,7 +14,7 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "format": "vp fmt ." }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index ec5ada2f30..82bc36c019 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -32,18 +32,14 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' > test ! -f .prettierrc.json # check prettier config is removed > cat vite.config.ts # check oxfmt config merged into vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index d74639391b..042f2820f0 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -50,15 +50,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 29b077788e..ecce492eef 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -50,15 +50,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 1bcde4d80e..c718e55578 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -9,13 +9,11 @@ "name": "migration-standalone-npm", "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" }, "packageManager": "npm@", "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" } } diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 53b010c9be..244b0c7f87 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -9,7 +9,6 @@ "name": "migration-standalone-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "pnpm@" @@ -18,15 +17,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index c327651108..b3a54aeb9c 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -44,15 +44,11 @@ core.hooksPath is not set > cat foo/pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index ae26c63e1e..00262e60ed 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -47,15 +47,11 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 9cdfbfdcad..31d179e307 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -27,15 +27,11 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 4901be6724..507b28090a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1426,6 +1426,11 @@ describe('ensureVitePlusBootstrap', () => { // The deleted wrapper alias must no longer survive in the workspace.yaml. const workspaceRaw = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf-8'); expect(workspaceRaw).not.toContain('@voidzero-dev/vite-plus-test'); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(pkg)).not.toContain('@voidzero-dev/vite-plus-test'); // And the project must not be left pending (no stale wrapper override anywhere). expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); @@ -1460,6 +1465,7 @@ describe('ensureVitePlusBootstrap', () => { devDependencies: Record; }; expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); @@ -1494,6 +1500,161 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.0.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + }); + + it('does not treat @vitest/eslint-plugin as runner usage', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/eslint-plugin': '^1.6.0', + '@vitest/utils': '^4.1.8', + vitest: '4.1.8', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '4.1.8', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'eslint.config.js'), + "import vitest from '@vitest/eslint-plugin';\nimport { diff } from '@vitest/utils';\nexport default [vitest.configs.recommended, diff];\n", + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['@vitest/eslint-plugin']).toBe('^1.6.0'); + expect(pkg.devDependencies['@vitest/utils']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + }); + + it('reconciles vitest and vite-plus in the workspace package that needs them', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + 'vite-plus': '^0.1.24', + vitest: 'npm:@voidzero-dev/vite-plus-test@^0.1.24', + '@vitest/ui': '^4.1.8', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + packages: [{ name: 'app', path: 'packages/app' }], + }; + + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const rootPkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + const appPkg = readJson(path.join(appDir, 'package.json')) as { + devDependencies: Record; + }; + expect(rootPkg.devDependencies.vitest).toBeUndefined(); + expect(appPkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(appPkg.devDependencies['@vitest/ui']).toBe(VITEST_VERSION); + expect(appPkg.devDependencies.vitest).toBe('catalog:'); + expect(JSON.stringify(appPkg)).not.toContain('@voidzero-dev/vite-plus-test'); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); + }); + + it('restores an opt-in browser provider used only through a Vite+ shim', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-app', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + [ + "import { defineConfig } from 'vite-plus';", + "import { playwright } from 'vite-plus/test/browser-playwright';", + 'export default defineConfig({ test: { browser: { enabled: true, provider: playwright() } } });', + ].join('\n'), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { @@ -2315,11 +2476,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['some-pkg']['@vitest/browser-playwright']).toBe('4.0.0'); }); - it('leaves an already-declared coverage provider untouched (no pin, no override)', () => { - // Coverage providers are vitest PEER deps the project installs and versions - // ITSELF. vite-plus never pins or overrides them: the user owns the provider - // version. (The runtime guard in define-config.ts only fail-fasts on a skew - // at `vp test --coverage` time; it does not rewrite the project's deps.) + it('aligns already-declared coverage providers without adding provider overrides', () => { + // Coverage providers have an exact vitest peer and must match the runner. + // Align their dependency specs directly; no provider override is needed. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2338,9 +2497,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { devDependencies: Record; overrides?: Record; }; - // Provider versions are preserved exactly as the user declared them. - expect(pkg.devDependencies['@vitest/coverage-v8']).toBe('^4.0.0'); - expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe(VITEST_VERSION); + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); // vitest itself is still pinned to the bundled version. expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); // …and coverage is never written into the override sink. @@ -2349,6 +2507,32 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(overrides['@vitest/coverage-istanbul']).toBeUndefined(); }); + it('removes direct vitest in the same pass that rewrites ordinary vitest imports', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.npm), true, true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + expect(fs.readFileSync(path.join(tmpDir, 'example.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + }); + it('does not add a coverage provider the project never declared', () => { // A project that uses vitest WITHOUT a coverage provider must not have one // injected by the migration — the user installs it only if they need it. diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d9288bb2e8..badc6fcf68 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1176,6 +1176,7 @@ async function main() { const vitePlusBootstrapPending = detectVitePlusBootstrapPending( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packages, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 6f7e05710f..0fab92ddcb 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -115,6 +115,21 @@ const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as // `vitest: *` peer, so it must NOT be pinned to the vitest version. const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); +// Official packages that do not declare a required `vitest` peer. Keep them +// aligned when a project lists them directly, but do not add a direct vitest +// merely because they are present. +const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ + ...VITEST_ALIGN_EXCLUDED, + '@vitest/expect', + '@vitest/mocker', + '@vitest/pretty-format', + '@vitest/runner', + '@vitest/snapshot', + '@vitest/spy', + '@vitest/utils', + '@vitest/ws-client', +]); + function isAlignableVitestEcosystemPackage(name: string): boolean { return name.startsWith('@vitest/') && !VITEST_ALIGN_EXCLUDED.has(name); } @@ -528,22 +543,27 @@ function projectListsVitestEcosystemDep(pkg: { optionalDependencies?: Record; peerDependencies?: Record; }): boolean { - const dependencyGroups = [ - pkg.dependencies, - pkg.devDependencies, - pkg.optionalDependencies, - pkg.peerDependencies, - ]; + // Peer declarations do not install the package in this project; its consumer + // is responsible for satisfying that package's peers. + const dependencyGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; return dependencyGroups.some((deps) => - deps ? Object.keys(deps).some((name) => name !== 'vitest' && name.includes('vitest')) : false, + deps + ? Object.keys(deps).some( + (name) => + name !== 'vitest' && + name.includes('vitest') && + // Excluded official packages either have no vitest peer or (for the + // ESLint plugin) only an optional `vitest: *` peer. Neither needs a + // direct install or workspace-wide override. + !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ) + : false, ); } -// True iff the project uses vitest DIRECTLY — via a vitest ecosystem dependency -// (see `projectListsVitestEcosystemDep`), a source file referencing vitest (the -// `vitest` substring matches `vitest` / `@vitest/` import specifiers and not -// `vite-plus/test`), or vitest browser mode (whose published `vite-plus/test/browser*` -// shims carry no `vitest` substring but still need vitest resolvable). Drives +// True iff the project uses vitest DIRECTLY — via a dependency that is expected +// to have a required vitest peer (see `projectListsVitestEcosystemDep`), an +// upstream `vitest` module specifier, or vitest browser mode. Drives // whether the migration keeps `vitest` managed or removes it entirely; the // browser-mode arm keeps it aligned with the direct-`vitest` injection below so // an injected `catalog:` spec never dangles against a vitest-less catalog. @@ -558,7 +578,7 @@ function projectUsesVitestDirectly( ): boolean { return ( projectListsVitestEcosystemDep(pkg) || - sourceTreeReferencesAny(projectPath, ['vitest']) || + sourceTreeReferencesRetainedVitestModule(projectPath) || usesVitestBrowserMode(projectPath) ); } @@ -1628,8 +1648,8 @@ export function rewriteStandaloneProject( let shouldRewritePnpmWorkspaceYaml = false; let shouldAddPnpmWorkspaceVitePlusOverride = false; let shouldAllowBrowserProviderBuilds = false; - // Whether the project uses vitest directly (an `@vitest/*` dep or a source - // reference). Computed inside the callback (where `pkg` is available) and + // Whether the project uses vitest directly (a required-peer consumer, an + // upstream module reference, or browser mode). Computed inside the callback and // hoisted so the post-callback pnpm-workspace.yaml writer sees it too. let usesVitest = false; // Determined inside editJsonFile callback to avoid a redundant file read @@ -1700,9 +1720,10 @@ export function rewriteStandaloneProject( }; } } else if (packageManager === PackageManager.pnpm) { - // If package.json already has a "pnpm" field, keep using it; - // otherwise use pnpm-workspace.yaml. - usePnpmWorkspaceYaml = !pkg.pnpm; + // Keep overrides in package.json only when it actually owns override/peer + // configuration (or no workspace file exists). An empty/unrelated `pnpm` + // object must not hide stale overrides in pnpm-workspace.yaml. + usePnpmWorkspaceYaml = !pnpmConfigLivesInPackageJson(pkg, projectPath); if (usePnpmWorkspaceYaml) { shouldRewritePnpmWorkspaceYaml = true; shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); @@ -2315,8 +2336,8 @@ function workspaceUsesWebdriverio( // Workspace-wide direct-vitest signal for the SHARED sinks a monorepo root // owns (pnpm-workspace.yaml catalog/overrides/peer rules, .yarnrc.yml catalog, // bun catalog): `vitest` stays managed there iff ANY package in the workspace — -// the root or any sub-package — uses vitest directly (an `@vitest/*` dep or a -// source reference). See `projectUsesVitestDirectly`. +// the root or any sub-package — uses vitest directly. See +// `projectUsesVitestDirectly`. function workspaceUsesVitestDirectly( rootDir: string, packages: WorkspacePackage[] | undefined, @@ -3405,6 +3426,7 @@ type BootstrapPackageJson = { resolutions?: Record; devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; optionalDependencies?: Record; pnpm?: { overrides?: Record; @@ -3412,6 +3434,8 @@ type BootstrapPackageJson = { allowAny?: string[]; allowedVersions?: Record; }; + allowBuilds?: Record; + onlyBuiltDependencies?: string[]; }; packageManager?: string; devEngines?: { packageManager?: unknown; [key: string]: unknown }; @@ -3649,21 +3673,18 @@ function readBunCatalogDependencyResolver(pkg: { // Decide where a pnpm project keeps its overrides / peer rules. A truthy // `pkg.pnpm` is not enough: an empty `pnpm: {}` is truthy yet carries no -// config, and when a real `pnpm-workspace.yaml` exists the workspace file is -// the actual config source. Treat the config as living in package.json only -// when `pkg.pnpm` has entries, or when it is present-but-empty AND there is no -// `pnpm-workspace.yaml` to own the config instead. -function pnpmConfigLivesInPackageJson( - pkg: BootstrapPackageJson, - projectPath: string, -): boolean { +// override/peer config, and when a real `pnpm-workspace.yaml` exists that file +// is the actual source unless package.json explicitly defines one of those +// managed sections. Unrelated keys such as `onlyBuiltDependencies` do not move +// override ownership into package.json. +function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: string): boolean { if (pkg.pnpm == null) { return false; } - return ( - Object.keys(pkg.pnpm).length > 0 || - !fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml')) - ); + if (!fs.existsSync(path.join(projectPath, 'pnpm-workspace.yaml'))) { + return true; + } + return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); } // Pin every alignable `@vitest/*` package the project lists to the bundled @@ -3686,23 +3707,140 @@ function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { return changed; } -// True when the project lists an alignable `@vitest/*` package at a version -// other than the bundled vitest, so the bootstrap should run to realign it. -function vitestEcosystemPackagesPending(pkg: BootstrapPackageJson): boolean { - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - return dependencyGroups.some((dependencies) => - dependencies - ? Object.keys(dependencies).some( - (name) => - isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION, - ) - : false, - ); +/** + * Reconcile the install dependencies in one package during an existing-Vite+ + * bootstrap. Package-manager overrides are intentionally handled separately at + * the workspace root; this function owns only dependency fields so it can also + * be applied to every workspace package. + */ +function reconcileVitePlusBootstrapPackage( + projectPath: string, + pkg: BootstrapPackageJson, + vitePlusVersion: string, + packageManager: PackageManager, + supportCatalog: boolean, + ensureVitePlus: boolean, +): boolean { + const before = JSON.stringify(pkg); + const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); + + const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups = [...installGroups, pkg.peerDependencies]; + + // Remove every dependency alias to the deleted wrapper before deciding + // whether this package needs a direct upstream vitest peer provider. + for (const dependencies of dependencyGroups) { + pruneLegacyWrapperAliases(dependencies); + } + + // npm keeps the managed alias directly in package.json. Catalog-based package + // managers leave dependency specs alone and repair their shared override. + if (packageManager === PackageManager.npm) { + for (const dependencies of installGroups) { + if ( + dependencies?.vite !== undefined && + !overrideSpecSatisfiesVitePlus('vite', dependencies.vite) + ) { + dependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + } + + alignVitestEcosystemPackages(pkg); + + const providerSourceModes = collectProviderSourceModes(projectPath); + let usesAnyOptInProvider = false; + for (const provider of OPT_IN_BROWSER_PROVIDERS) { + const usesProvider = + providerSourceModes[provider] || + dependencyGroups.some((dependencies) => dependencies?.[provider] !== undefined); + if (!usesProvider) { + continue; + } + usesAnyOptInProvider = true; + const installGroup = installGroups.find( + (dependencies) => dependencies?.[provider] !== undefined, + ); + if (installGroup) { + installGroup[provider] = VITEST_VERSION; + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies[provider] = VITEST_VERSION; + } + const frameworkPeer = BROWSER_PROVIDER_PEER_DEPS[provider]; + const frameworkPresent = dependencyGroups.some( + (dependencies) => dependencies?.[frameworkPeer] !== undefined, + ); + if (frameworkPeer && !frameworkPresent) { + pkg.devDependencies ??= {}; + pkg.devDependencies[frameworkPeer] = '*'; + } + } + + // The base browser runtime and preview provider are bundled by vite-plus; + // only the heavy framework-specific providers remain project-owned. + for (const bundledPackage of REMOVE_PACKAGES.filter((name) => name.startsWith('@vitest/'))) { + for (const dependencies of installGroups) { + if (dependencies?.[bundledPackage] !== undefined) { + delete dependencies[bundledPackage]; + } + } + } + + if (usesAnyOptInProvider && packageManager === PackageManager.npm) { + const viteAlreadyDirect = installGroups.some( + (dependencies) => dependencies?.vite !== undefined, + ); + if (!viteAlreadyDirect) { + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; + } + } + + if (usesVitest) { + // A direct @vitest/*/integration dependency with a required vitest peer + // cannot use the copy nested under its sibling `vite-plus` dependency under + // Yarn PnP or strict pnpm. Provide the peer from this package and keep it on + // the same exact version as the Vite+ runner. + const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); + if (existingGroup) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + ); + } else { + pkg.devDependencies ??= {}; + pkg.devDependencies.vitest = getCatalogDependencySpec( + undefined, + VITEST_VERSION, + supportCatalog, + ); + } + } else { + // Bare vitest is not itself a usage signal: older migrations injected it + // into every project. Remove that stale install pin when no remaining peer, + // source import, or browser-mode signal needs it. + for (const dependencies of installGroups) { + removeManagedVitestEntry(dependencies); + } + } + + return before !== JSON.stringify(pkg); +} + +function bootstrapProjectPaths( + rootDir: string, + packages: WorkspacePackage[] | undefined, +): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, + packages?: WorkspacePackage[], ): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -3718,20 +3856,40 @@ export function detectVitePlusBootstrapPending( return true; } - // A `@vitest/*` ecosystem package skewed from the bundled vitest needs - // realigning, independent of the package manager's override shape. - if (vitestEcosystemPackagesPending(pkg)) { + if (packageManager === undefined) { return true; } - if (packageManager === undefined) { - return true; + const usePnpmWorkspaceYaml = + packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + const childPkg = readJsonFile(childPackageJsonPath) as BootstrapPackageJson; + const candidate = JSON.parse(JSON.stringify(childPkg)) as BootstrapPackageJson; + if ( + reconcileVitePlusBootstrapPackage( + packagePath, + candidate, + canonicalVitePlusSpec, + packageManager, + supportCatalog, + index === 0, + ) + ) { + return true; + } } - // `vitest` is managed only when the project uses it directly; otherwise a - // lingering managed `vitest` entry is treated as pending so the bootstrap - // removes it (and a second detect after removal returns false). - const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + // Shared override/catalog sinks must keep vitest managed when any package in + // the workspace needs it. The direct dependency itself is localized above. + const usesVitest = workspaceUsesVitestDirectly(projectPath, packages); if (packageManager === PackageManager.yarn) { return ( @@ -3775,7 +3933,11 @@ export function detectVitePlusBootstrapPending( return false; } -function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: string): boolean { +function ensureVitePlusDependencySpecs( + pkg: BootstrapPackageJson, + version: string, + ensurePresent = true, +): boolean { let changed = false; // Re-pin a pre-existing vite-plus spec to the migrating toolchain target so // the lockfile moves off an old resolution (e.g. `^0.1.24`). Mirrors the @@ -3807,7 +3969,7 @@ function ensureVitePlusDependencySpecs(pkg: BootstrapPackageJson, version: strin changed = true; } } - if (pkg.devDependencies?.[VITE_PLUS_NAME]) { + if (pkg.devDependencies?.[VITE_PLUS_NAME] || !ensurePresent) { return changed; } pkg.devDependencies = { @@ -3845,34 +4007,6 @@ function ensureOverrideEntries( return { overrides: next, changed }; } -function ensureNpmVitePlusManagedDependencies( - pkg: BootstrapPackageJson, - usesVitest: boolean, -): boolean { - let changed = false; - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; - // Common case: strip a lingering managed `vitest` install dep. - if (!usesVitest) { - for (const dependencies of dependencyGroups) { - if (removeManagedVitestEntry(dependencies)) { - changed = true; - } - } - } - for (const [dependencyName, version] of Object.entries(managedOverridePackages(usesVitest))) { - for (const dependencies of dependencyGroups) { - if ( - dependencies?.[dependencyName] !== undefined && - !overrideSpecSatisfiesVitePlus(dependencyName, dependencies[dependencyName]) - ) { - dependencies[dependencyName] = version; - changed = true; - } - } - } - return changed; -} - function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean { const overrideKeys = Object.keys(managedOverridePackages(usesVitest)); pkg.pnpm ??= {}; @@ -3921,15 +4055,20 @@ export function ensureVitePlusBootstrap( return result; } - // Whether the project uses vitest directly (an `@vitest/*` dep or a source - // reference). Read up front so it is available to the post-callback - // pnpm-workspace.yaml / .yarnrc.yml / bun catalog rewrites too. `vitest` stays - // managed only when true; otherwise the bootstrap REMOVES any lingering - // managed `vitest` entry from every sink. - const usesVitest = projectUsesVitestDirectly( - projectPath, - readJsonFile(packageJsonPath) as BootstrapPackageJson, - ); + const initialRootPkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + // Shared override/catalog sinks are workspace-wide, so keep vitest managed + // when any package needs it. Each package's direct vitest dependency is + // reconciled independently below. + const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); + const usePnpmWorkspaceYaml = + workspaceInfo.packageManager === PackageManager.pnpm && + !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); + const supportCatalog = + !VITE_PLUS_VERSION.startsWith('file:') && + (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); + const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; editJsonFile< BootstrapPackageJson & { @@ -3938,20 +4077,14 @@ export function ensureVitePlusBootstrap( catalogs?: Record>; } >(packageJsonPath, (pkg) => { - const usePnpmWorkspaceYaml = - workspaceInfo.packageManager === PackageManager.pnpm && - !pnpmConfigLivesInPackageJson(pkg, projectPath); - const supportCatalog = - !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); - let packageJsonChanged = ensureVitePlusDependencySpecs( + let packageJsonChanged = reconcileVitePlusBootstrapPackage( + projectPath, pkg, - supportCatalog ? 'catalog:' : VITE_PLUS_VERSION, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + true, ); - packageJsonChanged = alignVitestEcosystemPackages(pkg) || packageJsonChanged; - if (workspaceInfo.packageManager === PackageManager.npm) { - packageJsonChanged = ensureNpmVitePlusManagedDependencies(pkg, usesVitest) || packageJsonChanged; - } if (workspaceInfo.packageManager === PackageManager.yarn) { const ensured = ensureOverrideEntries(pkg.resolutions, usesVitest); @@ -3988,12 +4121,40 @@ export function ensureVitePlusBootstrap( packageJsonChanged = true; } packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; + if (pnpmMajorVersion !== undefined && pkg.pnpm) { + const beforePnpm = JSON.stringify(pkg.pnpm); + applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); + packageJsonChanged = beforePnpm !== JSON.stringify(pkg.pnpm) || packageJsonChanged; + } } result.packageJson = packageJsonChanged; return pkg; }); + // Existing Vite+ monorepos take this bootstrap path instead of the full + // migration, so reconcile every workspace manifest as well as the root. + for (const workspacePackage of workspaceInfo.packages) { + const packagePath = path.join(projectPath, workspacePackage.path); + const childPackageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(childPackageJsonPath)) { + continue; + } + let childChanged = false; + editJsonFile(childPackageJsonPath, (pkg) => { + childChanged = reconcileVitePlusBootstrapPackage( + packagePath, + pkg, + canonicalVitePlusSpec, + workspaceInfo.packageManager, + supportCatalog, + false, + ); + return childChanged ? pkg : undefined; + }); + result.packageJson = result.packageJson || childChanged; + } + if (workspaceInfo.packageManager === PackageManager.pnpm) { const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; if (!pnpmConfigLivesInPackageJson(pkg, projectPath)) { @@ -4014,12 +4175,12 @@ export function ensureVitePlusBootstrap( usesVitest, ) ) { - // Bootstrap only completes the catalog / overrides / peer rules for a - // project that already uses Vite+. Build-script allowance stays owned - // by the full migration paths, so pass an undefined pnpm major to skip - // it (mirrors the single-arg call this path used before the signature - // grew the build-allowance parameters). - rewritePnpmWorkspaceYaml(projectPath, undefined, false, usesVitest); + rewritePnpmWorkspaceYaml( + projectPath, + pnpmMajorVersion, + shouldAllowBrowserBuilds, + usesVitest, + ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); @@ -4213,9 +4374,10 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { - const matchesHint = (content: string): boolean => hints.some((hint) => content.includes(hint)); - +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -4239,7 +4401,7 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): } } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { - if (matchesHint(fs.readFileSync(entryPath, 'utf8'))) { + if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { return true; } } catch { @@ -4253,6 +4415,23 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return scanDir(projectPath, true); } +function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { + return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); +} + +// Normal imports from `vitest` are rewritten to `vite-plus/test` later in the +// same migration and therefore do not justify a lasting direct dependency. +// Module augmentations and triple-slash type references deliberately retain the +// upstream module identity, so keep vitest package-local for those surfaces. +function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { + return sourceTreeMatches(projectPath, (content) => { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + />, - // Whether the project uses vitest DIRECTLY (an `@vitest/*` dep or a source - // reference). `vitest` is managed (and a managed dep/override pin kept) only + // Whether the project uses vitest DIRECTLY (a required-peer consumer, an + // upstream module reference, or browser mode). `vitest` is managed only // when true; in the common case (`false`) a lingering managed `vitest` entry // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. @@ -4378,6 +4557,11 @@ export function rewritePackageJson( } } } + // Optional Vitest packages are published in lockstep with the runner. Keep + // every declared official @vitest/* package on the bundled version during a + // fresh migration too; existing-Vite+ upgrades use the same rule in the + // bootstrap path. + alignVitestEcosystemPackages(pkg); // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the // published vite-plus metadata for transitive dep resolution (e.g. @@ -4503,18 +4687,19 @@ export function rewritePackageJson( const effectiveBrowserMode = vitestBrowserMode || hasBrowserDepSignal; // Trigger vite-plus install when a project has a vitest-adjacent package // (e.g. `vitest-browser-svelte`) that declares vitest as a peer dep — even - // if the project has no vite/oxlint/tsdown dep to migrate. The peer dep is - // satisfied by the upstream vitest that vite-plus bundles as a direct dep. - // Note: peerDependencies count as "adjacent signal" but NOT as installed. + // if the project has no vite/oxlint/tsdown dep to migrate. Only installed + // dependency groups count; a peer declaration alone installs nothing here. const installableNames = [ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {}), ]; - const adjacentSignals = [...installableNames, ...Object.keys(pkg.peerDependencies ?? {})]; const isVitestAdjacent = !installableNames.includes('vitest') && - adjacentSignals.some((name) => name !== 'vitest' && name.includes('vitest')); + installableNames.some( + (name) => + name !== 'vitest' && name.includes('vitest') && !VITEST_DIRECT_USAGE_EXCLUDED.has(name), + ); // Normalize a pre-existing pinned vite-plus so sub-packages don't drift // from siblings: in catalog-supporting monorepos that's `catalog:`, under // force-override (file:) it's the tgz path. Preserve protocol-prefixed diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index f2b8385864..8c606e460d 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -1,6 +1,6 @@ # RFC: Migrating Existing Vite+ Projects to a New Version -- Status: Partially implemented on `rfc/migrate-upgrade-path` (commits `03689668`, `3e5a5137`); vitest-removal simplification and browser-mode verification pending (see Follow-ups) +- Status: Implemented on `rfc/migrate-upgrade-path`; end-to-end browser-mode verification remains (see Follow-ups) - Depends on: [#1588 replace @voidzero-dev/vite-plus-test with upstream vitest](https://github.com/voidzero-dev/vite-plus/pull/1588) (merged, `342fd2f4`) - Related: `docs/guide/upgrade.md`, [migration-command.md](./migration-command.md), [upgrade-command.md](./upgrade-command.md) @@ -19,57 +19,57 @@ Both are needed, and the order matters. `vp migrate` normally runs the project's ## Migrate rules -Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so a project never needs its own `vitest`. It resolves transitively, and an ecosystem package resolves its exact `vitest` peer against it. Verified on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)): with the direct `vitest` removed, coverage stays green on all three. The complementary forced-single case (a third-party `vitest-browser-svelte` keeps a managed `vitest`) is covered by the `migration-vitest-peer-dep` snap test. +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under Yarn PnP and strict pnpm, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. -| Area | Rule | -| ---- | ---- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, forced-single exception | Keep a managed `vitest` (add to `devDependencies` **and** override/pin it to the bundled version) when the project has a **non-exact** `vitest` peer to collapse: a third-party integration on a range peer (`vitest-browser-react` / `-vue` / `-svelte`, ...), vitest browser mode, or a direct `vitest` source import. The override forces the range down to `vite-plus`'s exact version (one copy); the `devDependencies` entry satisfies the peer deterministically. Official `@vitest/*` (exact peer) do NOT trigger this, their exact peer already dedupes to `vite-plus`'s vitest. | -| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`, since each carries an **exact** `vitest` peer. Exclude `@vitest/eslint-plugin` (separate version line, `vitest: *` peer). Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | +Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. + +| Area | Rule | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. -**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). That predates `vite-plus` declaring `vitest`+`@vitest/browser` as dependencies and may now be obsolete, but it is not yet confirmed across package managers, so the browser-mode injection stays until a urllib-style 3-PM check clears it. +**Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. ## Vitest ecosystem packages How each package the `vitest` ecosystem rule covers is handled, verified against the registry at `4.1.9`. The code rule: align any `@vitest/*` the project lists to `VITEST_VERSION`, except `@vitest/eslint-plugin`; the browser packages additionally follow their bundled/opt-in handling. -| Package | `vitest` peer | Handling | -| ------- | ------------- | -------- | -| `@vitest/coverage-v8` | `4.1.9` (exact) | align to `VITEST_VERSION` | -| `@vitest/coverage-istanbul` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/ui` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/web-worker` | `4.1.9` | align to `VITEST_VERSION` | -| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`) | -| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`) | -| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` peer | -| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` peer | -| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` | none | transitive deps of `vitest`; `vite-plus` provides them, the project does not list them | -| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | -| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, **and** a managed `vitest` is kept (devDep + override) to force a single copy against the range peer | +| Package | `vitest` peer | Handling | +| ---------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `@vitest/coverage-v8` | `4.1.9` (exact) | align; provide direct `vitest` in the same package | +| `@vitest/coverage-istanbul` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/ui` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/web-worker` | `4.1.9` | align; provide direct `vitest` in the same package | +| `@vitest/browser` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-preview` | `4.1.9` | removed (bundled by `vite-plus`); browser package keeps direct `vitest` | +| `@vitest/browser-playwright` | `4.1.9` + `playwright` | opt-in: pin to `VITEST_VERSION`, keep `playwright` and direct `vitest` | +| `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` and direct `vitest` | +| `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` `/ws-client` | none | transitive runtime packages; align if listed, but do not add `vitest` for them alone | +| `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, with a package-local `vitest` plus shared override | ## Implementation -| Area | Change | -| ---- | ------ | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/migrator.ts` | Managed override set (`managedOverridePackages`); `vitest` removal across every sink; coverage-provider alignment; behind `vite-plus`/`vite` re-pin; empty-`pnpm` routing fix. | - -Covered by unit tests in `migrator.spec.ts` (vitest removal, coverage alignment, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/migrator.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix. | -Not yet reflected in code: the current implementation still *pins* `vitest` when the project lists a vitest ecosystem package, rather than removing it. The "vitest itself: never project-managed" rule above (validated by the urllib 3-PM PRs) makes that pin unnecessary; collapsing it into unconditional removal is the next code change. +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. ## Follow-ups (not in this change) -- Refine the code so `vitest` is removed even when a vitest ecosystem package is present (keep only the ecosystem-package alignment), per the validated rule. -- Verify vitest browser mode across pnpm/npm/yarn with no direct `vitest`; remove the browser-mode injection if it is obsolete. -- Regenerate `snap-tests-global/migration-*` and add an end-to-end check on a real `0.1.x` project. +- Verify the browser-mode upgrade across pnpm/npm/yarn; simplify package-local provisioning only if strict peer and optimizer resolution remain correct. +- Add an end-to-end check on a real `0.1.x` project. - Update `docs/guide/upgrade.md` / the release-notes prompt to the `vp upgrade && vp migrate` flow once shipped, and `npm deprecate @voidzero-dev/vite-plus-test`. - Optional `vp migrate --check` (detection-only, exit code signals an available upgrade) for CI. From 1a0f9d447df6041142dc5d8c1c825f0bd736e2da Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 19 Jun 2026 20:57:31 +0800 Subject: [PATCH 09/32] fix(migrate): validate upgrade scenarios in snapshots --- .../snap.txt | 2 +- .../snap.txt | 2 +- .../migration-already-vite-plus/snap.txt | 2 +- .../migration-already-vite-plus/steps.json | 2 +- .../snap.txt | 2 +- .../migration-rewrite-declare-module/snap.txt | 3 +- .../steps.json | 2 +- .../migration-standalone-npm/snap.txt | 4 +- .../migration-standalone-npm/steps.json | 2 +- .../package.json | 14 +++ .../pnpm-workspace.yaml | 10 ++ .../snap.txt | 38 +++++++ .../steps.json | 8 ++ .../vite.config.ts | 11 +++ .../package.json | 13 +++ .../pnpm-workspace.yaml | 10 ++ .../snap.txt | 41 ++++++++ .../steps.json | 8 ++ .../vite.config.ts | 11 +++ .../package.json | 14 +++ .../packages/app/package.json | 8 ++ .../pnpm-workspace.yaml | 12 +++ .../snap.txt | 48 +++++++++ .../steps.json | 9 ++ .../local-vite-plus/dist/bin.js | 2 + .../local-vite-plus/package.json | 4 + .../package.json | 16 +++ .../pnpm-workspace.yaml | 10 ++ .../setup-local.mjs | 5 + .../snap.txt | 34 +++++++ .../steps.json | 9 ++ .../package.json | 18 ++++ .../snap.txt | 22 +++++ .../steps.json | 7 ++ .../package.json | 23 +++++ .../snap.txt | 29 ++++++ .../steps.json | 7 ++ .../.yarnrc.yml | 4 + .../package.json | 17 ++++ .../snap.txt | 35 +++++++ .../steps.json | 8 ++ .../package.json | 21 ++++ .../snap.txt | 25 +++++ .../steps.json | 7 ++ .../example.spec.ts | 5 + .../migration-vitest-import-only/package.json | 10 ++ .../migration-vitest-import-only/snap.txt | 43 ++++++++ .../migration-vitest-import-only/steps.json | 9 ++ .../package.json | 10 ++ .../snap.txt | 37 +++++++ .../steps.json | 10 ++ .../src/migration/__tests__/migrator.spec.ts | 6 +- .../migration/__tests__/npm-reinstall.spec.ts | 84 ++++++++++++++++ packages/cli/src/migration/bin.ts | 10 ++ packages/cli/src/migration/migrator.ts | 41 +++++--- packages/cli/src/migration/npm-reinstall.ts | 98 +++++++++++++++++++ rfcs/migrate-existing-projects.md | 31 ++++-- 57 files changed, 939 insertions(+), 34 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/package.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-vitest-import-only/steps.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json create mode 100644 packages/cli/src/migration/__tests__/npm-reinstall.spec.ts create mode 100644 packages/cli/src/migration/npm-reinstall.ts diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt index f0a1a3747b..0d1216e4f3 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt @@ -14,7 +14,7 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt index 62d1b178c6..dacabcc34b 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt @@ -13,7 +13,7 @@ "prepare": "vp config" }, "devDependencies": { - "vite": "^7.0.0", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index d917553c49..ccefd20e87 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults +> vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults ◇ Migrated . to Vite+ • Node npm • Package manager settings configured diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json index 85bc820818..5a9a3fbc1a 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # legacy wrapper-override project: rewrites the stale vitest wrapper override to bundled vitest and completes the missing @vitest/* family pins, no hooks/agent setup defaults", + "vp migrate --no-interactive # common existing project removes the stale wrapper override, no hooks/agent setup defaults", "vp migrate --no-interactive --hooks --agent agents # explicit setup should still update existing vite-plus project", "cat package.json # prepare script should be configured for vp config", "test -f AGENTS.md # explicit agent instructions should be written", diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 1633a2fd74..8b8b296ed1 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -27,7 +27,7 @@ "@vitejs/plugin-react": "^6.0.1", "globals": "^17.6.0", "typescript": "~6.0.2", - "vite": "^8.0.12", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index e816e2f39a..670aa286d9 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive # migration should rewrite imports to vite-plus +> vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied, 1 file had imports rewritten @@ -38,6 +38,7 @@ declare module 'vitest/config' { { "name": "migration-rewrite-declare-module", "devDependencies": { + "vitest": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json index c55aec0263..52c732fd4d 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # migration should rewrite imports to vite-plus", + "vp migrate --no-interactive # retained vitest augmentations should keep a package-local vitest", "cat src/index.ts # check src/index.ts", "cat package.json # check package.json", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog" diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index c718e55578..680cd111de 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -17,5 +17,5 @@ } } -[1]> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override -vite override not found in lockfile +> node -e "const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }" # verify lockfile updated with override +lockfile has vite override diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json index 41f180650f..42f66e7055 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -8,6 +8,6 @@ "commands": [ "vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", "cat package.json # check package.json has overrides field (not pnpm.overrides)", - "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" + "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json new file mode 100644 index 0000000000..5798af5eaa --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "@vitest/browser": "^4.1.8", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt new file mode 100644 index 0000000000..2e020716a1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -0,0 +1,38 @@ +> vp migrate --no-interactive # source-only browser provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, framework peer, and local vitest should be present +{ + "name": "migration-upgrade-browser-source-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # shared vitest catalog and override should be present +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json new file mode 100644 index 0000000000..74dfa42763 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only browser provider should be restored", + "cat package.json # provider, framework peer, and local vitest should be present", + "cat pnpm-workspace.yaml # shared vitest catalog and override should be present" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts new file mode 100644 index 0000000000..c8728c30c4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { playwright } from 'vite-plus/test/browser-playwright'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json new file mode 100644 index 0000000000..c048b8c6a8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt new file mode 100644 index 0000000000..e5f69418c0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -0,0 +1,41 @@ +> vp migrate --no-interactive # source-only WebdriverIO provider should be restored +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, webdriverio, and local vitest should be present +{ + "name": "migration-upgrade-browser-webdriverio-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-webdriverio": "", + "webdriverio": "*", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +allowBuilds: + edgedriver: true + geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json new file mode 100644 index 0000000000..6ac329801a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # source-only WebdriverIO provider should be restored", + "cat package.json # provider, webdriverio, and local vitest should be present", + "cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts new file mode 100644 index 0000000000..36f9be16c6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus'; +import { webdriverio } from 'vite-plus/test/browser-webdriverio'; + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: webdriverio(), + }, + }, +}); diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json new file mode 100644 index 0000000000..bc93d7cada --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json new file mode 100644 index 0000000000..84fbcdd3c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/packages/app/package.json @@ -0,0 +1,8 @@ +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..c809535178 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt new file mode 100644 index 0000000000..a790df7831 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt @@ -0,0 +1,48 @@ +> vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # root should not gain a direct vitest +{ + "name": "migration-upgrade-monorepo-vitest-localized-pnpm", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat packages/app/package.json # only the peer consumer should gain local vitest +{ + "name": "app", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} + +> cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package +packages: + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json new file mode 100644 index 0000000000..30299fa416 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # existing Vite+ workspace packages should be reconciled", + "cat package.json # root should not gain a direct vitest", + "cat packages/app/package.json # only the peer consumer should gain local vitest", + "cat pnpm-workspace.yaml # shared vitest config should exist for the consuming package" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js new file mode 100644 index 0000000000..08e3fe0c42 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/dist/bin.js @@ -0,0 +1,2 @@ +console.error('stale local vite-plus CLI was executed'); +process.exitCode = 42; diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json new file mode 100644 index 0000000000..c301f35a6f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/local-vite-plus/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plus", + "version": "0.1.24" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json new file mode 100644 index 0000000000..d65275d403 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.24", + "vite-plus": "^0.1.24", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.24" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + }, + "pnpm": {} +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..a56e85d300 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +overrides: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.24 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.24 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs new file mode 100644 index 0000000000..6bbe95da83 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/setup-local.mjs @@ -0,0 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +fs.mkdirSync('node_modules', { recursive: true }); +fs.cpSync('local-vite-plus', path.join('node_modules', 'vite-plus'), { recursive: true }); diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt new file mode 100644 index 0000000000..009a844efb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -0,0 +1,34 @@ +> node setup-local.mjs +> vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # stale wrapper deps and plain vite-plus range should be repaired +{ + "name": "migration-upgrade-stale-local-pnpm", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "pnpm": {} +} + +> cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json new file mode 100644 index 0000000000..9e51e271ad --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + { "command": "node setup-local.mjs", "ignoreOutput": true }, + "vp migrate --no-interactive # newer global CLI must bypass the installed stale local CLI", + "cat package.json # stale wrapper deps and plain vite-plus range should be repaired", + "cat pnpm-workspace.yaml # empty pnpm field must not hide workspace overrides" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json new file mode 100644 index 0000000000..79eb0c6816 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt new file mode 100644 index 0000000000..2b67a8c5e1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt @@ -0,0 +1,22 @@ +> vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # file pin should remain while stale vitest config is removed +{ + "name": "migration-upgrade-vite-plus-protocol-pin-npm", + "devDependencies": { + "vite-plus": "file:../custom-vite-plus" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json new file mode 100644 index 0000000000..4cf48ccb3d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # deliberate vite-plus protocol pin must survive bootstrap", + "cat package.json # file pin should remain while stale vitest config is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json new file mode 100644 index 0000000000..5b659d5fe8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/package.json @@ -0,0 +1,23 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "^4.1.8", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "^4.1.8", + "@vitest/utils": "^4.1.8", + "@vitest/web-worker": "^4.1.8", + "vite-plus": "latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt new file mode 100644 index 0000000000..06b21d930c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # exact @vitest peers require a package-local vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # ecosystem packages and vitest should align to the bundled version +{ + "name": "migration-upgrade-vitest-exact-peer-npm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/ui": "", + "@vitest/utils": "", + "@vitest/web-worker": "", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json new file mode 100644 index 0000000000..792fcf8e77 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # exact @vitest peers require a package-local vitest", + "cat package.json # ecosystem packages and vitest should align to the bundled version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml new file mode 100644 index 0000000000..65d6ec1deb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: pnp +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json new file mode 100644 index 0000000000..5f0242dfc9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/package.json @@ -0,0 +1,17 @@ +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "^4.1.8", + "vite-plus": "catalog:" + }, + "resolutions": { + "vite": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "4.12.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt new file mode 100644 index 0000000000..8d0b908ae7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -0,0 +1,35 @@ +> vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest +◇ Migrated . to Vite+ +• Node yarn +• Package manager settings configured + +> cat package.json # direct deps and resolutions should use the managed catalog/version +{ + "name": "migration-upgrade-vitest-exact-peer-yarn4", + "devDependencies": { + "@vitest/ui": "", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "", + "onFail": "download" + } + } +} + +> cat .yarnrc.yml # shared catalog should include the aligned vitest +nodeLinker: pnp +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +npmPreapprovedPackages: + - vitest + - '@vitest/*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json new file mode 100644 index 0000000000..41aa4f3d2c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest", + "cat package.json # direct deps and resolutions should use the managed catalog/version", + "cat .yarnrc.yml # shared catalog should include the aligned vitest" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json new file mode 100644 index 0000000000..0dccb74e58 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/package.json @@ -0,0 +1,21 @@ +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "^4.1.8", + "@vitest/ws-client": "^4.1.8", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt new file mode 100644 index 0000000000..c2ec356064 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # internal packages align, eslint plugin stays independent, vitest is removed +{ + "name": "migration-upgrade-vitest-non-runtime-only-npm", + "devDependencies": { + "@vitest/eslint-plugin": "^1.6.0", + "@vitest/utils": "", + "@vitest/ws-client": "", + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json new file mode 100644 index 0000000000..06299da744 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # non-runtime @vitest packages must not keep a vitest pin", + "cat package.json # internal packages align, eslint plugin stays independent, vitest is removed" + ] +} diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts new file mode 100644 index 0000000000..8305afb0b3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/example.spec.ts @@ -0,0 +1,5 @@ +import { expect, it } from 'vitest'; + +it('works', () => { + expect(true).toBe(true); +}); diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/package.json b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json new file mode 100644 index 0000000000..00414adb22 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt new file mode 100644 index 0000000000..e51c39c3e3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # direct dependency and shared pin should be removed +{ + "name": "migration-vitest-import-only", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat example.spec.ts # source import should use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => { + expect(true).toBe(true); +}); + +> cat pnpm-workspace.yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json new file mode 100644 index 0000000000..5337542640 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/steps.json @@ -0,0 +1,9 @@ +{ + "env": {}, + "commands": [ + "vp migrate --no-interactive # ordinary vitest imports should migrate without retaining direct vitest", + "cat package.json # direct dependency and shared pin should be removed", + "cat example.spec.ts # source import should use the Vite+ public surface", + "cat pnpm-workspace.yaml" + ] +} diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json new file mode 100644 index 0000000000..6fc60c5d10 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vitest" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt new file mode 100644 index 0000000000..bd5f121f6b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -0,0 +1,37 @@ +> vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # user's vitest dependency should be preserved +{ + "name": "migration-vitest-unmanaged-override", + "scripts": { + "test": "vp test", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vitest": "^4.0.0", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # no vitest catalog or override should be introduced +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json new file mode 100644 index 0000000000..86631201d7 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"npm:@voidzero-dev/vite-plus-core@latest\"}" + }, + "commands": [ + "vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned", + "cat package.json # user's vitest dependency should be preserved", + "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 507b28090a..021ceb25f1 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1939,7 +1939,7 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies['vite-plus']).toBe('latest'); }); - it('keeps yarn monorepo bootstrap rewrites out of package dependency specs', () => { + it('normalizes yarn monorepo dependency specs through the shared catalog', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -1965,8 +1965,8 @@ describe('ensureVitePlusBootstrap', () => { devDependencies: Record; resolutions: Record; }; - expect(pkg.devDependencies.vite).toBe('^7.0.0'); - expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); expect(pkg.resolutions.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); const yarnrc = readYamlObject(path.join(tmpDir, '.yarnrc.yml')) as { nodeLinker: string; diff --git a/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts new file mode 100644 index 0000000000..a25bc2dbb3 --- /dev/null +++ b/packages/cli/src/migration/__tests__/npm-reinstall.spec.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { prepareNpmViteAliasReinstall } from '../npm-reinstall.ts'; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-npm-reinstall-')); + tempDirs.push(tempDir); + return tempDir; +} + +function writePackage(packagePath: string, name: string): void { + fs.mkdirSync(packagePath, { recursive: true }); + fs.writeFileSync(path.join(packagePath, 'package.json'), JSON.stringify({ name })); +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('prepareNpmViteAliasReinstall', () => { + it('prunes stale real-Vite lock entries and installations while preserving the core alias', () => { + const rootDir = createTempDir(); + const staleRootVite = path.join(rootDir, 'node_modules', 'vite'); + const staleNestedVite = path.join(rootDir, 'node_modules', 'consumer', 'node_modules', 'vite'); + const coreVite = path.join(rootDir, 'packages', 'app', 'node_modules', 'vite'); + writePackage(staleRootVite, 'vite'); + writePackage(staleNestedVite, 'vite'); + writePackage(coreVite, '@voidzero-dev/vite-plus-core'); + fs.writeFileSync( + path.join(rootDir, 'package-lock.json'), + JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'test' }, + 'node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'node_modules/consumer/node_modules/vite': { + version: '7.3.5', + resolved: 'https://registry.npmjs.org/vite/-/vite-7.3.5.tgz', + }, + 'packages/app/node_modules/vite': { + name: '@voidzero-dev/vite-plus-core', + version: '0.2.1', + }, + }, + }), + ); + + expect( + prepareNpmViteAliasReinstall(rootDir, [rootDir, path.join(rootDir, 'packages', 'app')]), + ).toBe(true); + + const lock = JSON.parse(fs.readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + expect(lock.packages['node_modules/vite']).toBeUndefined(); + expect(lock.packages['node_modules/consumer/node_modules/vite']).toBeUndefined(); + expect(lock.packages['packages/app/node_modules/vite']).toBeDefined(); + expect(fs.existsSync(staleRootVite)).toBe(false); + expect(fs.existsSync(staleNestedVite)).toBe(false); + expect(fs.existsSync(coreVite)).toBe(true); + }); + + it('removes a stale workspace-local install when no package-lock exists', () => { + const rootDir = createTempDir(); + const workspaceDir = path.join(rootDir, 'packages', 'app'); + const staleVite = path.join(workspaceDir, 'node_modules', 'vite'); + writePackage(staleVite, 'vite'); + + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(true); + expect(fs.existsSync(staleVite)).toBe(false); + expect(prepareNpmViteAliasReinstall(rootDir, [rootDir, workspaceDir])).toBe(false); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index badc6fcf68..a19af4f2c8 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -78,6 +78,7 @@ import { type Framework, type NodeVersionManagerDetection, } from './migrator.ts'; +import { prepareNpmViteAliasReinstall } from './npm-reinstall.ts'; import { addMigrationWarning, createMigrationReport, type MigrationReport } from './report.ts'; async function confirmNodeVersionFileMigration( @@ -1081,6 +1082,9 @@ async function executeMigrationPlan( plan.packageManager === PackageManager.npm || plan.packageManager === PackageManager.bun ? ['--force'] : ['--no-frozen-lockfile']; + if (plan.packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall(workspaceInfo.rootDir, getWorkspaceProjectPaths(workspaceInfo)); + } updateMigrationProgress('Installing dependencies'); const finalInstallSummary = await runViteInstall( workspaceInfo.rootDir, @@ -1393,6 +1397,12 @@ async function main() { const resolved = await ensureExistingPackageManager(); updateMigrationProgress('Installing dependencies'); const resolvedVersion = resolved?.version ?? packageManagerVersion; + if (packageManager === PackageManager.npm) { + prepareNpmViteAliasReinstall( + workspaceInfoOptional.rootDir, + getWorkspaceProjectPaths(workspaceInfoOptional), + ); + } const installSummary = await runViteInstall( workspaceInfoOptional.rootDir, options.interactive, diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 0fab92ddcb..c387f3b6be 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1798,6 +1798,7 @@ export function rewriteStandaloneProject( usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), usesVitest, + sourceTreeReferencesRetainedVitestModule(projectPath), ); // ensure vite-plus is in devDependencies @@ -2022,6 +2023,7 @@ export function rewriteMonorepoProject( usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), projectUsesVitestDirectly(projectPath, pkg), + sourceTreeReferencesRetainedVitestModule(projectPath), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -3734,16 +3736,16 @@ function reconcileVitePlusBootstrapPackage( pruneLegacyWrapperAliases(dependencies); } - // npm keeps the managed alias directly in package.json. Catalog-based package - // managers leave dependency specs alone and repair their shared override. - if (packageManager === PackageManager.npm) { - for (const dependencies of installGroups) { - if ( - dependencies?.vite !== undefined && - !overrideSpecSatisfiesVitePlus('vite', dependencies.vite) - ) { - dependencies.vite = VITE_PLUS_OVERRIDE_PACKAGES.vite; - } + // Normalize direct Vite install entries as well as the shared override. Keep + // named catalog references intact; plain/behind aliases move to the active + // default catalog or the current core alias. + for (const dependencies of installGroups) { + if (dependencies?.vite !== undefined) { + dependencies.vite = getCatalogDependencySpec( + dependencies.vite, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + supportCatalog, + ); } } @@ -3864,7 +3866,9 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.pnpm && !pnpmConfigLivesInPackageJson(pkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || packageManager === PackageManager.bun); + (usePnpmWorkspaceYaml || + packageManager === PackageManager.yarn || + packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); @@ -4067,7 +4071,9 @@ export function ensureVitePlusBootstrap( !pnpmConfigLivesInPackageJson(initialRootPkg, projectPath); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && - (usePnpmWorkspaceYaml || workspaceInfo.packageManager === PackageManager.bun); + (usePnpmWorkspaceYaml || + workspaceInfo.packageManager === PackageManager.yarn || + workspaceInfo.packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; editJsonFile< @@ -4484,6 +4490,10 @@ export function rewritePackageJson( // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. usesVitestDirectly = true, + // Module augmentations/triple-slash references intentionally retain the + // upstream `vitest` identity after import rewriting and therefore require a + // package-local provider under strict dependency layouts. + retainedVitestModule = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -4731,7 +4741,8 @@ export function rewritePackageJson( // `existingVitePlus` is already truthy here), or a re-migration of a project that // already owns it. The guard below still no-ops when a direct `vitest` already exists, // so a genuine normalize pass of an already-correct project mutates nothing. - const needDirectVitest = needVitePlus || effectiveBrowserMode || isVitestAdjacent; + const needDirectVitest = + needVitePlus || effectiveBrowserMode || isVitestAdjacent || retainedVitestModule; if (needVitePlus || shouldNormalizeExistingVitePlus) { pkg.devDependencies = { ...pkg.devDependencies, @@ -4757,7 +4768,9 @@ export function rewritePackageJson( }; if ( !installableDeps.vitest && - (effectiveBrowserMode || Object.keys(installableDeps).some((name) => name.includes('vitest'))) + (effectiveBrowserMode || + retainedVitestModule || + Object.keys(installableDeps).some((name) => name.includes('vitest'))) ) { pkg.devDependencies ??= {}; pkg.devDependencies.vitest = getCatalogDependencySpec( diff --git a/packages/cli/src/migration/npm-reinstall.ts b/packages/cli/src/migration/npm-reinstall.ts new file mode 100644 index 0000000000..607fd7a652 --- /dev/null +++ b/packages/cli/src/migration/npm-reinstall.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { readJsonFile, writeJsonFile } from '../utils/json.ts'; + +const VITE_PLUS_CORE_PACKAGE = '@voidzero-dev/vite-plus-core'; + +interface NpmLockPackage { + name?: string; + resolved?: string; +} + +interface NpmPackageLock { + packages?: Record; +} + +function isViteInstallPath(packagePath: string): boolean { + return packagePath === 'node_modules/vite' || packagePath.endsWith('/node_modules/vite'); +} + +function isVitePlusCorePackage(pkg: NpmLockPackage | undefined): boolean { + return ( + pkg?.name === VITE_PLUS_CORE_PACKAGE || + pkg?.resolved?.includes('/@voidzero-dev/vite-plus-core/') === true + ); +} + +function removeStaleInstalledVite(packagePath: string): boolean { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + try { + const pkg = readJsonFile(packageJsonPath) as { name?: string }; + if (pkg.name === VITE_PLUS_CORE_PACKAGE) { + return false; + } + } catch { + // A broken package directory also needs to be replaced by the reinstall. + } + + fs.rmSync(packagePath, { recursive: true, force: true }); + return true; +} + +/** + * npm does not replace an already-installed package when its dependency changes + * from `vite` to the `@voidzero-dev/vite-plus-core` npm alias. Even `npm + * install --force` can exit successfully while retaining the real Vite package + * and its stale package-lock entry. Remove only those stale Vite entries before + * the migration's final install so npm resolves the managed alias afresh. + */ +export function prepareNpmViteAliasReinstall( + rootDir: string, + projectPaths: string[] = [rootDir], +): boolean { + const packageLockPath = path.join(rootDir, 'package-lock.json'); + let changed = false; + + if (fs.existsSync(packageLockPath)) { + const packageLock = readJsonFile(packageLockPath) as NpmPackageLock; + let lockChanged = false; + + for (const [packagePath, pkg] of Object.entries(packageLock.packages ?? {})) { + if (!isViteInstallPath(packagePath)) { + continue; + } + + const installPath = path.resolve(rootDir, packagePath); + const relativeInstallPath = path.relative(rootDir, installPath); + if (relativeInstallPath.startsWith('..') || path.isAbsolute(relativeInstallPath)) { + continue; + } + + if (!isVitePlusCorePackage(pkg)) { + delete packageLock.packages?.[packagePath]; + lockChanged = true; + removeStaleInstalledVite(installPath); + } else { + changed = removeStaleInstalledVite(installPath) || changed; + } + } + + if (lockChanged) { + writeJsonFile(packageLockPath, packageLock as unknown as Record); + changed = true; + } + } + + // Also handle installs without a lockfile and workspace-local copies that do + // not have their own package-lock entry. + for (const projectPath of projectPaths) { + changed = removeStaleInstalledVite(path.join(projectPath, 'node_modules', 'vite')) || changed; + } + + return changed; +} diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 8c606e460d..d15177806c 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -34,7 +34,7 @@ Removing the old direct dependency was exercised on `node-modules/urllib` across | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | | Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | | pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); a failed install warns and sets a non-zero exit. | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. @@ -60,12 +60,29 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/migrator.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix. | - -Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation) and a routing test in `vite_global_cli`. +| Area | Change | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup. | + +Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. + +## Snapshot coverage + +| Scenario | Global snap fixture | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Stale local CLI escalation, plain-range re-pin, stale wrapper removal, empty `pnpm` routing | `migration-upgrade-stale-local-pnpm` | +| Default direct-`vitest` removal and ordinary import rewrite | `migration-already-vite-plus`, `migration-vitest-import-only` | +| Official exact peers under npm and Yarn PnP | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | +| Third-party range peer | `migration-vitest-peer-dep` | +| Internal `@vitest/*` packages and `@vitest/eslint-plugin` exclusions | `migration-upgrade-vitest-non-runtime-only-npm` | +| Playwright and WebdriverIO browser restoration, including pnpm driver approvals | `migration-upgrade-browser-source-only-pnpm`, `migration-upgrade-browser-webdriverio-pnpm` | +| Package-local Vitest in an existing monorepo with shared root overrides | `migration-upgrade-monorepo-vitest-localized-pnpm` | +| Retained upstream module augmentations | `migration-rewrite-declare-module` | +| Unmanaged/CI override mode preserves user-owned Vitest | `migration-vitest-unmanaged-override` | +| Deliberate protocol-pinned `vite-plus` spec | `migration-upgrade-vite-plus-protocol-pin-npm` | +| Idempotent rerun on an already-current project | `migration-from-tsdown`, `migration-from-tsdown-json-config` | +| Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | ## Follow-ups (not in this change) From 73a21e6ea307c610c8156f592ea7b74f233bffbb Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 17:37:40 +0800 Subject: [PATCH 10/32] test(migrate): update default vitest snapshots --- crates/vite_global_cli/src/js_executor.rs | 2 +- .../snap-tests-global/new-vite-monorepo-bun/snap.txt | 4 +--- .../cli/snap-tests-global/new-vite-monorepo/snap.txt | 4 ---- .../snap-tests/create-approve-builds-bun/snap.txt | 9 +++------ .../create-approve-builds-migrate-pnpm11/snap.txt | 12 ------------ .../snap-tests/create-approve-builds-pnpm11/snap.txt | 12 ------------ .../snap-tests/create-approve-builds-yarn/snap.txt | 6 ++---- .../snap-tests/create-org-bundled-monorepo/snap.txt | 4 ---- 8 files changed, 7 insertions(+), 46 deletions(-) diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 0af79b0399..bbf25fa9b6 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -486,7 +486,7 @@ mod tests { assert!(!local_vite_plus_is_older("0.2.1", "0.2.1")); // Newer local keeps local-first semantics. assert!(!local_vite_plus_is_older("0.3.0", "0.2.1")); - // Unparseable versions are conservative: never escalate. + // Unparsable versions are conservative: never escalate. assert!(!local_vite_plus_is_older("latest", "0.2.1")); } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 931bc042ab..4d6f94a18d 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -30,8 +30,7 @@ vite.config.ts "vite-plus": "catalog:" }, "overrides": { - "vite": "catalog:", - "vitest": "catalog:" + "vite": "catalog:" }, "devEngines": { "packageManager": { @@ -45,7 +44,6 @@ vite.config.ts }, "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "", "vite-plus": "latest" } } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 484912d7a9..465d62b5d8 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -65,18 +65,14 @@ catalog: "@types/node": ^24 typescript: ^5 vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -f vite-plus-monorepo/.gitignore && echo '.gitignore exists' || echo 'ERROR: .gitignore missing' # verify gitignore renamed from _gitignore .gitignore exists diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 1a2f29e76d..6ab57ecfe0 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -20,8 +20,7 @@ "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -61,8 +60,7 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -97,8 +95,7 @@ bun pm trust v () "vite-plus": "latest" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index ac8879bff3..c456d18458 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -11,18 +11,14 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -41,18 +37,14 @@ allowBuilds: core-js: set this to true or false catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -63,15 +55,11 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index ae6586d93e..c4f467fee6 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -9,18 +9,14 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved @@ -37,18 +33,14 @@ allowBuilds: core-js: set this to true or false catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" @@ -59,15 +51,11 @@ allowBuilds: core-js: true catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 831f12dea0..03d9474be1 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -24,8 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { @@ -61,8 +60,7 @@ These dependencies may not work until built. Enable them in the workspace root p "vite-plus": "latest" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vitest": "" + "vite": "npm:@voidzero-dev/vite-plus-core@latest" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index 3fe290bc75..cc31c0c256 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -26,18 +26,14 @@ packages: - packages/* catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: vite-plus: latest overrides: vite: "catalog:" - vitest: "catalog:" peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: "*" - vitest: "*" > test -d my-mono/.git && echo 'Git initialized' # git-init prompt covers bundled monorepo path Git initialized From 6221bb1227603354e40d92ce79aec531f6bdc567 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 19:50:10 +0800 Subject: [PATCH 11/32] fix(migrate): handle peer and override edge cases --- .../package.json | 16 ++ .../pnpm-workspace.yaml | 10 + .../snap.txt | 44 +++++ .../steps.json | 8 + .../package.json | 19 ++ .../snap.txt | 27 +++ .../steps.json | 7 + .../package.json | 16 ++ .../pnpm-workspace.yaml | 13 ++ .../snap.txt | 39 ++++ .../steps.json | 8 + .../env.d.ts | 1 + .../package.json | 14 ++ .../pnpm-workspace.yaml | 10 + .../snap.txt | 43 +++++ .../steps.json | 8 + .../bun-catalog-file-protocol.spec.ts | 4 +- .../src/migration/__tests__/migrator.spec.ts | 178 ++++++++++++++++-- packages/cli/src/migration/migrator.ts | 56 +++++- rfcs/migrate-existing-projects.md | 8 +- 20 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json new file mode 100644 index 0000000000..c1ec7ab36a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt new file mode 100644 index 0000000000..4c60c8d885 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -0,0 +1,44 @@ +> vp migrate --no-interactive # peer-only browser provider is promoted with its required peers +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # provider, Playwright, and package-local Vitest are installed +{ + "name": "migration-upgrade-browser-peer-only-pnpm", + "devDependencies": { + "vite-plus": "catalog:", + "@vitest/browser-playwright": "", + "playwright": "*", + "vitest": "catalog:" + }, + "peerDependencies": { + "@vitest/browser-playwright": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json new file mode 100644 index 0000000000..0487af5787 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer-only browser provider is promoted with its required peers", + "cat package.json # provider, Playwright, and package-local Vitest are installed", + "cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json new file mode 100644 index 0000000000..7d344a220d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": { + "@vitest/runner": "4.0.0" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt new file mode 100644 index 0000000000..e7a9d733d6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -0,0 +1,27 @@ +> vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal +This project is already using Vite+! Happy coding! + + +> cat package.json # object-valued override is preserved +{ + "name": "migration-upgrade-nested-vitest-override-npm", + "devDependencies": { + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": { + "@vitest/runner": "" + } + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> vp migrate --no-interactive # nested override must not make migration permanently pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json new file mode 100644 index 0000000000..d97ed7f2e9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # nested Vitest override is user-owned and not pending removal", + "cat package.json # object-valued override is preserved", + "vp migrate --no-interactive # nested override must not make migration permanently pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json new file mode 100644 index 0000000000..86d9d9cbcc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "catalog:test" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..970868c122 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +catalogs: + test: + vitest: ^4.0.0 +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt new file mode 100644 index 0000000000..d7f208b469 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned +◇ Migrated . to Vite+ +• Node pnpm +• Package manager settings configured + +> cat package.json # peer uses its resolved public range without gaining direct Vitest +{ + "name": "migration-upgrade-peer-vitest-catalog-pnpm", + "devDependencies": { + "vite-plus": "catalog:" + }, + "peerDependencies": { + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} + +> cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +catalogs: + test: {} +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' + +> vp migrate --no-interactive # repaired project should no longer be pending +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json new file mode 100644 index 0000000000..d51f6f4cfc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # peer catalog must resolve before managed Vitest catalogs are pruned", + "cat package.json # peer uses its resolved public range without gaining direct Vitest", + "cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed", + "vp migrate --no-interactive # repaired project should no longer be pending" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts new file mode 100644 index 0000000000..e4fafb12fe --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json new file mode 100644 index 0000000000..057c1fe203 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..d9df99abda --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest +overrides: + vite: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt new file mode 100644 index 0000000000..42017f4cf2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -0,0 +1,43 @@ +> vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # directive detection keeps package-local Vitest provisioned +{ + "name": "migration-upgrade-vitest-reference-whitespace-pnpm", + "devDependencies": { + "vite": "catalog:", + "vitest": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat env.d.ts # directive is rewritten to the Vite+ public type surface +/// + +> cat pnpm-workspace.yaml # directive detection keeps shared Vitest management +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + vitest: +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json new file mode 100644 index 0000000000..24700d18cc --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", + "cat package.json # directive detection keeps package-local Vitest provisioned", + "cat env.d.ts # directive is rewritten to the Vite+ public type surface", + "cat pnpm-workspace.yaml # directive detection keeps shared Vitest management" + ] +} diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index d04dbce46c..6fdbc3d704 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -195,7 +195,9 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { rewritePackageJson(pkg, PackageManager.pnpm, true); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + // With no catalog resolver available, use a public fallback rather than + // leaking either a dangling catalog reference or the managed file: path. + expect(pkg.peerDependencies.vitest).toBe('*'); expect(pkg.optionalDependencies.vite).toBe( 'file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz', ); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 021ceb25f1..f979ed54e8 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1657,6 +1657,162 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); + it('resolves a Vitest peer catalog before removing its managed catalog entry', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'peer-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { vitest: 'catalog:test' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'catalogs:', + ' test:', + ' vitest: ^4.0.0', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalogs: Record>; + }; + expect(workspace.catalogs.test.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('keeps Vitest managed when promoting a peer-only browser provider', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'browser-library', + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { '@vitest/browser-playwright': '^4.0.0' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.peerDependencies['@vitest/browser-playwright']).toBe('^4.0.0'); + expect(pkg.devDependencies['@vitest/browser-playwright']).toBe(VITEST_VERSION); + expect(pkg.devDependencies.playwright).toBe('*'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + overrides: Record; + }; + expect(workspace.catalog.vitest).toBe(VITEST_VERSION); + expect(workspace.overrides.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('recognizes whitespace in retained Vitest triple-slash directives', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'typed-library', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync(path.join(tmpDir, 'env.d.ts'), '/// \n'); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite]', + ' allowedVersions:', + " vite: '*'", + '', + ].join('\n'), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + }); + + it('does not remain pending for an object-valued nested Vitest override', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nested-override', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: { '@vitest/runner': '4.0.0' }, + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + expect(result.changed).toBe(false); + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + overrides: Record; + }; + expect(pkg.overrides.vitest).toEqual({ '@vitest/runner': '4.0.0' }); + }); + it('removes a stale vitest wrapper override for a common-case npm project (no @vitest/* dep, no vitest source)', () => { // v0.2.1 spec: vite-plus consumes upstream vitest directly, so a project that // does NOT use vitest directly must NOT carry a managed `vitest` override — @@ -2348,9 +2504,10 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // `vitest` is no longer a managed override key, so the peer entry is left as - // the user wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:'); + // Peer declarations do not keep the managed catalog alive. Resolve the + // catalog entry to its public range before pruning it so the peer cannot + // dangle after migration. + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -2959,6 +3116,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { // no catalog entry is written for it and it must self-resolve. expect(devDeps).toHaveProperty('@vitest/browser-webdriverio', VITEST_VERSION); expect(devDeps.webdriverio).toBe('*'); + expect(devDeps.vitest).toBe('catalog:'); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { allowBuilds: Record; @@ -3149,6 +3307,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { allowBuilds: Record; }; expect(yaml.catalog['@vitest/browser-webdriverio']).toBe('4.0.0'); + expect(yaml.catalog.vitest).toBe(VITEST_VERSION); expect(yaml.allowBuilds.edgedriver).toBe(true); expect(yaml.allowBuilds.geckodriver).toBe(true); }); @@ -3524,9 +3683,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { peerDependencies: Record; }; expect(pkg.peerDependencies.vite).toBe('*'); - // `vitest` is no longer a managed override key (common case: no @vitest/* - // dep, no vitest source), so its peer entry is left as the user wrote it. - expect(pkg.peerDependencies.vitest).toBe('catalog:'); + // Never expose the deleted wrapper alias as a public peer range. + expect(pkg.peerDependencies.vitest).toBe('*'); }); it('adds vitest only to the monorepo package that uses browser mode', () => { @@ -4722,9 +4880,7 @@ describe('rewriteMonorepo yarn catalog', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // `vitest` is no longer managed, so the peer entry is left as the user - // wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); }); @@ -4887,9 +5043,7 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - // `vitest` is no longer managed, so the peer entry is left as the user - // wrote it (untouched). - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); it('rewrites workspaces named catalogs and writes default catalog beside them', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index c387f3b6be..5f64bd2f1f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -578,6 +578,13 @@ function projectUsesVitestDirectly( ): boolean { return ( projectListsVitestEcosystemDep(pkg) || + // Browser packages declared only as peers still become direct installs: + // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in + // providers into devDependencies and treat the bundled browser packages as + // browser-mode intent. Account for that promotion before shared + // catalog/override ownership is decided, otherwise the promoted provider's + // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. + VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || usesVitestBrowserMode(projectPath) ); @@ -2803,6 +2810,33 @@ function getCatalogDependencySpec( return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; } +// A peer declaration does not install Vitest and therefore must not keep a +// workspace-wide managed Vitest catalog alive. Resolve its catalog reference to +// the public peer range before that catalog is pruned, so the surviving peer +// never points at a missing default/named catalog entry. +function normalizeVitestPeerCatalogSpec( + peerDependencies: Record | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!peerDependencies) { + return false; + } + const current = peerDependencies.vitest; + if (!current?.startsWith('catalog:')) { + return false; + } + const normalized = getCatalogDependencySpec(current, VITEST_VERSION, true, { + dependencyField: 'peerDependencies', + dependencyName: 'vitest', + catalogDependencyResolver, + }); + if (normalized === current) { + return false; + } + peerDependencies.vitest = normalized; + return true; +} + function isVitePlusOverrideSpec(value: string): boolean { return ( Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || @@ -3506,7 +3540,7 @@ function overridesSatisfyVitePlus( ): boolean { // Common case: a lingering managed `vitest` override is NOT satisfied — it // must be removed, so the bootstrap stays pending until it is. - if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && overrides?.vitest !== undefined) { + if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE && typeof overrides?.vitest === 'string') { return false; } return Object.keys(managedOverridePackages(usesVitest)).every((dependencyName) => @@ -3722,6 +3756,7 @@ function reconcileVitePlusBootstrapPackage( packageManager: PackageManager, supportCatalog: boolean, ensureVitePlus: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { const before = JSON.stringify(pkg); const usesVitest = projectUsesVitestDirectly(projectPath, pkg); @@ -3750,6 +3785,7 @@ function reconcileVitePlusBootstrapPackage( } alignVitestEcosystemPackages(pkg); + normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); const providerSourceModes = collectProviderSourceModes(projectPath); let usesAnyOptInProvider = false; @@ -3870,6 +3906,7 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.yarn || packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(childPackageJsonPath)) { @@ -3885,6 +3922,7 @@ export function detectVitePlusBootstrapPending( packageManager, supportCatalog, index === 0, + catalogDependencyResolver, ) ) { return true; @@ -4075,6 +4113,10 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager === PackageManager.yarn || workspaceInfo.packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; + const catalogDependencyResolver = createCatalogDependencyResolver( + projectPath, + workspaceInfo.packageManager, + ); editJsonFile< BootstrapPackageJson & { @@ -4090,6 +4132,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, true, + catalogDependencyResolver, ); if (workspaceInfo.packageManager === PackageManager.yarn) { @@ -4155,6 +4198,7 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager, supportCatalog, false, + catalogDependencyResolver, ); return childChanged ? pkg : undefined; }); @@ -4170,6 +4214,7 @@ export function ensureVitePlusBootstrap( : undefined; const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( + result.packageJson || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), @@ -4433,7 +4478,7 @@ function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean return sourceTreeMatches(projectPath, (content) => { return ( /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - /` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | | `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | | `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | | Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | @@ -36,7 +36,7 @@ Removing the old direct dependency was exercised on `node-modules/urllib` across | pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | | Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | -Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -83,6 +83,10 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deliberate protocol-pinned `vite-plus` spec | `migration-upgrade-vite-plus-protocol-pin-npm` | | Idempotent rerun on an already-current project | `migration-from-tsdown`, `migration-from-tsdown-json-config` | | Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | +| Peer `vitest` catalog references resolve before managed catalog pruning | `migration-upgrade-peer-vitest-catalog-pnpm` | +| Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | +| Whitespace-tolerant Vitest type-directive detection | `migration-upgrade-vitest-reference-whitespace-pnpm` | +| Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | ## Follow-ups (not in this change) From 42d9f1ce06d33e775aa681b96eeab0126499c647 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 20:51:26 +0800 Subject: [PATCH 12/32] fix(migrate): cover remaining vitest upgrade cases --- .../example.spec.ts | 3 + .../package.json | 11 ++ .../snap.txt | 38 ++++ .../steps.json | 9 + .../package.json | 19 ++ .../snap.txt | 25 +++ .../steps.json | 6 + .../.fixture/vite-plugin-gherkin/index.js | 1 + .../.fixture/vite-plugin-gherkin/package.json | 10 + .../package.json | 19 ++ .../snap.txt | 29 +++ .../steps.json | 8 + .../snap.txt | 12 +- .../steps.json | 5 +- .../package.json | 18 ++ .../snap.txt | 39 ++++ .../steps.json | 9 + .../tsconfig.json | 5 + .../version.ts | 3 + .../package.json | 3 +- .../snap.txt | 9 +- .../steps.json | 6 +- .../bun-catalog-file-protocol.spec.ts | 15 ++ .../src/migration/__tests__/migrator.spec.ts | 180 ++++++++++++++++-- packages/cli/src/migration/migrator.ts | 139 +++++++++++--- packages/cli/src/utils/package.ts | 91 ++++++++- packages/cli/src/utils/tsconfig.ts | 21 ++ rfcs/migrate-existing-projects.md | 36 ++-- 28 files changed, 693 insertions(+), 76 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts new file mode 100644 index 0000000000..fbd3232594 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/example.spec.ts @@ -0,0 +1,3 @@ +import { expect, it } from 'vitest'; + +it('works', () => expect(true).toBe(true)); diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json new file mode 100644 index 0000000000..08ef8b2b7d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vite": "^7.0.0", + "vitest": "^4.0.0" + }, + "packageManager": "yarn@4.12.0" +} diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt new file mode 100644 index 0000000000..48f7e4d4c8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -0,0 +1,38 @@ +> vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass +◇ Migrated . to Vite+ +• Node yarn +• 2 config updates applied, 1 file had imports rewritten + +> cat package.json # migrated dependency specs use the Yarn catalog immediately +{ + "name": "migration-standalone-yarn4-idempotent", + "scripts": { + "test": "vp test run", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "yarn@", + "resolutions": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + } +} + +> cat .yarnrc.yml # managed catalog entries are available to those specs +nodeLinker: node-modules +npmPreapprovedPackages: + - vitest + - '@vitest/*' +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vite-plus: latest + +> cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface +import { expect, it } from 'vite-plus/test'; + +it('works', () => expect(true).toBe(true)); + +> vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json new file mode 100644 index 0000000000..2462490ad8 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass", + "cat package.json # migrated dependency specs use the Yarn catalog immediately", + "cat .yarnrc.yml # managed catalog entries are available to those specs", + "cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface", + "vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json new file mode 100644 index 0000000000..971be76cb9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt new file mode 100644 index 0000000000..5d1d0d9b1c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt @@ -0,0 +1,25 @@ +> vp migrate --no-interactive # deprecated coverage-c8 has an independent version line +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version +{ + "name": "migration-upgrade-deprecated-coverage-c8-npm", + "devDependencies": { + "@vitest/coverage-c8": "^0.33.0", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json new file mode 100644 index 0000000000..86c4696b8d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # deprecated coverage-c8 has an independent version line", + "cat package.json # coverage-c8 must not be rewritten to a nonexistent Vitest 4 version" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js new file mode 100644 index 0000000000..f053ebf797 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json new file mode 100644 index 0000000000..53dde2cc8c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/.fixture/vite-plugin-gherkin/package.json @@ -0,0 +1,10 @@ +{ + "name": "vite-plugin-gherkin", + "version": "0.2.0", + "exports": { + ".": "./index.js" + }, + "peerDependencies": { + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json new file mode 100644 index 0000000000..391a849187 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt new file mode 100644 index 0000000000..eebc1c025c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -0,0 +1,29 @@ +> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata +> vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # package-local Vitest and its shared override remain aligned +{ + "name": "migration-upgrade-required-vitest-peer-metadata-npm", + "devDependencies": { + "vite-plugin-gherkin": "0.2.0", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json new file mode 100644 index 0000000000..738904c5e0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", + "vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name", + "cat package.json # package-local Vitest and its shared override remain aligned", + "vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index 42017f4cf2..a51e388a52 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -3,12 +3,11 @@ • Node pnpm • 2 config updates applied, 1 file had imports rewritten -> cat package.json # directive detection keeps package-local Vitest provisioned +> cat package.json # rewritten directive does not retain a redundant Vitest dependency { "name": "migration-upgrade-vitest-reference-whitespace-pnpm", "devDependencies": { "vite": "catalog:", - "vitest": "catalog:", "vite-plus": "catalog:" }, "devEngines": { @@ -26,18 +25,17 @@ > cat env.d.ts # directive is rewritten to the Vite+ public type surface /// -> cat pnpm-workspace.yaml # directive detection keeps shared Vitest management +> cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management catalog: vite: npm:@voidzero-dev/vite-plus-core@latest vite-plus: latest - vitest: overrides: vite: 'catalog:' - vitest: 'catalog:' peerDependencyRules: allowAny: - vite - - vitest allowedVersions: vite: '*' - vitest: '*' + +> vp migrate --no-interactive # directive rewriting is stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json index 24700d18cc..188941dff5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/steps.json @@ -1,8 +1,9 @@ { "commands": [ "vp migrate --no-interactive # TypeScript whitespace in a Vitest type directive is valid", - "cat package.json # directive detection keeps package-local Vitest provisioned", + "cat package.json # rewritten directive does not retain a redundant Vitest dependency", "cat env.d.ts # directive is rewritten to the Vite+ public type surface", - "cat pnpm-workspace.yaml # directive detection keeps shared Vitest management" + "cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management", + "vp migrate --no-interactive # directive rewriting is stable on rerun" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json new file mode 100644 index 0000000000..26701f311e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "latest", + "vitest": "4.1.8" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "4.1.8" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt new file mode 100644 index 0000000000..cce99f7a35 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -0,0 +1,39 @@ +> vp migrate --no-interactive # retained upstream references require package-local Vitest +◇ Migrated . to Vite+ +• Node npm +• Package manager settings configured + +> cat package.json # Vitest dependency and override stay aligned +{ + "name": "migration-upgrade-vitest-retained-references-npm", + "devDependencies": { + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat version.ts # vitest/package.json remains intentionally unre-written +import metadata from 'vitest/package.json'; + +console.log(metadata.version); + +> vp migrate --no-interactive # retained references remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json new file mode 100644 index 0000000000..2a598938ba --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "vp migrate --no-interactive # retained upstream references require package-local Vitest", + "cat package.json # Vitest dependency and override stay aligned", + "cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference", + "cat version.ts # vitest/package.json remains intentionally unre-written", + "vp migrate --no-interactive # retained references remain stable on rerun" + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts new file mode 100644 index 0000000000..3b2e0f0e80 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/version.ts @@ -0,0 +1,3 @@ +import metadata from 'vitest/package.json'; + +console.log(metadata.version); diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json index 6fc60c5d10..184290e34f 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/package.json @@ -4,7 +4,8 @@ "test": "vitest" }, "devDependencies": { + "@vitest/ui": "4.0.13", "vite": "^7.0.0", - "vitest": "^4.0.0" + "vitest": "4.0.13" } } diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index bd5f121f6b..ad04a2be32 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -3,7 +3,7 @@ • Node pnpm • 2 config updates applied -> cat package.json # user's vitest dependency should be preserved +> cat package.json # user's Vitest and exact-peer UI versions should both be preserved { "name": "migration-vitest-unmanaged-override", "scripts": { @@ -11,8 +11,9 @@ "prepare": "vp config" }, "devDependencies": { + "@vitest/ui": "", "vite": "catalog:", - "vitest": "^4.0.0", + "vitest": "", "vite-plus": "catalog:" }, "devEngines": { @@ -24,6 +25,7 @@ } } +> node -e "const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)" # exact user-owned versions remain unchanged > cat pnpm-workspace.yaml # no vitest catalog or override should be introduced catalog: vite: npm:@voidzero-dev/vite-plus-core@latest @@ -35,3 +37,6 @@ peerDependencyRules: - vite allowedVersions: vite: '*' + +> vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun +This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json index 86631201d7..767e603b45 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/steps.json @@ -4,7 +4,9 @@ }, "commands": [ "vp migrate --no-interactive # vitest omitted from managed overrides must remain user-owned", - "cat package.json # user's vitest dependency should be preserved", - "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced" + "cat package.json # user's Vitest and exact-peer UI versions should both be preserved", + "node -e \"const pkg = require('./package.json'); if (pkg.devDependencies.vitest !== '4.0.13' || pkg.devDependencies['@vitest/ui'] !== '4.0.13') process.exit(1)\" # exact user-owned versions remain unchanged", + "cat pnpm-workspace.yaml # no vitest catalog or override should be introduced", + "vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun" ] } diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index 6fdbc3d704..0594907345 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -205,4 +205,19 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], ).toBe('file:/tmp/tgz/vite-plus-0.0.0.tgz'); }); + + it('does not align Vitest ecosystem packages when Vitest is unmanaged', () => { + const pkg = { + devDependencies: { + vite: '^7.0.0', + vitest: '4.0.13', + '@vitest/ui': '4.0.13', + }, + }; + + rewritePackageJson(pkg, PackageManager.npm); + + expect(pkg.devDependencies.vitest).toBe('4.0.13'); + expect(pkg.devDependencies['@vitest/ui']).toBe('4.0.13'); + }); }); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f979ed54e8..2be6c2b2c6 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1503,6 +1503,130 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); + it('does not align deprecated @vitest/coverage-c8 to a nonexistent Vitest 4 version', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + '@vitest/coverage-c8': '^0.33.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-c8']).toBe('^0.33.0'); + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + }); + + it('detects a required Vitest peer from Yarn PnP dependency metadata', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + }, + resolutions: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + devEngines: { + packageManager: { name: 'yarn', version: '4.12.0', onFail: 'download' }, + }, + }), + ); + const pluginDir = path.join(tmpDir, '.yarn/cache/vite-plugin-gherkin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'vite-plugin-gherkin', + version: '0.2.0', + exports: { '.': './index.js' }, + peerDependencies: { vitest: '^4.1.0' }, + }), + ); + fs.writeFileSync(path.join(pluginDir, 'index.js'), 'module.exports = {};\n'); + fs.writeFileSync( + path.join(tmpDir, '.pnp.cjs'), + [ + "const path = require('node:path');", + 'module.exports = {', + ' resolveToUnqualified(request) {', + " if (request !== 'vite-plugin-gherkin') throw new Error('not found');", + " return path.join(__dirname, '.yarn/cache/vite-plugin-gherkin');", + ' },', + '};', + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.yarn)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + resolutions: Record; + }; + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(pkg.resolutions.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + }); + + it.each([ + { + name: 'compilerOptions.types', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ), + }, + { + name: 'vitest/package.json', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'version.ts'), + "import metadata from 'vitest/package.json';\nconsole.log(metadata.version);\n", + ), + }, + ])('keeps package-local Vitest for retained $name references', ({ writeReference }) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'latest' }, + overrides: { vite: 'npm:@voidzero-dev/vite-plus-core@latest' }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + writeReference(tmpDir); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + it('does not treat @vitest/eslint-plugin as runner usage', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -1751,15 +1875,12 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); - it('recognizes whitespace in retained Vitest triple-slash directives', () => { + it('rewrites whitespace-tolerant Vitest directives without leaving rerun mutations', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'typed-library', - devDependencies: { 'vite-plus': 'catalog:' }, - devEngines: { - packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, - }, + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, }), ); fs.writeFileSync(path.join(tmpDir, 'env.d.ts'), '/// \n'); @@ -1779,13 +1900,21 @@ describe('ensureVitePlusBootstrap', () => { ].join('\n'), ); - ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); - const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; - }; - expect(pkg.devDependencies.vitest).toBe('catalog:'); - expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstWorkspace = fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8'); + const firstDirective = fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8'); + + expect(firstPackageJson).not.toContain('"vitest"'); + expect(firstWorkspace).not.toContain('vitest:'); + expect(firstDirective).toContain('types = "vite-plus/test"'); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'utf8')).toBe(firstWorkspace); + expect(fs.readFileSync(path.join(tmpDir, 'env.d.ts'), 'utf8')).toBe(firstDirective); }); it('does not remain pending for an object-valued nested Vitest override', () => { @@ -2298,6 +2427,35 @@ describe('ensureVitePlusBootstrap', () => { }; expect(workspace.packages).toEqual(['packages/*']); }); + + it('writes catalog specs during the first standalone Yarn migration', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: '^7.0.0', vitest: '^4.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'example.spec.ts'), + "import { expect, it } from 'vitest';\nit('works', () => expect(true).toBe(true));\n", + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.yarn); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + + const firstPackageJson = fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'); + const firstYarnrc = fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8'); + const pkg = JSON.parse(firstPackageJson) as { devDependencies: Record }; + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); + + rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); + expect(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')).toBe(firstPackageJson); + expect(fs.readFileSync(path.join(tmpDir, '.yarnrc.yml'), 'utf8')).toBe(firstYarnrc); + }); }); describe('rewriteStandaloneProject pnpm workspace yaml', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5f64bd2f1f..3daff5a557 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -44,6 +44,7 @@ import { findTsconfigFiles, hasBaseUrlInTsconfig, hasTypesToRewriteInTsconfig, + hasVitestTypesInTsconfig, removeDeprecatedTsconfigFalseOption, rewriteTypesInTsconfig, } from '../utils/tsconfig.ts'; @@ -111,15 +112,22 @@ const OPT_IN_BROWSER_PROVIDERS = [WEBDRIVERIO_PROVIDER, PLAYWRIGHT_PROVIDER] as // family, and the runtime internals all pin `vitest: `), so any the // project lists must match the bundled vitest or Vitest runs mixed copies (the // `define-config.ts` coverage guard fail-fasts on exactly this skew). -// `@vitest/eslint-plugin` is the exception: it versions on its own line with a -// `vitest: *` peer, so it must NOT be pinned to the vitest version. -const VITEST_ALIGN_EXCLUDED = new Set(['@vitest/eslint-plugin']); +// `@vitest/eslint-plugin` versions on its own line, and deprecated +// `@vitest/coverage-c8` never published on the Vitest 4 line, so neither may be +// pinned to the bundled Vitest version. +const VITEST_ALIGN_EXCLUDED = new Set([ + '@vitest/eslint-plugin', + // Deprecated at 0.33.0 and replaced by @vitest/coverage-v8. It does not + // publish versions on Vitest's current release line, so pinning it to the + // bundled Vitest version creates a dependency spec that does not exist. + '@vitest/coverage-c8', +]); // Official packages that do not declare a required `vitest` peer. Keep them // aligned when a project lists them directly, but do not add a direct vitest // merely because they are present. const VITEST_DIRECT_USAGE_EXCLUDED = new Set([ - ...VITEST_ALIGN_EXCLUDED, + '@vitest/eslint-plugin', '@vitest/expect', '@vitest/mocker', '@vitest/pretty-format', @@ -561,6 +569,49 @@ function projectListsVitestEcosystemDep(pkg: { ); } +// Detect installed dependencies whose package metadata declares a required +// Vitest peer. Package names are not authoritative: integrations such as +// `vite-plugin-gherkin` require Vitest without containing "vitest" in their +// own name. Optional peers do not require package-local provisioning. +function projectListsRequiredVitestPeer( + projectPath: string, + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }, +): boolean { + const dependencyNames = new Set([ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.devDependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]); + dependencyNames.delete('vitest'); + + for (const name of dependencyNames) { + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata) { + continue; + } + try { + const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + if ( + typeof installedPkg.peerDependencies?.vitest === 'string' && + installedPkg.peerDependenciesMeta?.vitest?.optional !== true + ) { + return true; + } + } catch { + // Missing or unreadable installed metadata cannot provide a peer signal; + // retain the existing package-name and source-based fallbacks below. + } + } + return false; +} + // True iff the project uses vitest DIRECTLY — via a dependency that is expected // to have a required vitest peer (see `projectListsVitestEcosystemDep`), an // upstream `vitest` module specifier, or vitest browser mode. Drives @@ -575,9 +626,11 @@ function projectUsesVitestDirectly( devDependencies?: Record; peerDependencies?: Record; }, + requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), ): boolean { return ( projectListsVitestEcosystemDep(pkg) || + requiredVitestPeer || // Browser packages declared only as peers still become direct installs: // rewritePackageJson/reconcileVitePlusBootstrapPackage promote opt-in // providers into devDependencies and treat the bundled browser packages as @@ -1681,7 +1734,8 @@ export function rewriteStandaloneProject( }>(packageJsonPath, (pkg) => { shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); - usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); + usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -1796,24 +1850,24 @@ export function rewriteStandaloneProject( } } + const supportCatalog = usePnpmWorkspaceYaml || packageManager === PackageManager.yarn; extractedStagedConfig = rewritePackageJson( pkg, packageManager, - usePnpmWorkspaceYaml, + supportCatalog, skipStagedMigration, catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), usesVitest, sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // ensure vite-plus is in devDependencies if (!pkg.devDependencies?.[VITE_PLUS_NAME] || isForceOverrideMode()) { const version = - usePnpmWorkspaceYaml && !VITE_PLUS_VERSION.startsWith('file:') - ? 'catalog:' - : VITE_PLUS_VERSION; + supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION; pkg.devDependencies = { ...pkg.devDependencies, [VITE_PLUS_NAME]: version, @@ -2020,6 +2074,7 @@ export function rewriteMonorepoProject( scripts?: Record; installConfig?: { hoistingLimits?: string }; }>(packageJsonPath, (pkg) => { + const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); // rewrite scripts in package.json extractedStagedConfig = rewritePackageJson( pkg, @@ -2029,8 +2084,9 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), - projectUsesVitestDirectly(projectPath, pkg), + projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer), sourceTreeReferencesRetainedVitestModule(projectPath), + requiredVitestPeer, ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own @@ -3727,6 +3783,9 @@ function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: st // vitest version. Returns true if any spec changed. These are plain dependency // entries (not overrides), so this is package-manager agnostic. function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE) { + return false; + } const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; let changed = false; for (const dependencies of dependencyGroups) { @@ -3801,7 +3860,9 @@ function reconcileVitePlusBootstrapPackage( (dependencies) => dependencies?.[provider] !== undefined, ); if (installGroup) { - installGroup[provider] = VITEST_VERSION; + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroup[provider] = VITEST_VERSION; + } } else { pkg.devDependencies ??= {}; pkg.devDependencies[provider] = VITEST_VERSION; @@ -3843,11 +3904,13 @@ function reconcileVitePlusBootstrapPackage( // the same exact version as the Vite+ runner. const existingGroup = installGroups.find((dependencies) => dependencies?.vitest !== undefined); if (existingGroup) { - existingGroup.vitest = getCatalogDependencySpec( - existingGroup.vitest, - VITEST_VERSION, - supportCatalog, - ); + if (VITEST_IS_MANAGED_OVERRIDE) { + existingGroup.vitest = getCatalogDependencySpec( + existingGroup.vitest, + VITEST_VERSION, + supportCatalog, + ); + } } else { pkg.devDependencies ??= {}; pkg.devDependencies.vitest = getCatalogDependencySpec( @@ -4470,17 +4533,21 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } -// Normal imports from `vitest` are rewritten to `vite-plus/test` later in the -// same migration and therefore do not justify a lasting direct dependency. -// Module augmentations and triple-slash type references deliberately retain the -// upstream module identity, so keep vitest package-local for those surfaces. +// Normal imports and triple-slash type directives from `vitest` are rewritten +// to `vite-plus/test` later in the same migration and therefore do not justify +// a lasting direct dependency. Module augmentations, `vitest/package.json`, and +// compilerOptions.types entries deliberately retain the upstream package +// identity, so keep Vitest package-local for those surfaces. function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { - return sourceTreeMatches(projectPath, (content) => { - return ( - /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - / { + return ( + /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + content.includes('vitest/package.json') + ); + }) + ); } function usesVitestBrowserMode(projectPath: string): boolean { @@ -4535,10 +4602,13 @@ export function rewritePackageJson( // is REMOVED so it arrives transitively through vite-plus. Defaults to true to // preserve legacy behavior for callers that don't compute the signal. usesVitestDirectly = true, - // Module augmentations/triple-slash references intentionally retain the - // upstream `vitest` identity after import rewriting and therefore require a - // package-local provider under strict dependency layouts. + // Module augmentations, compilerOptions.types, and `vitest/package.json` + // intentionally retain the upstream package identity after import rewriting + // and therefore require a package-local provider under strict layouts. retainedVitestModule = false, + // Installed dependency metadata can reveal required Vitest peers whose + // package names do not include "vitest". + requiredVitestPeer = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -4699,7 +4769,9 @@ export function rewritePackageJson( (deps) => deps?.[provider] !== undefined, ); if (installGroup) { - installGroup[provider] = VITEST_VERSION; + if (VITEST_IS_MANAGED_OVERRIDE) { + installGroup[provider] = VITEST_VERSION; + } } else { pkg.devDependencies ??= {}; pkg.devDependencies[provider] = VITEST_VERSION; @@ -4790,7 +4862,11 @@ export function rewritePackageJson( // already owns it. The guard below still no-ops when a direct `vitest` already exists, // so a genuine normalize pass of an already-correct project mutates nothing. const needDirectVitest = - needVitePlus || effectiveBrowserMode || isVitestAdjacent || retainedVitestModule; + needVitePlus || + effectiveBrowserMode || + isVitestAdjacent || + retainedVitestModule || + requiredVitestPeer; if (needVitePlus || shouldNormalizeExistingVitePlus) { pkg.devDependencies = { ...pkg.devDependencies, @@ -4818,6 +4894,7 @@ export function rewritePackageJson( !installableDeps.vitest && (effectiveBrowserMode || retainedVitestModule || + requiredVitestPeer || Object.keys(installableDeps).some((name) => name.includes('vitest'))) ) { pkg.devDependencies ??= {}; diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index ef3faccecf..14a8587766 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -19,15 +19,97 @@ interface PackageMetadata { path: string; } +function findOwningPackageJson(resolvedPath: string, packageName: string): string | undefined { + let currentDir: string; + try { + currentDir = fs.statSync(resolvedPath).isDirectory() + ? resolvedPath + : path.dirname(resolvedPath); + } catch { + return undefined; + } + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, 'package.json'); + if (fs.existsSync(candidate)) { + try { + const candidatePkg = JSON.parse(fs.readFileSync(candidate, 'utf8')); + if (candidatePkg.name === packageName) { + return candidate; + } + } catch { + // Keep walking: this may be an unrelated or malformed nested manifest. + } + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + +function resolvePackageJsonWithNode( + require: ReturnType, + packageName: string, +): string | undefined { + try { + return require.resolve(`${packageName}/package.json`); + } catch { + // Packages with an exports map often do not expose `./package.json`. + } + try { + return findOwningPackageJson(require.resolve(packageName), packageName); + } catch { + return undefined; + } +} + +function findPnpApiPath(projectPath: string): string | undefined { + let currentDir = path.resolve(projectPath); + while (currentDir !== path.dirname(currentDir)) { + const candidate = path.join(currentDir, '.pnp.cjs'); + if (fs.existsSync(candidate)) { + return candidate; + } + currentDir = path.dirname(currentDir); + } + return undefined; +} + export function detectPackageMetadata( projectPath: string, packageName: string, ): PackageMetadata | void { + // Create require from the project path so resolution only searches the + // project's dependencies, not the global installation's. + const require = createRequire(path.join(projectPath, 'noop.js')); + let pkgFilePath = resolvePackageJsonWithNode(require, packageName); + if (!pkgFilePath) { + const pnpApiPath = findPnpApiPath(projectPath); + if (!pnpApiPath) { + return; + } + try { + const pnpApi = createRequire(pnpApiPath)(pnpApiPath) as { + resolveToUnqualified: (request: string, issuer: string) => string; + setup?: () => void; + }; + // Activating the generated API makes archive-backed Yarn cache paths + // readable through Node's fs implementation as well. + pnpApi.setup?.(); + const unqualified = pnpApi.resolveToUnqualified( + packageName, + path.join(projectPath, 'noop.js'), + ); + pkgFilePath = findOwningPackageJson(unqualified, packageName); + if (!pkgFilePath) { + pkgFilePath = resolvePackageJsonWithNode(require, packageName); + } + } catch { + return; + } + } + if (!pkgFilePath) { + return; + } try { - // Create require from the project path so resolution only searches - // the project's node_modules, not the global installation's - const require = createRequire(path.join(projectPath, 'noop.js')); - const pkgFilePath = require.resolve(`${packageName}/package.json`); const pkg = JSON.parse(fs.readFileSync(pkgFilePath, 'utf8')); return { name: pkg.name, @@ -35,7 +117,6 @@ export function detectPackageMetadata( path: path.dirname(pkgFilePath), }; } catch { - // ignore MODULE_NOT_FOUND error return; } } diff --git a/packages/cli/src/utils/tsconfig.ts b/packages/cli/src/utils/tsconfig.ts index f421dae252..a842e1360f 100644 --- a/packages/cli/src/utils/tsconfig.ts +++ b/packages/cli/src/utils/tsconfig.ts @@ -192,6 +192,27 @@ export function hasTypesToRewriteInTsconfig(filePath: string): boolean { ); } +export function hasVitestTypesInTsconfig(filePath: string): boolean { + let text: string; + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch { + return false; + } + + const parsed = parseJsonc(text) as { + compilerOptions?: { types?: unknown[] }; + } | null; + + const types = parsed?.compilerOptions?.types; + return ( + Array.isArray(types) && + types.some((type) => + typeof type === 'string' ? type === 'vitest' || type.startsWith('vitest/') : false, + ) + ); +} + export function rewriteTypesInTsconfig(filePath: string): boolean { let text: string; try { diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 44c73cf339..64d74a4e8e 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,20 +23,20 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` module reference. This includes official packages with exact peers (`@vitest/ui`, coverage providers, browser providers) and third-party integrations with range peers (`vitest-browser-react` / `-vue` / `-svelte`, ...). The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | Align every official `@vitest/*` package the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer); it neither triggers a `vitest` install nor a shared override. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | - -Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. +| Area | Rule | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` package reference. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | + +Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -56,6 +56,7 @@ How each package the `vitest` ecosystem rule covers is handled, verified against | `@vitest/browser-webdriverio` | `4.1.9` + `webdriverio` | opt-in: pin to `VITEST_VERSION`, keep `webdriverio` and direct `vitest` | | `@vitest/expect` `/runner` `/snapshot` `/spy` `/utils` `/mocker` `/pretty-format` `/ws-client` | none | transitive runtime packages; align if listed, but do not add `vitest` for them alone | | `@vitest/eslint-plugin` | `*` | left as-is (own version line, e.g. `1.6.x`) | +| `@vitest/coverage-c8` | `>=0.30.0 <1` | left as-is (deprecated at `0.33.0`; there is no package version matching Vitest 4) | | `vitest-browser-react` `/-vue` `/-svelte`, ... | `^4` (range) | third-party, own versioning; left at a compatible release, with a package-local `vitest` plus shared override | ## Implementation @@ -85,8 +86,13 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Reinstall and lockfile refresh after the alias rewrite | `migration-standalone-npm` | | Peer `vitest` catalog references resolve before managed catalog pruning | `migration-upgrade-peer-vitest-catalog-pnpm` | | Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | -| Whitespace-tolerant Vitest type-directive detection | `migration-upgrade-vitest-reference-whitespace-pnpm` | +| Whitespace-tolerant Vitest directives rewrite without leaving transient pins | `migration-upgrade-vitest-reference-whitespace-pnpm` | | Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | +| Retained `compilerOptions.types` and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | +| Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | +| Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | +| Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | +| Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | ## Follow-ups (not in this change) From e43e47a93b9eb0ec6aaf1910b1c115fe4848ae94 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:00:01 +0800 Subject: [PATCH 13/32] fix(test): normalize snapshot file endings --- packages/tools/src/snap-test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index b8c0bdde1b..da978ea823 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -715,7 +715,10 @@ async function runTestCase( } } - const newSnapContent = newSnap.join('\n'); + // Command output commonly ends with multiple newlines. Preserve one existing + // terminal newline, but collapse extras so rerun snapshots do not gain a + // blank line at EOF on every invocation. + const newSnapContent = newSnap.join('\n').replace(/(?:\r?\n)+$/, '\n'); await fsPromises.writeFile(`${casesDir}/${name}/snap.txt`, newSnapContent); console.log('%s finished in %dms', name, Date.now() - startTime); From 1f0945e83bf69c01871ce6c7e1aa7bb341d6eb43 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:06:14 +0800 Subject: [PATCH 14/32] test(migrate): sync idempotency snapshots --- .../migration-standalone-yarn4-idempotent/snap.txt | 1 + .../migration-upgrade-browser-peer-only-pnpm/snap.txt | 1 + .../migration-upgrade-nested-vitest-override-npm/snap.txt | 1 + .../migration-upgrade-peer-vitest-catalog-pnpm/snap.txt | 1 + .../snap.txt | 1 + .../snap.txt | 1 + .../snap.txt | 1 + .../migration-vitest-unmanaged-override/snap.txt | 1 + packages/tools/src/snap-test.ts | 5 +---- 9 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index 48f7e4d4c8..a61ab8c68d 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -36,3 +36,4 @@ it('works', () => expect(true).toBe(true)); > vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 4c60c8d885..51b2bb428e 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -42,3 +42,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt index e7a9d733d6..d170208fe3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -25,3 +25,4 @@ This project is already using Vite+! Happy coding! > vp migrate --no-interactive # nested override must not make migration permanently pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt index d7f208b469..61c5a2be7b 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -37,3 +37,4 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index eebc1c025c..dafe572187 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -27,3 +27,4 @@ > vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index a51e388a52..be3bfa3b44 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -39,3 +39,4 @@ peerDependencyRules: > vp migrate --no-interactive # directive rewriting is stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index cce99f7a35..8fc11d08b5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -37,3 +37,4 @@ console.log(metadata.version); > vp migrate --no-interactive # retained references remain stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index ad04a2be32..0369832eb5 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -40,3 +40,4 @@ peerDependencyRules: > vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun This project is already using Vite+! Happy coding! + diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index da978ea823..b8c0bdde1b 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -715,10 +715,7 @@ async function runTestCase( } } - // Command output commonly ends with multiple newlines. Preserve one existing - // terminal newline, but collapse extras so rerun snapshots do not gain a - // blank line at EOF on every invocation. - const newSnapContent = newSnap.join('\n').replace(/(?:\r?\n)+$/, '\n'); + const newSnapContent = newSnap.join('\n'); await fsPromises.writeFile(`${casesDir}/${name}/snap.txt`, newSnapContent); console.log('%s finished in %dms', name, Date.now() - startTime); From 376a5b8fefc052c3a8662229f540deb4d8d8e378 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 21 Jun 2026 21:21:59 +0800 Subject: [PATCH 15/32] test(create): update standalone Yarn catalog snapshot --- packages/cli/snap-tests/create-approve-builds-yarn/snap.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 03d9474be1..09e34b0952 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -16,7 +16,7 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "dependenciesMeta": { "core-js": { @@ -57,7 +57,7 @@ These dependencies may not work until built. Enable them in the workspace root p "core-js": "3.39.0" }, "devDependencies": { - "vite-plus": "latest" + "vite-plus": "catalog:" }, "resolutions": { "vite": "npm:@voidzero-dev/vite-plus-core@latest" From 2574f2e3faca448cf0319b2cd835720876c1915b Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 13:57:37 +0800 Subject: [PATCH 16/32] fix(migrate): preserve vitest imports for Nuxt tests --- crates/vite_migration/src/import_rewriter.rs | 253 +++++++++++++++--- crates/vite_migration/src/lib.rs | 5 +- packages/cli/binding/index.d.cts | 9 +- packages/cli/binding/src/migration.rs | 23 +- .../.fixture/nuxt-test-utils/package.json | 12 + .../package.json | 14 + .../packages/nuxt/nuxt.spec.ts | 7 + .../packages/nuxt/package.json | 8 + .../packages/unit/package.json | 7 + .../packages/unit/unit.spec.ts | 3 + .../pnpm-workspace.yaml | 10 + .../snap.txt | 61 +++++ .../steps.json | 17 ++ .../.fixture/nuxt-test-utils/package.json | 12 + .../nuxt.spec.ts | 8 + .../package.json | 19 ++ .../snap.txt | 46 ++++ .../steps.json | 15 ++ .../unit.spec.ts | 3 + .../lint-vite-plus-imports-nuxt/package.json | 8 + .../lint-vite-plus-imports-nuxt/snap.txt | 41 +++ .../src/nuxt.spec.ts | 7 + .../src/unit.spec.ts | 3 + .../lint-vite-plus-imports-nuxt/steps.json | 10 + .../vite.config.ts | 10 + .../fixtures/nuxt-test-utils/package.json | 6 + .../cli/src/__tests__/oxlint-plugin.spec.ts | 23 ++ .../src/migration/__tests__/migrator.spec.ts | 109 ++++++++ packages/cli/src/migration/bin.ts | 79 +++++- packages/cli/src/migration/migrator.ts | 161 +++++++++-- packages/cli/src/migration/report.ts | 2 + packages/cli/src/oxlint-plugin.ts | 67 ++++- rfcs/migrate-existing-projects.md | 69 +++-- 33 files changed, 1049 insertions(+), 78 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json create mode 100644 packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts create mode 100644 packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json diff --git a/crates/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index d0de5a0840..350f2e2538 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1575,6 +1575,34 @@ static PARSED_VITEST_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_VITEST_RULES).expect("failed to parse vitest rewrite rules") }); +const BARE_VITEST_RULE_IDS: [&str; 4] = [ + "rewrite-vitest-import", + "rewrite-vitest-export", + "rewrite-vitest-require", + "rewrite-vitest-dynamic-import", +]; + +fn is_bare_vitest_rule(rule: &RuleConfig) -> bool { + BARE_VITEST_RULE_IDS.contains(&rule.id.as_str()) +} + +static PARSED_BARE_VITEST_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(is_bare_vitest_rule) + .collect() +}); + +static PARSED_VITEST_RULES_WITHOUT_BARE: LazyLock>> = + LazyLock::new(|| { + ast_grep::load_rules(REWRITE_VITEST_RULES) + .expect("failed to parse vitest rewrite rules") + .into_iter() + .filter(|rule| !is_bare_vitest_rule(rule)) + .collect() + }); + static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); @@ -1905,6 +1933,20 @@ struct SkipPackages { skip_tsdown: bool, } +#[derive(Debug, Clone, Copy, Default)] +struct PackageRewriteContext { + skip_packages: SkipPackages, + uses_nuxt_test_utils: bool, +} + +/// Options controlling directory-wide import rewriting. +#[derive(Debug, Clone, Copy, Default)] +pub struct RewriteImportsOptions { + /// Preserve exact bare `vitest` module specifiers in files that directly + /// reference `@nuxt/test-utils`, provided the nearest package declares it. + pub preserve_bare_vitest_in_nuxt_files: bool, +} + impl SkipPackages { /// Check if all packages should be skipped (file can be skipped entirely) const fn all_skipped(&self) -> bool { @@ -1937,15 +1979,15 @@ fn find_nearest_package_json(file_path: &Path, root: &Path) -> Option { /// Parse package.json and check which packages are in peerDependencies or dependencies. /// Returns default (no skipping) if package.json doesn't exist or can't be parsed. -fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { +fn get_package_rewrite_context(package_json_path: &Path) -> PackageRewriteContext { let content = match std::fs::read_to_string(package_json_path) { Ok(c) => c, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; let pkg: serde_json::Value = match serde_json::from_str(&content) { Ok(p) => p, - Err(_) => return SkipPackages::default(), + Err(_) => return PackageRewriteContext::default(), }; // Helper to check if a package exists in a dependencies object @@ -1955,16 +1997,29 @@ fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages .is_some_and(|deps| deps.contains_key(package_name)) }; - // Check both peerDependencies and dependencies - SkipPackages { - skip_vite: has_package("peerDependencies", "vite") || has_package("dependencies", "vite"), - skip_vitest: has_package("peerDependencies", "vitest") - || has_package("dependencies", "vitest"), - skip_tsdown: has_package("peerDependencies", "tsdown") - || has_package("dependencies", "tsdown"), + // Peer and runtime dependencies preserve the existing whole-package skip + // behavior. Nuxt compatibility is narrower and accepts the three install + // groups where @nuxt/test-utils is normally declared. + PackageRewriteContext { + skip_packages: SkipPackages { + skip_vite: has_package("peerDependencies", "vite") + || has_package("dependencies", "vite"), + skip_vitest: has_package("peerDependencies", "vitest") + || has_package("dependencies", "vitest"), + skip_tsdown: has_package("peerDependencies", "tsdown") + || has_package("dependencies", "tsdown"), + }, + uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] + .into_iter() + .any(|key| has_package(key, "@nuxt/test-utils")), } } +#[cfg(test)] +fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages { + get_package_rewrite_context(package_json_path).skip_packages +} + /// Result of rewriting imports in a file #[derive(Debug)] struct RewriteResult { @@ -1972,6 +2027,8 @@ struct RewriteResult { pub content: String, /// Whether any changes were made pub updated: bool, + /// Whether an exact bare `vitest` specifier was intentionally preserved. + pub preserved_bare_vitest: bool, } /// Result of rewriting imports in multiple files @@ -1981,6 +2038,8 @@ pub struct BatchRewriteResult { pub modified_files: Vec, /// Files that had no changes pub unchanged_files: Vec, + /// Nuxt test-utils files where exact bare `vitest` imports were preserved. + pub preserved_bare_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, } @@ -2021,47 +2080,60 @@ enum FileResult { /// } /// ``` pub fn rewrite_imports_in_directory(root: &Path) -> Result { + rewrite_imports_in_directory_with_options(root, RewriteImportsOptions::default()) +} + +/// Rewrite imports with file-scoped compatibility options. +pub fn rewrite_imports_in_directory_with_options( + root: &Path, + options: RewriteImportsOptions, +) -> Result { let walk_result = file_walker::find_ts_files(root)?; - // Pre-compute skip_packages for each file (requires mutable cache, done sequentially) - let mut skip_packages_cache: HashMap = HashMap::new(); - let files_with_skip: Vec<(PathBuf, SkipPackages)> = walk_result + // Pre-compute package context for each file (requires mutable cache, done sequentially). + let mut package_context_cache: HashMap = HashMap::new(); + let files_with_context: Vec<(PathBuf, PackageRewriteContext)> = walk_result .files .into_iter() .map(|file_path| { - let skip_packages = + let package_context = if let Some(package_json_path) = find_nearest_package_json(&file_path, root) { - *skip_packages_cache + *package_context_cache .entry(package_json_path.clone()) - .or_insert_with(|| get_skip_packages_from_package_json(&package_json_path)) + .or_insert_with(|| get_package_rewrite_context(&package_json_path)) } else { - SkipPackages::default() + PackageRewriteContext::default() }; - (file_path, skip_packages) + (file_path, package_context) }) .collect(); // Process files in parallel using rayon - let results: Vec<(PathBuf, FileResult)> = files_with_skip + let results: Vec<(PathBuf, FileResult, bool)> = files_with_context .into_par_iter() - .map(|(file_path, skip_packages)| { + .map(|(file_path, package_context)| { + let skip_packages = package_context.skip_packages; if skip_packages.all_skipped() { - return (file_path, FileResult::Unchanged); + return (file_path, FileResult::Unchanged, false); } - match rewrite_import(&file_path, &skip_packages) { + match rewrite_import( + &file_path, + &skip_packages, + options.preserve_bare_vitest_in_nuxt_files && package_context.uses_nuxt_test_utils, + ) { Ok(rewrite_result) => { if rewrite_result.updated { if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { - (file_path, FileResult::Error(e.to_string())) + (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified) + (file_path, FileResult::Modified, rewrite_result.preserved_bare_vitest) } } else { - (file_path, FileResult::Unchanged) + (file_path, FileResult::Unchanged, rewrite_result.preserved_bare_vitest) } } - Err(e) => (file_path, FileResult::Error(e.to_string())), + Err(e) => (file_path, FileResult::Error(e.to_string()), false), } }) .collect(); @@ -2070,10 +2142,14 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result batch_result.modified_files.push(file_path), FileResult::Unchanged => batch_result.unchanged_files.push(file_path), @@ -2100,12 +2176,28 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result Result { +fn rewrite_import( + file_path: &Path, + skip_packages: &SkipPackages, + preserve_bare_vitest_in_nuxt_files: bool, +) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - rewrite_import_content(&content, skip_packages) + let preserve_bare_vitest = + preserve_bare_vitest_in_nuxt_files && source_directly_references_nuxt_test_utils(&content); + rewrite_import_content_with_options(&content, skip_packages, preserve_bare_vitest) +} + +fn source_directly_references_nuxt_test_utils(content: &str) -> bool { + static RE_NUXT_TEST_UTILS_REFERENCE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?m)(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)["']@nuxt/test-utils(?:/[^"']+)?["']"#, + ) + .unwrap() + }); + RE_NUXT_TEST_UTILS_REFERENCE.is_match(content) } /// Fast pre-filter to skip expensive AST parsing for files with no relevant imports. @@ -2128,17 +2220,31 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo /// /// This is the internal function that performs the actual rewrite using ast-grep. /// Packages that are in peerDependencies or dependencies will be skipped. +#[cfg(test)] fn rewrite_import_content( content: &str, skip_packages: &SkipPackages, +) -> Result { + rewrite_import_content_with_options(content, skip_packages, false) +} + +fn rewrite_import_content_with_options( + content: &str, + skip_packages: &SkipPackages, + preserve_bare_vitest: bool, ) -> Result { // Fast path: skip AST parsing if the file doesn't contain any target strings if !content_may_need_rewriting(content, skip_packages) { - return Ok(RewriteResult { content: content.to_string(), updated: false }); + return Ok(RewriteResult { + content: content.to_string(), + updated: false, + preserved_bare_vitest: false, + }); } let mut new_content = content.to_string(); let mut updated = false; + let mut preserved_bare_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2151,7 +2257,15 @@ fn rewrite_import_content( // Apply vitest rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vitest { - let vitest_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_VITEST_RULES); + let vitest_rules = if preserve_bare_vitest { + let bare_rewrite = + ast_grep::apply_loaded_rules(&new_content, &PARSED_BARE_VITEST_RULES); + preserved_bare_vitest = bare_rewrite != new_content; + &*PARSED_VITEST_RULES_WITHOUT_BARE + } else { + &*PARSED_VITEST_RULES + }; + let vitest_content = ast_grep::apply_loaded_rules(&new_content, vitest_rules); if vitest_content != new_content { new_content = vitest_content; updated = true; @@ -2171,7 +2285,7 @@ fn rewrite_import_content( // These cannot be handled by ast-grep because they are parsed as comments. updated |= rewrite_reference_types(&mut new_content, skip_packages); - Ok(RewriteResult { content: new_content, updated }) + Ok(RewriteResult { content: new_content, updated, preserved_bare_vitest }) } #[cfg(test)] @@ -2301,7 +2415,7 @@ export default defineConfig({{ .unwrap(); // Run the rewrite - let result = rewrite_import(&vite_config_path, &SkipPackages::default()).unwrap(); + let result = rewrite_import(&vite_config_path, &SkipPackages::default(), false).unwrap(); assert!(result.updated); assert_eq!( @@ -2778,6 +2892,79 @@ describe('test', () => {});"#, assert!(!utils_content.contains("vite-plus")); } + #[test] + fn test_preserves_only_bare_vitest_in_nuxt_test_utils_files() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("package.json"), + r#"{ + "devDependencies": { + "@nuxt/test-utils": "4.0.3", + "vitest": "4.1.9" + } +}"#, + ) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + r#"import { vi } from 'vitest'; +export { expect } from 'vitest'; +const runtime = require('vitest'); +const dynamic = import('vitest'); +import { defineConfig } from 'vitest/config'; +import { startVitest } from 'vitest/node'; +import { page } from '@vitest/browser/context'; +import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, + ) + .unwrap(); + fs::write(temp.path().join("ordinary.spec.ts"), "import { expect } from 'vitest';\n") + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + ) + .unwrap(); + + assert_eq!(result.preserved_bare_vitest_files, [temp.path().join("nuxt.spec.ts")]); + let nuxt = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(nuxt.contains("from 'vitest'")); + assert!(nuxt.contains("require('vitest')")); + assert!(nuxt.contains("import('vitest')")); + assert!(nuxt.contains("from 'vite-plus'")); + assert!(nuxt.contains("from 'vite-plus/test/node'")); + assert!(nuxt.contains("from 'vite-plus/test/browser/context'")); + + let ordinary = fs::read_to_string(temp.path().join("ordinary.spec.ts")).unwrap(); + assert!(ordinary.contains("from 'vite-plus/test'")); + } + + #[test] + fn test_nuxt_preservation_requires_declared_test_utils_dependency() { + use std::fs; + + let temp = tempdir().unwrap(); + fs::write(temp.path().join("package.json"), r#"{"devDependencies":{"vitest":"4"}}"#) + .unwrap(); + fs::write( + temp.path().join("nuxt.spec.ts"), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ) + .unwrap(); + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + ) + .unwrap(); + + assert!(result.preserved_bare_vitest_files.is_empty()); + let content = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); + assert!(content.contains("from 'vite-plus/test'")); + } + #[test] fn test_rewrite_imports_in_directory_empty() { let temp = tempdir().unwrap(); diff --git a/crates/vite_migration/src/lib.rs b/crates/vite_migration/src/lib.rs index 78ab12872f..855f23cd9b 100644 --- a/crates/vite_migration/src/lib.rs +++ b/crates/vite_migration/src/lib.rs @@ -16,7 +16,10 @@ mod script_rewrite; mod vite_config; pub use file_walker::{WalkResult, find_ts_files}; -pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory}; +pub use import_rewriter::{ + BatchRewriteResult, RewriteImportsOptions, rewrite_imports_in_directory, + rewrite_imports_in_directory_with_options, +}; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use vite_config::{ MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config, diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 50de42f8fd..30bc455d31 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3288,6 +3288,8 @@ export interface BatchRewriteError { export interface BatchRewriteResult { /** Files that were modified */ modifiedFiles: Array; + /** Nuxt test-utils files where exact bare `vitest` imports were preserved */ + preservedBareVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3520,6 +3522,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files + * * `preserve_bare_vitest_in_nuxt_files` - Preserve exact bare `vitest` + * specifiers in files that directly reference a declared `@nuxt/test-utils` * * # Returns * @@ -3537,7 +3541,10 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * } * ``` */ -export declare function rewriteImportsInDirectory(root: string): BatchRewriteResult; +export declare function rewriteImportsInDirectory( + root: string, + preserveBareVitestInNuxtFiles?: boolean | undefined | null, +): BatchRewriteResult; /** * Rewrite Prettier scripts: rename `prettier` → `vp fmt` and strip Prettier-only flags. diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 059f8607ee..a702fca944 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -197,6 +197,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_files: Vec, + /// Nuxt test-utils files where exact bare `vitest` imports were preserved + pub preserved_bare_vitest_files: Vec, /// Files that had errors pub errors: Vec, } @@ -266,6 +268,8 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result Result Result { - let result = vite_migration::rewrite_imports_in_directory(Path::new(&root)) - .map_err(anyhow::Error::from)?; +pub fn rewrite_imports_in_directory( + root: String, + preserve_bare_vitest_in_nuxt_files: Option, +) -> Result { + let result = vite_migration::rewrite_imports_in_directory_with_options( + Path::new(&root), + vite_migration::RewriteImportsOptions { + preserve_bare_vitest_in_nuxt_files: preserve_bare_vitest_in_nuxt_files.unwrap_or(false), + }, + ) + .map_err(anyhow::Error::from)?; Ok(BatchRewriteResult { modified_files: result @@ -293,6 +305,11 @@ pub fn rewrite_imports_in_directory(root: String) -> Result .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), + preserved_bare_vitest_files: result + .preserved_bare_vitest_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), errors: result .errors .iter() diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json new file mode 100644 index 0000000000..66a6731860 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-upgrade-nuxt-test-utils-monorepo", + "private": true, + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.2", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json new file mode 100644 index 0000000000..508cad9200 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json new file mode 100644 index 0000000000..57a77b0b8e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/package.json @@ -0,0 +1,7 @@ +{ + "name": "unit-tests", + "private": true, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts new file mode 100644 index 0000000000..a5a3f5c5c2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/unit/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +void expect; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..912c35ad21 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - packages/* + +catalog: + vite-plus: latest + vitest: ^4.0.2 + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt new file mode 100644 index 0000000000..f6823a49b2 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -0,0 +1,61 @@ +> vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace +◇ Migrated . to Vite+ +• Node pnpm +• 2 files had imports rewritten +• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat packages/nuxt/package.json # affected workspace keeps direct Vitest +{ + "name": "nuxt-tests", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "file:../../.fixture/nuxt-test-utils", + "vitest": "catalog:" + } +} + +> cat packages/unit/package.json # unrelated workspace drops direct Vitest +{ + "name": "unit-tests", + "private": true, + "devDependencies": {} +} + +> cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it +packages: + - packages/* + +catalog: + vite-plus: latest + vitest: + vite: npm:@voidzero-dev/vite-plus-core@latest + +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vite-plus/test/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates +import { expect } from 'vite-plus/test'; + +void expect; + +> vp migrate --no-interactive # workspace result is idempotent +This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json new file mode 100644 index 0000000000..f87e8c2a72 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -0,0 +1,17 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace", + "cat packages/nuxt/package.json # affected workspace keeps direct Vitest", + "cat packages/unit/package.json # unrelated workspace drops direct Vitest", + "cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it", + "cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates", + "cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates", + "vp migrate --no-interactive # workspace result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json new file mode 100644 index 0000000000..578baa7ab6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/.fixture/nuxt-test-utils/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nuxt/test-utils", + "version": "4.0.3", + "peerDependencies": { + "vitest": "^4.0.2" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts new file mode 100644 index 0000000000..a763129373 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/nuxt.spec.ts @@ -0,0 +1,8 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from '@vitest/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vitest/config'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json new file mode 100644 index 0000000000..a1741d9a04 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "latest", + "vitest": "^4.0.2" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt new file mode 100644 index 0000000000..681c907914 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -0,0 +1,46 @@ +> vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces +◇ Migrated . to Vite+ +• Node npm +• 2 files had imports rewritten +• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• Package manager settings configured + +> cat package.json # direct Vitest and its shared pin remain for the preserved import +{ + "name": "migration-upgrade-nuxt-test-utils", + "devDependencies": { + "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", + "vite-plus": "latest", + "vitest": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + } +} + +> cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { page } from 'vite-plus/test/browser/context'; +import { vi } from 'vitest'; +import { defineConfig } from 'vite-plus'; + +mockNuxtImport('useExample', () => vi.fn()); +void page; +void defineConfig; + +> cat unit.spec.ts # unrelated bare Vitest imports still migrate +import { expect } from 'vite-plus/test'; + +expect(true).toBe(true); + +> vp migrate --no-interactive # the compatibility result is idempotent +This project is already using Vite+! Happy coding! + diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json new file mode 100644 index 0000000000..0f54655d5c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json @@ -0,0 +1,15 @@ +{ + "commands": [ + "vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces", + "cat package.json # direct Vitest and its shared pin remain for the preserved import", + "cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate", + "cat unit.spec.ts # unrelated bare Vitest imports still migrate", + "vp migrate --no-interactive # the compatibility result is idempotent" + ], + "ignoredPlatforms": [ + { + "os": "linux", + "libc": "musl" + } + ] +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts new file mode 100644 index 0000000000..593056d5d9 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +expect(true).toBe(true); diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json new file mode 100644 index 0000000000..66604e79b7 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/package.json @@ -0,0 +1,8 @@ +{ + "name": "lint-vite-plus-imports-nuxt", + "version": "0.0.0", + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt new file mode 100644 index 0000000000..b8965ab384 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -0,0 +1,41 @@ +[1]> vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. + ╭─[src/nuxt.spec.ts:3:29] + 2 │ import { expect, vi } from 'vitest'; + 3 │ import { startVitest } from 'vitest/node'; + · ───────────── + 4 │ + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test' instead of 'vitest' in Vite+ projects. + ╭─[src/unit.spec.ts:1:24] + 1 │ import { expect } from 'vitest'; + · ──────── + 2 │ + ╰──── + +Found 0 warnings and 2 errors. +Finished in ms on 2 files with rules using threads. + +> vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. + +> cat src/nuxt.spec.ts +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vite-plus/test/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; + +> cat src/unit.spec.ts +import { expect } from 'vite-plus/test'; + +void expect; + +> vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +Found 0 warnings and 0 errors. +Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts new file mode 100644 index 0000000000..aad9acb752 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/nuxt.spec.ts @@ -0,0 +1,7 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import { expect, vi } from 'vitest'; +import { startVitest } from 'vitest/node'; + +mockNuxtImport('useExample', () => vi.fn()); +void expect; +void startVitest; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts new file mode 100644 index 0000000000..a5a3f5c5c2 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -0,0 +1,3 @@ +import { expect } from 'vitest'; + +void expect; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json new file mode 100644 index 0000000000..6d28422626 --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -0,0 +1,10 @@ +{ + "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], + "commands": [ + "vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", + "vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "cat src/nuxt.spec.ts", + "cat src/unit.spec.ts", + "vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + ] +} diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts new file mode 100644 index 0000000000..ccf62c766b --- /dev/null +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { + jsPlugins: [{ name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }], + rules: { + 'vite-plus/prefer-vite-plus-imports': 'error', + }, + }, +}); diff --git a/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json new file mode 100644 index 0000000000..4749977f56 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/nuxt-test-utils/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "@nuxt/test-utils": "^4.0.3" + } +} diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 0e9f4c1b6b..94c7cf148d 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { RuleTester } from 'oxlint/plugins-dev'; import { describe, expect, it } from 'vitest'; @@ -10,6 +12,11 @@ import { } from '../oxlint-plugin-config.js'; import { preferVitePlusImportsRule, rewriteVitePlusImportSpecifier } from '../oxlint-plugin.js'; +const nuxtTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/component.spec.ts', +); + describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { expect( @@ -147,6 +154,10 @@ new RuleTester({ code: `declare module '@vitest/browser-playwright/context' {}`, filename: 'types.ts', }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + filename: nuxtTestFilename, + }, ], invalid: [ { @@ -211,5 +222,17 @@ new RuleTester({ errors: 2, output: `export * from 'vite-plus/test';\nimport { defineConfig } from 'vite-plus';`, }, + { + code: `import { vi } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 1, + filename: nuxtTestFilename, + output: `import { vi } from 'vitest';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, + { + code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 1, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + }, ], }); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 2be6c2b2c6..87f8d0f92c 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -23,6 +23,7 @@ const { rewriteMonorepo, rewriteMonorepoProject, detectPendingCoreMigration, + detectNuxtTestUtilsVitestImportFiles, detectVitePlusBootstrapPending, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, @@ -2848,6 +2849,114 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); }); + it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( + 'detects Nuxt-compatible bare Vitest imports from %s without installed metadata', + (dependencyGroup) => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + [dependencyGroup]: { '@nuxt/test-utils': '^4.0.3' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + + expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ + path.join(tmpDir, 'nuxt.spec.ts'), + ]); + }, + ); + + it('preserves Nuxt bare Vitest imports, keeps direct Vitest, and rewrites subpaths', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + devDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + '@nuxt/test-utils': '^4.0.3', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + [ + "import { vi } from 'vitest';", + "import { defineConfig } from 'vitest/config';", + "import { mockNuxtImport } from '@nuxt/test-utils/runtime';", + '', + ].join('\n'), + ); + fs.writeFileSync(path.join(tmpDir, 'unit.spec.ts'), "import { expect } from 'vitest';\n"); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.npm), + true, + true, + report, + ); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + const nuxtTest = fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8'); + expect(nuxtTest).toContain("from 'vitest'"); + expect(nuxtTest).toContain("from 'vite-plus'"); + expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + expect(report.preservedNuxtVitestImportFileCount).toBe(1); + }); + + it('rewrites Nuxt bare Vitest imports when compatibility preservation is declined', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'nuxt-project', + devDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + '@nuxt/test-utils': '^4.0.3', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'nuxt.spec.ts'), + "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", + ); + const report = createMigrationReport(); + + rewriteStandaloneProject( + tmpDir, + makeWorkspaceInfo(tmpDir, PackageManager.npm), + true, + true, + report, + { preserveNuxtVitestImports: false }, + ); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBeUndefined(); + expect(pkg.overrides.vitest).toBeUndefined(); + expect(fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8')).toContain( + "from 'vite-plus/test'", + ); + expect(report.preservedNuxtVitestImportFileCount).toBe(0); + }); + it('does not add a coverage provider the project never declared', () => { // A project that uses vitest WITHOUT a coverage provider must not have one // injected by the migration — the user installs it only if they need it. diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index a19af4f2c8..14f757af08 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -55,6 +55,7 @@ import { detectFramework, detectIncompatibleEslintIntegration, detectNodeVersionManagerFile, + detectNuxtTestUtilsVitestImportFiles, detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, @@ -347,6 +348,51 @@ interface MigrationPlan extends MigrationSetupPlan { migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; frameworkShimFrameworks?: Framework[]; + preserveNuxtVitestImports: boolean; + nuxtVitestUnsafeRewrite: boolean; +} + +const NUXT_VITEST_REWRITE_WARNING = + '@nuxt/test-utils compatibility: bare `vitest` imports were rewritten. Files using ' + + '`mockNuxtImport` or `mockComponent` may need manual fixes for duplicate `vi` imports.'; + +async function collectNuxtVitestImportDecision( + rootDir: string, + options: MigrationOptions, + packages?: WorkspacePackage[], +): Promise<{ preserveNuxtVitestImports: boolean; nuxtVitestUnsafeRewrite: boolean }> { + const affectedFiles = detectNuxtTestUtilsVitestImportFiles(rootDir, packages); + if (affectedFiles.length === 0) { + return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; + } + if (!options.interactive) { + return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; + } + + prompts.log.step('@nuxt/test-utils detected', { withGuide: true }); + const action = await prompts.select({ + message: 'How should bare `vitest` imports in Nuxt test files be handled?', + options: [ + { + label: 'Keep `vitest` imports (recommended)', + value: 'preserve' as const, + hint: 'Compatible with mockNuxtImport and mockComponent', + }, + { + label: 'Rewrite to `vite-plus/test`', + value: 'rewrite' as const, + hint: 'May require manual fixes for duplicate vi imports', + }, + ], + initialValue: 'preserve' as const, + }); + if (prompts.isCancel(action)) { + cancelAndExit(); + } + return { + preserveNuxtVitestImports: action === 'preserve', + nuxtVitestUnsafeRewrite: action === 'rewrite', + }; } function getFrameworkShimCandidates(rootDir: string, packages?: WorkspacePackage[]): Framework[] { @@ -636,6 +682,8 @@ async function collectMigrationPlan( const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); + const nuxtVitestPlan = await collectNuxtVitestImportDecision(rootDir, options, packages); + // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -675,6 +723,7 @@ async function collectMigrationPlan( migrateNodeVersionFile, nodeVersionDetection, frameworkShimFrameworks, + ...nuxtVitestPlan, }; return plan; @@ -789,6 +838,13 @@ function showMigrationSummary(options: { } log(`${styleText('gray', '•')} ${parts.join(', ')}`); } + if (report.preservedNuxtVitestImportFileCount > 0) { + log( + `${styleText('gray', '•')} Kept bare \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ + report.preservedNuxtVitestImportFileCount === 1 ? 'file' : 'files' + } for @nuxt/test-utils compatibility`, + ); + } if (report.eslintMigrated) { log(`${styleText('gray', '•')} ESLint rules migrated to Oxlint`); } @@ -907,6 +963,9 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); + if (plan.nuxtVitestUnsafeRewrite) { + addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); + } const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1027,7 +1086,9 @@ async function executeMigrationPlan( // 7. Rewrite configs updateMigrationProgress('Rewriting configs'); if (workspaceInfo.isMonorepo) { - rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report); + rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report, { + preserveNuxtVitestImports: plan.preserveNuxtVitestImports, + }); } else { rewriteStandaloneProject( workspaceInfo.rootDir, @@ -1035,6 +1096,7 @@ async function executeMigrationPlan( skipStagedMigration, true, report, + { preserveNuxtVitestImports: plan.preserveNuxtVitestImports }, ); } @@ -1173,6 +1235,18 @@ async function main() { } }; + const nuxtVitestPlan = await collectNuxtVitestImportDecision( + workspaceInfoOptional.rootDir, + options, + workspaceInfoOptional.packages, + ); + const nuxtVitestImportOptions = { + preserveNuxtVitestImports: nuxtVitestPlan.preserveNuxtVitestImports, + }; + if (nuxtVitestPlan.nuxtVitestUnsafeRewrite) { + addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); + } + const pendingCoreMigration = detectPendingCoreMigration(workspaceInfoOptional); const legacyGitHooksMigrationCandidate = detectLegacyGitHooksMigrationCandidate( workspaceInfoOptional.rootDir, @@ -1181,6 +1255,7 @@ async function main() { workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packages, + nuxtVitestImportOptions, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1217,6 +1292,7 @@ async function main() { true, report, pendingCoreMigration, + nuxtVitestImportOptions, ); if ( coreMigrationResult.scripts || @@ -1271,6 +1347,7 @@ async function main() { downloadPackageManager: downloadResult, }, report, + nuxtVitestImportOptions, ); didMigrate = bootstrapResult.changed || didMigrate; needsInstall = bootstrapResult.changed || needsInstall; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 3daff5a557..dade292775 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -627,6 +627,7 @@ function projectUsesVitestDirectly( peerDependencies?: Record; }, requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg), + preserveNuxtVitestImports = true, ): boolean { return ( projectListsVitestEcosystemDep(pkg) || @@ -639,6 +640,7 @@ function projectUsesVitestDirectly( // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || + (preserveNuxtVitestImports && sourceTreeReferencesNuxtVitestImport(projectPath, pkg)) || usesVitestBrowserMode(projectPath) ); } @@ -1688,12 +1690,17 @@ export function addFrameworkShim( * Rewrite standalone project to add vite-plus dependencies * @param projectPath - The path to the project */ +export interface VitestImportMigrationOptions { + preserveNuxtVitestImports?: boolean; +} + export function rewriteStandaloneProject( projectPath: string, workspaceInfo: WorkspaceInfo, skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1735,7 +1742,12 @@ export function rewriteStandaloneProject( shouldAllowBrowserProviderBuilds = hasOwnWebdriverioDependency(pkg) || usesWebdriverioProvider(projectPath); const requiredVitestPeer = projectListsRequiredVitestPeer(projectPath, pkg); - usesVitest = projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer); + usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ); const managed = managedOverridePackages(usesVitest); // Strip stale `vite-plus-test` wrapper aliases before injecting new overrides // so the deleted wrapper doesn't survive migration in any sink. @@ -1919,7 +1931,12 @@ export function rewriteStandaloneProject( injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(projectPath, silent, report); + rewriteAllImports( + projectPath, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -1934,6 +1951,7 @@ export function rewriteMonorepo( skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): void { const catalogDependencyResolver = createCatalogDependencyResolver( workspaceInfo.rootDir, @@ -1949,6 +1967,7 @@ export function rewriteMonorepo( const workspaceUsesVitest = workspaceUsesVitestDirectly( workspaceInfo.rootDir, workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { @@ -1972,6 +1991,7 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, + importOptions, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) @@ -1998,6 +2018,7 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceContext, true, + importOptions, ); } @@ -2011,7 +2032,12 @@ export function rewriteMonorepo( injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(workspaceInfo.rootDir, silent, report); + rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); @@ -2038,6 +2064,7 @@ export function rewriteMonorepoProject( catalogDependencyResolver?: CatalogDependencyResolver, workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, deferLazyPluginWrapping = false, + importOptions?: VitestImportMigrationOptions, ): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); @@ -2084,7 +2111,12 @@ export function rewriteMonorepoProject( catalogDependencyResolver, usesVitestBrowserMode(projectPath), collectProviderSourceModes(projectPath), - projectUsesVitestDirectly(projectPath, pkg, requiredVitestPeer), + projectUsesVitestDirectly( + projectPath, + pkg, + requiredVitestPeer, + importOptions?.preserveNuxtVitestImports !== false, + ), sourceTreeReferencesRetainedVitestModule(projectPath), requiredVitestPeer, ); @@ -2406,9 +2438,10 @@ function workspaceUsesWebdriverio( function workspaceUsesVitestDirectly( rootDir: string, packages: WorkspacePackage[] | undefined, + preserveNuxtVitestImports = true, ): boolean { const rootPkg = readPackageJsonIfExists(path.join(rootDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(rootDir, rootPkg)) { + if (projectUsesVitestDirectly(rootDir, rootPkg, undefined, preserveNuxtVitestImports)) { return true; } if (!packages) { @@ -2417,7 +2450,7 @@ function workspaceUsesVitestDirectly( for (const pkg of packages) { const packageDir = path.join(rootDir, pkg.path); const subPkg = readPackageJsonIfExists(path.join(packageDir, 'package.json')) ?? {}; - if (projectUsesVitestDirectly(packageDir, subPkg)) { + if (projectUsesVitestDirectly(packageDir, subPkg, undefined, preserveNuxtVitestImports)) { return true; } } @@ -3239,6 +3272,7 @@ function rewriteRootWorkspacePackageJson( // shared by every package, so `vitest` stays managed here iff ANY package uses // vitest directly. workspaceUsesVitest = true, + importOptions?: VitestImportMigrationOptions, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -3385,6 +3419,7 @@ function rewriteRootWorkspacePackageJson( catalogDependencyResolver, packages ? { rootDir: projectPath, packages } : undefined, true, + importOptions, ); } @@ -3487,6 +3522,7 @@ export function finalizeCoreMigrationForExistingVitePlus( silent = false, report?: MigrationReport, pending = detectPendingCoreMigration(workspaceInfo), + importOptions?: VitestImportMigrationOptions, ): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { @@ -3508,7 +3544,12 @@ export function finalizeCoreMigrationForExistingVitePlus( } } - result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report); + result.imports = rewriteAllImports( + workspaceInfo.rootDir, + silent, + report, + importOptions?.preserveNuxtVitestImports !== false, + ); return result; } @@ -3816,9 +3857,15 @@ function reconcileVitePlusBootstrapPackage( supportCatalog: boolean, ensureVitePlus: boolean, catalogDependencyResolver?: CatalogDependencyResolver, + importOptions?: VitestImportMigrationOptions, ): boolean { const before = JSON.stringify(pkg); - const usesVitest = projectUsesVitestDirectly(projectPath, pkg); + const usesVitest = projectUsesVitestDirectly( + projectPath, + pkg, + undefined, + importOptions?.preserveNuxtVitestImports !== false, + ); ensureVitePlusDependencySpecs(pkg, vitePlusVersion, ensureVitePlus); const installGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; @@ -3942,6 +3989,7 @@ export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, packages?: WorkspacePackage[], + importOptions?: VitestImportMigrationOptions, ): boolean { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -3986,6 +4034,7 @@ export function detectVitePlusBootstrapPending( supportCatalog, index === 0, catalogDependencyResolver, + importOptions, ) ) { return true; @@ -3994,7 +4043,11 @@ export function detectVitePlusBootstrapPending( // Shared override/catalog sinks must keep vitest managed when any package in // the workspace needs it. The direct dependency itself is localized above. - const usesVitest = workspaceUsesVitestDirectly(projectPath, packages); + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + packages, + importOptions?.preserveNuxtVitestImports !== false, + ); if (packageManager === PackageManager.yarn) { return ( @@ -4147,6 +4200,7 @@ function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: bo export function ensureVitePlusBootstrap( workspaceInfo: WorkspaceInfo, report?: MigrationReport, + importOptions?: VitestImportMigrationOptions, ): VitePlusBootstrapResult { const projectPath = workspaceInfo.rootDir; const packageJsonPath = path.join(projectPath, 'package.json'); @@ -4164,7 +4218,11 @@ export function ensureVitePlusBootstrap( // Shared override/catalog sinks are workspace-wide, so keep vitest managed // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. - const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages); + const usesVitest = workspaceUsesVitestDirectly( + projectPath, + workspaceInfo.packages, + importOptions?.preserveNuxtVitestImports !== false, + ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = @@ -4196,6 +4254,7 @@ export function ensureVitePlusBootstrap( supportCatalog, true, catalogDependencyResolver, + importOptions, ); if (workspaceInfo.packageManager === PackageManager.yarn) { @@ -4262,6 +4321,7 @@ export function ensureVitePlusBootstrap( supportCatalog, false, catalogDependencyResolver, + importOptions, ); return childChanged ? pkg : undefined; }); @@ -4488,10 +4548,12 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeMatches( +function sourceTreeMatchingFiles( projectPath: string, matchesContent: (content: string) => boolean, -): boolean { + stopAfterFirst = false, +): string[] { + const matchingFiles: string[] = []; const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; try { @@ -4516,7 +4578,10 @@ function sourceTreeMatches( } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { try { if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { - return true; + matchingFiles.push(entryPath); + if (stopAfterFirst) { + return true; + } } } catch { // Unreadable file — ignore and keep scanning. @@ -4526,13 +4591,70 @@ function sourceTreeMatches( return false; }; - return scanDir(projectPath, true); + scanDir(projectPath, true); + return matchingFiles; +} + +function sourceTreeMatches( + projectPath: string, + matchesContent: (content: string) => boolean, +): boolean { + return sourceTreeMatchingFiles(projectPath, matchesContent, true).length > 0; } function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): boolean { return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } +const BARE_VITEST_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest['"]/m; +const NUXT_TEST_UTILS_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; + +function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { + return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); +} + +function sourceReferencesNuxtTestUtilsWithBareVitest(content: string): boolean { + return ( + BARE_VITEST_MODULE_REFERENCE.test(content) && NUXT_TEST_UTILS_MODULE_REFERENCE.test(content) + ); +} + +function sourceTreeReferencesNuxtVitestImport(projectPath: string, pkg: DependencyBag): boolean { + return ( + hasNuxtTestUtilsDependency(pkg) && + sourceTreeMatches(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest) + ); +} + +/** + * Find files eligible for the @nuxt/test-utils bare-vitest compatibility choice. + * Each package is scanned independently so a root dependency does not leak into + * unrelated workspace manifests. + */ +export function detectNuxtTestUtilsVitestImportFiles( + rootDir: string, + packages?: WorkspacePackage[], +): string[] { + const files: string[] = []; + for (const projectPath of [ + rootDir, + ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path)), + ]) { + const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')); + if (!pkg || !hasNuxtTestUtilsDependency(pkg)) { + continue; + } + files.push( + ...sourceTreeMatchingFiles(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest), + ); + } + return [...new Set(files)]; +} + // Normal imports and triple-slash type directives from `vitest` are rewritten // to `vite-plus/test` later in the same migration and therefore do not justify // a lasting direct dependency. Module augmentations, `vitest/package.json`, and @@ -5667,13 +5789,20 @@ function wrapLazyPluginsInViteConfig( * This rewrites vite/vitest imports to @voidzero-dev/vite-plus * @param projectPath - The root directory to search for files */ -function rewriteAllImports(projectPath: string, silent = false, report?: MigrationReport): boolean { - const result = rewriteImportsInDirectory(projectPath); +function rewriteAllImports( + projectPath: string, + silent = false, + report?: MigrationReport, + preserveNuxtVitestImports = true, +): boolean { + const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); const modified = result.modifiedFiles.length; + const preserved = result.preservedBareVitestFiles.length; const errors = result.errors.length; if (report) { report.rewrittenImportFileCount += modified; + report.preservedNuxtVitestImportFileCount += preserved; report.rewrittenImportErrors.push( ...result.errors.map((error) => ({ path: displayRelative(error.path), diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 63391ae03a..d2bfe2bfec 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -7,6 +7,7 @@ export interface MigrationReport { tsdownImportCount: number; wrappedPluginConfigCount: number; rewrittenImportFileCount: number; + preservedNuxtVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; prettierMigrated: boolean; @@ -28,6 +29,7 @@ export function createMigrationReport(): MigrationReport { tsdownImportCount: 0, wrappedPluginConfigCount: 0, rewrittenImportFileCount: 0, + preservedNuxtVitestImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, prettierMigrated: false, diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 25ca9c2983..41a163fd0c 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { definePlugin, defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; @@ -98,13 +101,57 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } +const NUXT_TEST_UTILS_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; +const nuxtTestUtilsPackageCache = new Map(); + +function nearestPackageUsesNuxtTestUtils(filename: string): boolean { + if (!path.isAbsolute(filename)) { + return false; + } + let directory = path.dirname(filename); + while (true) { + const packageJsonPath = path.join(directory, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const cached = nuxtTestUtilsPackageCache.get(packageJsonPath); + if (cached !== undefined) { + return cached; + } + let usesNuxtTestUtils = false; + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + }; + usesNuxtTestUtils = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ); + } catch { + // Invalid or unreadable package metadata cannot opt into the exception. + } + nuxtTestUtilsPackageCache.set(packageJsonPath, usesNuxtTestUtils); + return usesNuxtTestUtils; + } + const parent = path.dirname(directory); + if (parent === directory) { + return false; + } + directory = parent; + } +} + function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, + preserveBareVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } + if (preserveBareVitest && literal.value === 'vitest') { + return; + } const replacement = rewriteVitePlusImportSpecifier(literal.value); if (!replacement) { @@ -138,24 +185,30 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { + let preserveBareVitest = false; return { + Program() { + preserveBareVitest = + nearestPackageUsesNuxtTestUtils(context.filename) && + NUXT_TEST_UTILS_MODULE_REFERENCE.test(context.sourceCode.text); + }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source); + maybeReportLiteral(context, node.source, preserveBareVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression); + maybeReportLiteral(context, node.expression, preserveBareVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -169,7 +222,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id); + maybeReportLiteral(context, id, preserveBareVitest); }, }; }, diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 64d74a4e8e..5a2abfdf91 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,21 +23,53 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, or retains a direct upstream `vitest` package reference. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +| Area | Rule | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or preserves bare `vitest` imports for `@nuxt/test-utils` compatibility. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. +## `@nuxt/test-utils` compatibility + +`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. The migration therefore treats bare `vitest` imports in Nuxt test-utils files as a compatibility boundary rather than applying the ordinary rewrite unconditionally. + +Detection and scope: + +1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`. +2. Within an eligible package, a Nuxt test-utils file is one that directly imports, exports from, requires, or dynamically imports `@nuxt/test-utils` or one of its subpaths. +3. The compatibility choice applies only to the exact bare specifier `vitest` in those files. `vitest/config`, every other `vitest/*` subpath, `@vitest/browser*`, and files unrelated to `@nuxt/test-utils` continue through the normal rewrites. +4. Preserving at least one bare import is retained direct Vitest usage, so that package keeps its package-local `vitest` and the workspace keeps the matching shared pin/catalog entry. +5. `prefer-vite-plus-imports` uses the same file-level exception. Lint and autofix must not undo the migration result. + +If eligible files with bare `vitest` imports exist, interactive migration asks: + +```text +◆ @nuxt/test-utils detected + +◆ How should bare `vitest` imports in Nuxt test files be handled? +│ ● Keep `vitest` imports (recommended) +│ Compatible with `mockNuxtImport` and `mockComponent` +│ ○ Rewrite to `vite-plus/test` +│ May require manual fixes for duplicate `vi` imports +``` + +`--no-interactive` selects the recommended preservation behavior. A preserving migration reports: + +```text +• Kept bare `vitest` imports in 7 files for @nuxt/test-utils compatibility +``` + +The count is the number of files, not import declarations. If the user explicitly selects rewriting, migration emits a compatibility warning identifying the possible duplicate-`vi` follow-up. + **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. ## Vitest ecosystem packages @@ -61,10 +93,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup. | +| Area | Change | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a file-scoped Nuxt compatibility mode that preserves only exact bare `vitest` specifiers while continuing all Vitest subpath and browser-provider rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; Nuxt dependency/file detection, prompt choice, and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt file-level bare-`vitest` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -93,6 +127,9 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | | Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | +| Nuxt-compatible bare imports are preserved while Vitest subpaths still rewrite | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | + +The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: the Nuxt file's bare import remains exempt while its Vitest subpath and an unrelated file's bare import are both fixed. ## Follow-ups (not in this change) From 9618733403c2faf3f9dd677861afd93666610e1c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 15:09:42 +0800 Subject: [PATCH 17/32] test(ecosystem-ci): update npmx.dev fixture --- ecosystem-ci/repo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 052fb48ee0..dad175cc9b 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -94,7 +94,7 @@ "npmx.dev": { "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", - "hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d", + "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", "forceFreshMigration": true }, "vite-plus-jest-dom-repro": { From bc3d1a9ac05e97bd8ec2f4e8141fdbb0ed6a21f7 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 15:38:11 +0800 Subject: [PATCH 18/32] test(cli): stabilize Nuxt lint snapshot --- .../cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt | 6 +++--- .../cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt index b8965ab384..271607b5e0 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -1,4 +1,4 @@ -[1]> vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail +[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. ╭─[src/nuxt.spec.ts:3:29] @@ -18,7 +18,7 @@ Found 0 warnings and 2 errors. Finished in ms on 2 files with rules using threads. -> vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. @@ -36,6 +36,6 @@ import { expect } from 'vite-plus/test'; void expect; -> vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json index 6d28422626..e7e97ca151 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -1,10 +1,10 @@ { "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], "commands": [ - "vp lint src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", - "vp lint --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", + "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", "cat src/nuxt.spec.ts", "cat src/unit.spec.ts", - "vp lint src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" ] } From 3da96af692ca9c73f911e6df94a5a80c02cab765 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 16:01:32 +0800 Subject: [PATCH 19/32] fix(migrate): preserve Vitest across Nuxt packages --- .github/workflows/e2e-test.yml | 1 + crates/vite_migration/src/import_rewriter.rs | 124 ++++++++++-------- packages/cli/binding/index.d.cts | 10 +- packages/cli/binding/src/migration.rs | 16 +-- .../packages/nuxt/unit.spec.ts | 5 + .../snap.txt | 19 ++- .../steps.json | 7 +- .../snap.txt | 18 +-- .../steps.json | 10 +- .../lint-vite-plus-imports-nuxt/snap.txt | 32 ++--- .../src/unit.spec.ts | 2 + .../lint-vite-plus-imports-nuxt/steps.json | 6 +- .../cli/src/__tests__/oxlint-plugin.spec.ts | 20 ++- .../src/migration/__tests__/migrator.spec.ts | 52 +------- packages/cli/src/migration/bin.ts | 74 +---------- packages/cli/src/migration/migrator.ts | 33 ++--- packages/cli/src/oxlint-plugin.ts | 30 ++--- rfcs/migrate-existing-projects.md | 69 ++++------ 18 files changed, 215 insertions(+), 313 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e63f1a51f1..3af6cba3fc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -318,6 +318,7 @@ jobs: # on vi.fn() calls — migration sets rule as "error" in config, --allow can't override vp run lint || true vp run test:types + vp test --project nuxt vp test --project unit - name: vite-plus-jest-dom-repro node-version: 24 diff --git a/crates/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index 350f2e2538..7b94ae88f7 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -1586,20 +1586,26 @@ fn is_bare_vitest_rule(rule: &RuleConfig) -> bool { BARE_VITEST_RULE_IDS.contains(&rule.id.as_str()) } -static PARSED_BARE_VITEST_RULES: LazyLock>> = LazyLock::new(|| { +fn is_unscoped_vitest_rule(rule: &RuleConfig) -> bool { + is_bare_vitest_rule(rule) + || rule.id.starts_with("rewrite-vitest-config-") + || rule.id.starts_with("rewrite-vitest-subpath-") +} + +static PARSED_UNSCOPED_VITEST_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITEST_RULES) .expect("failed to parse vitest rewrite rules") .into_iter() - .filter(is_bare_vitest_rule) + .filter(is_unscoped_vitest_rule) .collect() }); -static PARSED_VITEST_RULES_WITHOUT_BARE: LazyLock>> = +static PARSED_VITEST_RULES_WITHOUT_UNSCOPED: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITEST_RULES) .expect("failed to parse vitest rewrite rules") .into_iter() - .filter(|rule| !is_bare_vitest_rule(rule)) + .filter(|rule| !is_unscoped_vitest_rule(rule)) .collect() }); @@ -1717,7 +1723,11 @@ fn apply_regex_replace(content: &mut String, re: &Regex, replacement: &str) -> b /// to match TypeScript semantics and avoid false positives inside string/template literals. /// Allocates only for preamble lines, leaving the file body untouched. /// Returns whether any changes were made. -fn rewrite_reference_types(content: &mut String, skip_packages: &SkipPackages) -> bool { +fn rewrite_reference_types( + content: &mut String, + skip_packages: &SkipPackages, + preserve_unscoped_vitest: bool, +) -> bool { // Fast path: skip files with no triple-slash reference directives. // Check for "///" which covers all spacing variants (///, /// Files that had no changes pub unchanged_files: Vec, - /// Nuxt test-utils files where exact bare `vitest` imports were preserved. - pub preserved_bare_vitest_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved. + pub preserved_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, } @@ -2083,7 +2099,7 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result { if rewrite_result.updated { if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified, rewrite_result.preserved_bare_vitest) + (file_path, FileResult::Modified, rewrite_result.preserved_vitest) } } else { - (file_path, FileResult::Unchanged, rewrite_result.preserved_bare_vitest) + (file_path, FileResult::Unchanged, rewrite_result.preserved_vitest) } } Err(e) => (file_path, FileResult::Error(e.to_string()), false), @@ -2142,13 +2158,13 @@ pub fn rewrite_imports_in_directory_with_options( let mut batch_result = BatchRewriteResult { modified_files: Vec::new(), unchanged_files: Vec::new(), - preserved_bare_vitest_files: Vec::new(), + preserved_vitest_files: Vec::new(), errors: Vec::new(), }; - for (file_path, file_result, preserved_bare_vitest) in results { - if preserved_bare_vitest { - batch_result.preserved_bare_vitest_files.push(file_path.clone()); + for (file_path, file_result, preserved_vitest) in results { + if preserved_vitest { + batch_result.preserved_vitest_files.push(file_path.clone()); } match file_result { FileResult::Modified => batch_result.modified_files.push(file_path), @@ -2179,25 +2195,13 @@ pub fn rewrite_imports_in_directory_with_options( fn rewrite_import( file_path: &Path, skip_packages: &SkipPackages, - preserve_bare_vitest_in_nuxt_files: bool, + preserve_vitest_in_nuxt_package: bool, ) -> Result { // Read the file let content = std::fs::read_to_string(file_path)?; // Rewrite the imports - let preserve_bare_vitest = - preserve_bare_vitest_in_nuxt_files && source_directly_references_nuxt_test_utils(&content); - rewrite_import_content_with_options(&content, skip_packages, preserve_bare_vitest) -} - -fn source_directly_references_nuxt_test_utils(content: &str) -> bool { - static RE_NUXT_TEST_UTILS_REFERENCE: LazyLock = LazyLock::new(|| { - Regex::new( - r#"(?m)(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)["']@nuxt/test-utils(?:/[^"']+)?["']"#, - ) - .unwrap() - }); - RE_NUXT_TEST_UTILS_REFERENCE.is_match(content) + rewrite_import_content_with_options(&content, skip_packages, preserve_vitest_in_nuxt_package) } /// Fast pre-filter to skip expensive AST parsing for files with no relevant imports. @@ -2231,20 +2235,20 @@ fn rewrite_import_content( fn rewrite_import_content_with_options( content: &str, skip_packages: &SkipPackages, - preserve_bare_vitest: bool, + preserve_unscoped_vitest: bool, ) -> Result { // Fast path: skip AST parsing if the file doesn't contain any target strings if !content_may_need_rewriting(content, skip_packages) { return Ok(RewriteResult { content: content.to_string(), updated: false, - preserved_bare_vitest: false, + preserved_vitest: false, }); } let mut new_content = content.to_string(); let mut updated = false; - let mut preserved_bare_vitest = false; + let mut preserved_vitest = false; // Apply vite rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vite { @@ -2257,11 +2261,11 @@ fn rewrite_import_content_with_options( // Apply vitest rules if not skipped (using pre-parsed rules) if !skip_packages.skip_vitest { - let vitest_rules = if preserve_bare_vitest { - let bare_rewrite = - ast_grep::apply_loaded_rules(&new_content, &PARSED_BARE_VITEST_RULES); - preserved_bare_vitest = bare_rewrite != new_content; - &*PARSED_VITEST_RULES_WITHOUT_BARE + let vitest_rules = if preserve_unscoped_vitest { + let upstream_rewrite = + ast_grep::apply_loaded_rules(&new_content, &PARSED_UNSCOPED_VITEST_RULES); + preserved_vitest = upstream_rewrite != new_content; + &*PARSED_VITEST_RULES_WITHOUT_UNSCOPED } else { &*PARSED_VITEST_RULES }; @@ -2283,9 +2287,9 @@ fn rewrite_import_content_with_options( // Apply reference type rewriting (/// ) // These cannot be handled by ast-grep because they are parsed as comments. - updated |= rewrite_reference_types(&mut new_content, skip_packages); + updated |= rewrite_reference_types(&mut new_content, skip_packages, preserve_unscoped_vitest); - Ok(RewriteResult { content: new_content, updated, preserved_bare_vitest }) + Ok(RewriteResult { content: new_content, updated, preserved_vitest }) } #[cfg(test)] @@ -2893,7 +2897,7 @@ describe('test', () => {});"#, } #[test] - fn test_preserves_only_bare_vitest_in_nuxt_test_utils_files() { + fn test_preserves_unscoped_vitest_in_nuxt_test_utils_packages() { use std::fs; let temp = tempdir().unwrap(); @@ -2919,26 +2923,32 @@ import { page } from '@vitest/browser/context'; import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, ) .unwrap(); - fs::write(temp.path().join("ordinary.spec.ts"), "import { expect } from 'vitest';\n") - .unwrap(); + fs::write( + temp.path().join("ordinary.spec.ts"), + "/// \nimport { expect } from 'vitest';\n", + ) + .unwrap(); let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, ) .unwrap(); - assert_eq!(result.preserved_bare_vitest_files, [temp.path().join("nuxt.spec.ts")]); + assert_eq!(result.preserved_vitest_files.len(), 2); + assert!(result.preserved_vitest_files.contains(&temp.path().join("nuxt.spec.ts"))); + assert!(result.preserved_vitest_files.contains(&temp.path().join("ordinary.spec.ts"))); let nuxt = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); assert!(nuxt.contains("from 'vitest'")); assert!(nuxt.contains("require('vitest')")); assert!(nuxt.contains("import('vitest')")); - assert!(nuxt.contains("from 'vite-plus'")); - assert!(nuxt.contains("from 'vite-plus/test/node'")); + assert!(nuxt.contains("from 'vitest/config'")); + assert!(nuxt.contains("from 'vitest/node'")); assert!(nuxt.contains("from 'vite-plus/test/browser/context'")); let ordinary = fs::read_to_string(temp.path().join("ordinary.spec.ts")).unwrap(); - assert!(ordinary.contains("from 'vite-plus/test'")); + assert!(ordinary.contains("from 'vitest'")); + assert!(ordinary.contains("types=\"vitest/globals\"")); } #[test] @@ -2956,11 +2966,11 @@ import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_bare_vitest_in_nuxt_files: true }, + RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, ) .unwrap(); - assert!(result.preserved_bare_vitest_files.is_empty()); + assert!(result.preserved_vitest_files.is_empty()); let content = fs::read_to_string(temp.path().join("nuxt.spec.ts")).unwrap(); assert!(content.contains("from 'vite-plus/test'")); } diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 30bc455d31..5d1ad7d870 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3288,8 +3288,8 @@ export interface BatchRewriteError { export interface BatchRewriteResult { /** Files that were modified */ modifiedFiles: Array; - /** Nuxt test-utils files where exact bare `vitest` imports were preserved */ - preservedBareVitestFiles: Array; + /** Files in Nuxt test-utils packages where upstream `vitest` imports were preserved */ + preservedVitestFiles: Array; /** Files that had errors */ errors: Array; } @@ -3522,8 +3522,8 @@ export declare function rewriteEslint(scriptsJson: string): string | null; * # Arguments * * * `root` - The root directory to search for files - * * `preserve_bare_vitest_in_nuxt_files` - Preserve exact bare `vitest` - * specifiers in files that directly reference a declared `@nuxt/test-utils` + * * `preserve_vitest_in_nuxt_packages` - Preserve `vitest` and `vitest/*` + * specifiers throughout packages that declare `@nuxt/test-utils` * * # Returns * @@ -3543,7 +3543,7 @@ export declare function rewriteEslint(scriptsJson: string): string | null; */ export declare function rewriteImportsInDirectory( root: string, - preserveBareVitestInNuxtFiles?: boolean | undefined | null, + preserveVitestInNuxtPackages?: boolean | undefined | null, ): BatchRewriteResult; /** diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index a702fca944..9306f7b79d 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -197,8 +197,8 @@ pub struct BatchRewriteError { pub struct BatchRewriteResult { /// Files that were modified pub modified_files: Vec, - /// Nuxt test-utils files where exact bare `vitest` imports were preserved - pub preserved_bare_vitest_files: Vec, + /// Files in Nuxt test-utils packages where upstream `vitest` imports were preserved + pub preserved_vitest_files: Vec, /// Files that had errors pub errors: Vec, } @@ -268,8 +268,8 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result Result, + preserve_vitest_in_nuxt_packages: Option, ) -> Result { let result = vite_migration::rewrite_imports_in_directory_with_options( Path::new(&root), vite_migration::RewriteImportsOptions { - preserve_bare_vitest_in_nuxt_files: preserve_bare_vitest_in_nuxt_files.unwrap_or(false), + preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), }, ) .map_err(anyhow::Error::from)?; @@ -305,8 +305,8 @@ pub fn rewrite_imports_in_directory( .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), - preserved_bare_vitest_files: result - .preserved_bare_vitest_files + preserved_vitest_files: result + .preserved_vitest_files .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts new file mode 100644 index 0000000000..3ea9392334 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/packages/nuxt/unit.spec.ts @@ -0,0 +1,5 @@ +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt index f6823a49b2..e43f57906d 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -1,8 +1,8 @@ -> vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace +> vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace ◇ Migrated . to Vite+ • Node pnpm -• 2 files had imports rewritten -• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility • Package manager settings configured > cat packages/nuxt/package.json # affected workspace keeps direct Vitest @@ -42,16 +42,23 @@ peerDependencyRules: vite: '*' vitest: '*' -> cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates +> cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { expect, vi } from 'vitest'; -import { startVitest } from 'vite-plus/test/node'; +import { startVitest } from 'vitest/node'; mockNuxtImport('useExample', () => vi.fn()); void expect; void startVitest; -> cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates +> cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package +import { expect } from 'vitest'; +import { startVitest } from 'vitest/node'; + +void expect; +void startVitest; + +> cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest import { expect } from 'vite-plus/test'; void expect; diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json index f87e8c2a72..b454ea054b 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/steps.json @@ -1,11 +1,12 @@ { "commands": [ - "vp migrate --no-interactive # preserve Nuxt imports and localize direct Vitest to the affected workspace", + "vp migrate --no-interactive # preserve upstream Vitest package-wide and localize it to the affected workspace", "cat packages/nuxt/package.json # affected workspace keeps direct Vitest", "cat packages/unit/package.json # unrelated workspace drops direct Vitest", "cat pnpm-workspace.yaml # shared Vitest pin remains because one workspace needs it", - "cat packages/nuxt/nuxt.spec.ts # bare Vitest stays while its subpath migrates", - "cat packages/unit/unit.spec.ts # unrelated bare Vitest migrates", + "cat packages/nuxt/nuxt.spec.ts # upstream Vitest and its subpath stay", + "cat packages/nuxt/unit.spec.ts # files without Nuxt imports still preserve Vitest in the affected package", + "cat packages/unit/unit.spec.ts # an unrelated workspace still migrates Vitest", "vp migrate --no-interactive # workspace result is idempotent" ], "ignoredPlatforms": [ diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt index 681c907914..f0fca66ab3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -1,11 +1,11 @@ -> vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces +> vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils ◇ Migrated . to Vite+ • Node npm -• 2 files had imports rewritten -• Kept bare `vitest` imports in 1 file for @nuxt/test-utils compatibility +• 1 file had imports rewritten +• Kept upstream `vitest` imports in 2 files for @nuxt/test-utils compatibility • Package manager settings configured -> cat package.json # direct Vitest and its shared pin remain for the preserved import +> cat package.json # direct Vitest and its shared pin remain for the package-level exception { "name": "migration-upgrade-nuxt-test-utils", "devDependencies": { @@ -26,21 +26,21 @@ } } -> cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate +> cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { page } from 'vite-plus/test/browser/context'; import { vi } from 'vitest'; -import { defineConfig } from 'vite-plus'; +import { defineConfig } from 'vitest/config'; mockNuxtImport('useExample', () => vi.fn()); void page; void defineConfig; -> cat unit.spec.ts # unrelated bare Vitest imports still migrate -import { expect } from 'vite-plus/test'; +> cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest +import { expect } from 'vitest'; expect(true).toBe(true); -> vp migrate --no-interactive # the compatibility result is idempotent +> vp migrate --no-interactive # the package-level compatibility result is idempotent This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json index 0f54655d5c..a3c9b5ae00 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/steps.json @@ -1,10 +1,10 @@ { "commands": [ - "vp migrate --no-interactive # preserve Nuxt-compatible bare imports by default while rewriting other Vitest surfaces", - "cat package.json # direct Vitest and its shared pin remain for the preserved import", - "cat nuxt.spec.ts # bare Vitest stays; config and browser subpaths migrate", - "cat unit.spec.ts # unrelated bare Vitest imports still migrate", - "vp migrate --no-interactive # the compatibility result is idempotent" + "vp migrate --no-interactive # preserve upstream Vitest throughout packages that declare @nuxt/test-utils", + "cat package.json # direct Vitest and its shared pin remain for the package-level exception", + "cat nuxt.spec.ts # unscoped Vitest stays while the scoped browser package migrates", + "cat unit.spec.ts # an unrelated test file in the same package also keeps upstream Vitest", + "vp migrate --no-interactive # the package-level compatibility result is idempotent" ], "ignoredPlatforms": [ { diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt index 271607b5e0..f98e748c25 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/snap.txt @@ -1,41 +1,35 @@ -[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail - - × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test/node' instead of 'vitest/node' in Vite+ projects. - ╭─[src/nuxt.spec.ts:3:29] - 2 │ import { expect, vi } from 'vitest'; - 3 │ import { startVitest } from 'vitest/node'; - · ───────────── - 4 │ - ╰──── +[1]> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails - × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/test' instead of 'vitest' in Vite+ projects. - ╭─[src/unit.spec.ts:1:24] - 1 │ import { expect } from 'vitest'; - · ──────── - 2 │ + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus' instead of 'vite' in Vite+ projects. + ╭─[src/unit.spec.ts:1:30] + 1 │ import { defineConfig } from 'vite'; + · ────── + 2 │ import { expect } from 'vitest'; ╰──── -Found 0 warnings and 2 errors. +Found 0 warnings and 1 error. Finished in ms on 2 files with rules using threads. -> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports +> vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. > cat src/nuxt.spec.ts import { mockNuxtImport } from '@nuxt/test-utils/runtime'; import { expect, vi } from 'vitest'; -import { startVitest } from 'vite-plus/test/node'; +import { startVitest } from 'vitest/node'; mockNuxtImport('useExample', () => vi.fn()); void expect; void startVitest; > cat src/unit.spec.ts -import { expect } from 'vite-plus/test'; +import { defineConfig } from 'vite-plus'; +import { expect } from 'vitest'; +void defineConfig; void expect; -> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean +> vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean Found 0 warnings and 0 errors. Finished in ms on 2 files with rules using threads. diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts index a5a3f5c5c2..ec1d98893d 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/src/unit.spec.ts @@ -1,3 +1,5 @@ +import { defineConfig } from 'vite'; import { expect } from 'vitest'; +void defineConfig; void expect; diff --git a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json index e7e97ca151..454491842b 100644 --- a/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json +++ b/packages/cli/snap-tests/lint-vite-plus-imports-nuxt/steps.json @@ -1,10 +1,10 @@ { "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], "commands": [ - "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # only the Nuxt bare import is exempt; subpaths and unrelated files still fail", - "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # preserve the compatible bare import while fixing all other Vitest imports", + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # all upstream Vitest imports are exempt; the unrelated Vite import still fails", + "vp lint --threads=1 --fix src/nuxt.spec.ts src/unit.spec.ts # fix Vite without changing any upstream Vitest imports", "cat src/nuxt.spec.ts", "cat src/unit.spec.ts", - "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the mixed compatible result is clean" + "vp lint --threads=1 src/nuxt.spec.ts src/unit.spec.ts # confirm the package-level compatible result is clean" ] } diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 94c7cf148d..0c66f92072 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -16,6 +16,10 @@ const nuxtTestFilename = path.join( import.meta.dirname, 'fixtures/nuxt-test-utils/component.spec.ts', ); +const nuxtUnitTestFilename = path.join( + import.meta.dirname, + 'fixtures/nuxt-test-utils/unit.spec.ts', +); describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { @@ -158,8 +162,18 @@ new RuleTester({ code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, filename: nuxtTestFilename, }, + { + code: `import { expect } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { defineConfig } from 'vitest/config';`, + filename: nuxtUnitTestFilename, + }, ], invalid: [ + { + code: `import { page } from '@vitest/browser/context'`, + errors: 1, + filename: nuxtUnitTestFilename, + output: `import { page } from 'vite-plus/test/browser/context'`, + }, { // `declare module 'vite'` IS rewritten — the vite family doesn't // re-export upstream vite types so augmentation works against either id. @@ -224,9 +238,9 @@ new RuleTester({ }, { code: `import { vi } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, - errors: 1, - filename: nuxtTestFilename, - output: `import { vi } from 'vitest';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, + errors: 2, + filename: path.join(import.meta.dirname, 'ordinary.spec.ts'), + output: `import { vi } from 'vite-plus/test';\nimport { startVitest } from 'vite-plus/test/node';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, }, { code: `import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';`, diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 87f8d0f92c..a5e02d2e71 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2850,7 +2850,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }); it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( - 'detects Nuxt-compatible bare Vitest imports from %s without installed metadata', + 'detects package-wide upstream Vitest imports from %s without installed metadata', (dependencyGroup) => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -2867,11 +2867,12 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(detectNuxtTestUtilsVitestImportFiles(tmpDir)).toEqual([ path.join(tmpDir, 'nuxt.spec.ts'), + path.join(tmpDir, 'unit.spec.ts'), ]); }, ); - it('preserves Nuxt bare Vitest imports, keeps direct Vitest, and rewrites subpaths', () => { + it('preserves all upstream Vitest imports in a Nuxt test-utils package', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2911,50 +2912,9 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.overrides.vitest).toBe(VITEST_VERSION); const nuxtTest = fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8'); expect(nuxtTest).toContain("from 'vitest'"); - expect(nuxtTest).toContain("from 'vite-plus'"); - expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain( - "from 'vite-plus/test'", - ); - expect(report.preservedNuxtVitestImportFileCount).toBe(1); - }); - - it('rewrites Nuxt bare Vitest imports when compatibility preservation is declined', () => { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - name: 'nuxt-project', - devDependencies: { - vite: '^7.0.0', - vitest: '^4.0.0', - '@nuxt/test-utils': '^4.0.3', - }, - }), - ); - fs.writeFileSync( - path.join(tmpDir, 'nuxt.spec.ts'), - "import { vi } from 'vitest';\nimport { mockNuxtImport } from '@nuxt/test-utils/runtime';\n", - ); - const report = createMigrationReport(); - - rewriteStandaloneProject( - tmpDir, - makeWorkspaceInfo(tmpDir, PackageManager.npm), - true, - true, - report, - { preserveNuxtVitestImports: false }, - ); - - const pkg = readJson(path.join(tmpDir, 'package.json')) as { - devDependencies: Record; - overrides: Record; - }; - expect(pkg.devDependencies.vitest).toBeUndefined(); - expect(pkg.overrides.vitest).toBeUndefined(); - expect(fs.readFileSync(path.join(tmpDir, 'nuxt.spec.ts'), 'utf8')).toContain( - "from 'vite-plus/test'", - ); - expect(report.preservedNuxtVitestImportFileCount).toBe(0); + expect(nuxtTest).toContain("from 'vitest/config'"); + expect(fs.readFileSync(path.join(tmpDir, 'unit.spec.ts'), 'utf8')).toContain("from 'vitest'"); + expect(report.preservedNuxtVitestImportFileCount).toBe(2); }); it('does not add a coverage provider the project never declared', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 14f757af08..038cf029a8 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -55,7 +55,6 @@ import { detectFramework, detectIncompatibleEslintIntegration, detectNodeVersionManagerFile, - detectNuxtTestUtilsVitestImportFiles, detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, @@ -348,51 +347,6 @@ interface MigrationPlan extends MigrationSetupPlan { migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; frameworkShimFrameworks?: Framework[]; - preserveNuxtVitestImports: boolean; - nuxtVitestUnsafeRewrite: boolean; -} - -const NUXT_VITEST_REWRITE_WARNING = - '@nuxt/test-utils compatibility: bare `vitest` imports were rewritten. Files using ' + - '`mockNuxtImport` or `mockComponent` may need manual fixes for duplicate `vi` imports.'; - -async function collectNuxtVitestImportDecision( - rootDir: string, - options: MigrationOptions, - packages?: WorkspacePackage[], -): Promise<{ preserveNuxtVitestImports: boolean; nuxtVitestUnsafeRewrite: boolean }> { - const affectedFiles = detectNuxtTestUtilsVitestImportFiles(rootDir, packages); - if (affectedFiles.length === 0) { - return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; - } - if (!options.interactive) { - return { preserveNuxtVitestImports: true, nuxtVitestUnsafeRewrite: false }; - } - - prompts.log.step('@nuxt/test-utils detected', { withGuide: true }); - const action = await prompts.select({ - message: 'How should bare `vitest` imports in Nuxt test files be handled?', - options: [ - { - label: 'Keep `vitest` imports (recommended)', - value: 'preserve' as const, - hint: 'Compatible with mockNuxtImport and mockComponent', - }, - { - label: 'Rewrite to `vite-plus/test`', - value: 'rewrite' as const, - hint: 'May require manual fixes for duplicate vi imports', - }, - ], - initialValue: 'preserve' as const, - }); - if (prompts.isCancel(action)) { - cancelAndExit(); - } - return { - preserveNuxtVitestImports: action === 'preserve', - nuxtVitestUnsafeRewrite: action === 'rewrite', - }; } function getFrameworkShimCandidates(rootDir: string, packages?: WorkspacePackage[]): Framework[] { @@ -682,8 +636,6 @@ async function collectMigrationPlan( const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); - const nuxtVitestPlan = await collectNuxtVitestImportDecision(rootDir, options, packages); - // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -723,7 +675,6 @@ async function collectMigrationPlan( migrateNodeVersionFile, nodeVersionDetection, frameworkShimFrameworks, - ...nuxtVitestPlan, }; return plan; @@ -840,7 +791,7 @@ function showMigrationSummary(options: { } if (report.preservedNuxtVitestImportFileCount > 0) { log( - `${styleText('gray', '•')} Kept bare \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ + `${styleText('gray', '•')} Kept upstream \`vitest\` imports in ${report.preservedNuxtVitestImportFileCount} ${ report.preservedNuxtVitestImportFileCount === 1 ? 'file' : 'files' } for @nuxt/test-utils compatibility`, ); @@ -963,9 +914,6 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); - if (plan.nuxtVitestUnsafeRewrite) { - addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); - } const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1086,9 +1034,7 @@ async function executeMigrationPlan( // 7. Rewrite configs updateMigrationProgress('Rewriting configs'); if (workspaceInfo.isMonorepo) { - rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report, { - preserveNuxtVitestImports: plan.preserveNuxtVitestImports, - }); + rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report); } else { rewriteStandaloneProject( workspaceInfo.rootDir, @@ -1096,7 +1042,6 @@ async function executeMigrationPlan( skipStagedMigration, true, report, - { preserveNuxtVitestImports: plan.preserveNuxtVitestImports }, ); } @@ -1235,18 +1180,6 @@ async function main() { } }; - const nuxtVitestPlan = await collectNuxtVitestImportDecision( - workspaceInfoOptional.rootDir, - options, - workspaceInfoOptional.packages, - ); - const nuxtVitestImportOptions = { - preserveNuxtVitestImports: nuxtVitestPlan.preserveNuxtVitestImports, - }; - if (nuxtVitestPlan.nuxtVitestUnsafeRewrite) { - addMigrationWarning(report, NUXT_VITEST_REWRITE_WARNING); - } - const pendingCoreMigration = detectPendingCoreMigration(workspaceInfoOptional); const legacyGitHooksMigrationCandidate = detectLegacyGitHooksMigrationCandidate( workspaceInfoOptional.rootDir, @@ -1255,7 +1188,6 @@ async function main() { workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, workspaceInfoOptional.packages, - nuxtVitestImportOptions, ); let packageManager: PackageManager | undefined = vitePlusBootstrapPending ? (workspaceInfoOptional.packageManager ?? @@ -1292,7 +1224,6 @@ async function main() { true, report, pendingCoreMigration, - nuxtVitestImportOptions, ); if ( coreMigrationResult.scripts || @@ -1347,7 +1278,6 @@ async function main() { downloadPackageManager: downloadResult, }, report, - nuxtVitestImportOptions, ); didMigrate = bootstrapResult.changed || didMigrate; needsInstall = bootstrapResult.changed || needsInstall; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index dade292775..309e735c50 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -614,7 +614,8 @@ function projectListsRequiredVitestPeer( // True iff the project uses vitest DIRECTLY — via a dependency that is expected // to have a required vitest peer (see `projectListsVitestEcosystemDep`), an -// upstream `vitest` module specifier, or vitest browser mode. Drives +// upstream `vitest` module specifier, a package-level @nuxt/test-utils +// compatibility boundary, or vitest browser mode. Drives // whether the migration keeps `vitest` managed or removes it entirely; the // browser-mode arm keeps it aligned with the direct-`vitest` injection below so // an injected `catalog:` spec never dangles against a vitest-less catalog. @@ -640,7 +641,7 @@ function projectUsesVitestDirectly( // exact Vitest peer is left unsatisfied under strict pnpm/Yarn layouts. VITEST_BROWSER_DEP_NAMES.some((name) => pkg.peerDependencies?.[name] !== undefined) || sourceTreeReferencesRetainedVitestModule(projectPath) || - (preserveNuxtVitestImports && sourceTreeReferencesNuxtVitestImport(projectPath, pkg)) || + (preserveNuxtVitestImports && hasNuxtTestUtilsDependency(pkg)) || usesVitestBrowserMode(projectPath) ); } @@ -4606,10 +4607,8 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } -const BARE_VITEST_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest['"]/m; -const NUXT_TEST_UTILS_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; +const UPSTREAM_VITEST_MODULE_REFERENCE = + /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( @@ -4617,21 +4616,9 @@ function hasNuxtTestUtilsDependency(pkg: DependencyBag): boolean { ); } -function sourceReferencesNuxtTestUtilsWithBareVitest(content: string): boolean { - return ( - BARE_VITEST_MODULE_REFERENCE.test(content) && NUXT_TEST_UTILS_MODULE_REFERENCE.test(content) - ); -} - -function sourceTreeReferencesNuxtVitestImport(projectPath: string, pkg: DependencyBag): boolean { - return ( - hasNuxtTestUtilsDependency(pkg) && - sourceTreeMatches(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest) - ); -} - /** - * Find files eligible for the @nuxt/test-utils bare-vitest compatibility choice. + * Find files whose upstream Vitest imports are preserved by the + * @nuxt/test-utils package-level compatibility rule. * Each package is scanned independently so a root dependency does not leak into * unrelated workspace manifests. */ @@ -4649,7 +4636,9 @@ export function detectNuxtTestUtilsVitestImportFiles( continue; } files.push( - ...sourceTreeMatchingFiles(projectPath, sourceReferencesNuxtTestUtilsWithBareVitest), + ...sourceTreeMatchingFiles(projectPath, (content) => + UPSTREAM_VITEST_MODULE_REFERENCE.test(content), + ), ); } return [...new Set(files)]; @@ -5797,7 +5786,7 @@ function rewriteAllImports( ): boolean { const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); const modified = result.modifiedFiles.length; - const preserved = result.preservedBareVitestFiles.length; + const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; if (report) { diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 41a163fd0c..c763f9c235 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -101,10 +101,12 @@ function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): str return `${quote}${replacement}${quote}`; } -const NUXT_TEST_UTILS_MODULE_REFERENCE = - /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]@nuxt\/test-utils(?:\/[^'"]+)?['"]/m; const nuxtTestUtilsPackageCache = new Map(); +function isUpstreamVitestSpecifier(specifier: string): boolean { + return specifier === 'vitest' || specifier.startsWith('vitest/'); +} + function nearestPackageUsesNuxtTestUtils(filename: string): boolean { if (!path.isAbsolute(filename)) { return false; @@ -144,12 +146,12 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, - preserveBareVitest = false, + preserveUpstreamVitest = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } - if (preserveBareVitest && literal.value === 'vitest') { + if (preserveUpstreamVitest && isUpstreamVitestSpecifier(literal.value)) { return; } @@ -185,30 +187,28 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { - let preserveBareVitest = false; + let preserveUpstreamVitest = false; return { Program() { - preserveBareVitest = - nearestPackageUsesNuxtTestUtils(context.filename) && - NUXT_TEST_UTILS_MODULE_REFERENCE.test(context.sourceCode.text); + preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, ImportExpression(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSImportType(node) { - maybeReportLiteral(context, node.source, preserveBareVitest); + maybeReportLiteral(context, node.source, preserveUpstreamVitest); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression, preserveBareVitest); + maybeReportLiteral(context, node.expression, preserveUpstreamVitest); }, TSModuleDeclaration(node) { if (node.global) { @@ -222,7 +222,7 @@ export const preferVitePlusImportsRule = defineRule({ ) { return; } - maybeReportLiteral(context, id, preserveBareVitest); + maybeReportLiteral(context, id, preserveUpstreamVitest); }, }; }, diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 5a2abfdf91..55b29ed441 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -23,52 +23,41 @@ Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-p Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. -| Area | Rule | -| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or preserves bare `vitest` imports for `@nuxt/test-utils` compatibility. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export; rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +| Area | Rule | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. ## `@nuxt/test-utils` compatibility -`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. The migration therefore treats bare `vitest` imports in Nuxt test-utils files as a compatibility boundary rather than applying the ordinary rewrite unconditionally. +`@nuxt/test-utils`'s transform detects an existing `vi` import only when its module specifier is exactly `vitest`. When a test uses `mockNuxtImport` or `mockComponent`, changing that import to `vite-plus/test` makes the transform inject a second `vi` import and can fail compilation with a duplicate identifier. Requiring users to know which individual files exercise that transform is brittle, so the migration uses one package-level rule instead. Detection and scope: 1. A package is eligible when its `dependencies`, `devDependencies`, or `optionalDependencies` contains `@nuxt/test-utils`. -2. Within an eligible package, a Nuxt test-utils file is one that directly imports, exports from, requires, or dynamically imports `@nuxt/test-utils` or one of its subpaths. -3. The compatibility choice applies only to the exact bare specifier `vitest` in those files. `vitest/config`, every other `vitest/*` subpath, `@vitest/browser*`, and files unrelated to `@nuxt/test-utils` continue through the normal rewrites. -4. Preserving at least one bare import is retained direct Vitest usage, so that package keeps its package-local `vitest` and the workspace keeps the matching shared pin/catalog entry. -5. `prefer-vite-plus-imports` uses the same file-level exception. Lint and autofix must not undo the migration result. +2. Every `vitest` and `vitest/*` module specifier in that package is preserved, regardless of whether the individual file imports `@nuxt/test-utils`. This includes unit tests and shared test helpers, eliminating mixed import identities within one test suite. +3. Scoped `@vitest/browser*` specifiers keep their existing Vite+ rewrites and provider provisioning because they are separate packages, not the upstream `vitest` package identity protected by this rule. +4. An eligible package keeps its package-local `vitest`, and the workspace keeps the matching shared pin/catalog entry. +5. Workspace scope follows the nearest `package.json`: one Nuxt package does not suppress rewrites in unrelated workspace packages. +6. `prefer-vite-plus-imports` uses the same package-level exception for `vitest` and `vitest/*`. Lint and autofix must not undo the migration result. -If eligible files with bare `vitest` imports exist, interactive migration asks: +This rule is automatic in interactive and non-interactive migrations; there is no per-file prompt. A migration reports: ```text -◆ @nuxt/test-utils detected - -◆ How should bare `vitest` imports in Nuxt test files be handled? -│ ● Keep `vitest` imports (recommended) -│ Compatible with `mockNuxtImport` and `mockComponent` -│ ○ Rewrite to `vite-plus/test` -│ May require manual fixes for duplicate `vi` imports -``` - -`--no-interactive` selects the recommended preservation behavior. A preserving migration reports: - -```text -• Kept bare `vitest` imports in 7 files for @nuxt/test-utils compatibility +• Kept upstream `vitest` imports in 135 files for @nuxt/test-utils compatibility ``` -The count is the number of files, not import declarations. If the user explicitly selects rewriting, migration emits a compatibility warning identifying the possible duplicate-`vi` follow-up. +The count is the number of files, not import declarations. **Pending verification:** vitest **browser mode** historically needed a direct `vitest` injected (the "vibe-dashboard" regression). The upgrade now restores the opt-in provider and framework peer and keeps the package-local `vitest`; retain that behavior until a urllib-style pnpm/npm/yarn check proves any part is redundant. @@ -93,12 +82,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `crates/vite_migration` (`import_rewriter.rs`) | Support a file-scoped Nuxt compatibility mode that preserves only exact bare `vitest` specifiers while continuing all Vitest subpath and browser-provider rewrites; return the preserved-file count for the migration summary. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; Nuxt dependency/file detection, prompt choice, and retained Vitest provisioning. | -| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt file-level bare-`vitest` exception so diagnostics and autofix preserve the migration's compatible result. | +| Area | Change | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -127,9 +116,9 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | | Unmanaged exact-peer Vitest ecosystem versions remain aligned with user-owned Vitest | `migration-vitest-unmanaged-override` | -| Nuxt-compatible bare imports are preserved while Vitest subpaths still rewrite | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | +| Nuxt packages preserve all upstream `vitest` imports without affecting sibling packages | `migration-upgrade-nuxt-test-utils`, `migration-upgrade-nuxt-test-utils-monorepo` | -The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: the Nuxt file's bare import remains exempt while its Vitest subpath and an unrelated file's bare import are both fixed. +The matching Oxlint/autofix behavior is covered by the local `lint-vite-plus-imports-nuxt` snapshot: all `vitest` imports in the Nuxt package remain exempt, while the rule continues rewriting Vite and scoped browser-package imports. ## Follow-ups (not in this change) From c1e337f0655e362c256b4d3851d4cd4353027366 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:06:21 +0800 Subject: [PATCH 20/32] fix(migrate): convert Yarn PnP projects --- .../snap.txt | 7 +- .../steps.json | 2 +- .../snap.txt | 6 +- .../steps.json | 6 +- .../snap.txt | 10 +- .../steps.json | 4 +- .../config/tsconfig.test.json | 5 + .../resolve.cjs | 1 + .../snap.txt | 10 ++ .../steps.json | 2 + .../src/migration/__tests__/migrator.spec.ts | 121 ++++++++++++++++++ packages/cli/src/migration/bin.ts | 65 +++++++++- packages/cli/src/migration/migrator.ts | 99 +++++++++++++- rfcs/migrate-existing-projects.md | 55 ++++---- 14 files changed, 349 insertions(+), 44 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index a61ab8c68d..4f5d245b29 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -1,7 +1,12 @@ -> vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass +> vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode ◇ Migrated . to Vite+ • Node yarn • 2 config updates applied, 1 file had imports rewritten +• Package manager settings configured > cat package.json # migrated dependency specs use the Yarn catalog immediately { diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json index 2462490ad8..09ea344a15 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp migrate --no-interactive # standalone Yarn writes catalog specs on the first pass", + "vp migrate --no-interactive # implicit Yarn Berry PnP converts before the first pass", "cat package.json # migrated dependency specs use the Yarn catalog immediately", "cat .yarnrc.yml # managed catalog entries are available to those specs", "cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface", diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index dafe572187..fa679f6ff3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -1,5 +1,4 @@ -> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata -> vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name +> vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest ◇ Migrated . to Vite+ • Node npm • Package manager settings configured @@ -25,6 +24,7 @@ } } -> vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun +> node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata +> vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer This project is already using Vite+! Happy coding! diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json index 738904c5e0..46f3b70402 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/steps.json @@ -1,8 +1,8 @@ { "commands": [ - "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", - "vp migrate --no-interactive # required Vitest peer is detected without a Vitest package name", + "vp migrate --no-interactive # clean checkout conservatively preserves existing Vitest", "cat package.json # package-local Vitest and its shared override remain aligned", - "vp migrate --no-interactive # metadata-based peer provisioning is stable on rerun" + "node -e \"const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })\" # simulate installed dependency metadata", + "vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt index 8d0b908ae7..1a2c62b558 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -1,4 +1,8 @@ -> vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest +> vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration + +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode ◇ Migrated . to Vite+ • Node yarn • Package manager settings configured @@ -24,8 +28,8 @@ } } -> cat .yarnrc.yml # shared catalog should include the aligned vitest -nodeLinker: pnp +> cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted +nodeLinker: node-modules catalog: vite: npm:@voidzero-dev/vite-plus-core@latest vite-plus: latest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json index 41aa4f3d2c..2c014edafb 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/steps.json @@ -1,8 +1,8 @@ { "env": {}, "commands": [ - "vp migrate --no-interactive # Yarn PnP exact peer should receive package-local vitest", + "vp migrate --no-interactive # Yarn PnP converts to node-modules before exact-peer migration", "cat package.json # direct deps and resolutions should use the managed catalog/version", - "cat .yarnrc.yml # shared catalog should include the aligned vitest" + "cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted" ] } diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json new file mode 100644 index 0000000000..aa0a8c0310 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/config/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs new file mode 100644 index 0000000000..48997b4070 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/resolve.cjs @@ -0,0 +1 @@ +module.exports = require.resolve('vitest'); diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index 8fc11d08b5..d58ec2197c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -30,6 +30,16 @@ } } +> cat config/tsconfig.test.json # nested compilerOptions.types is also retained +{ + "compilerOptions": { + "types": ["vitest/globals"] + } +} + +> cat resolve.cjs # require.resolve remains an upstream Vitest reference +module.exports = require.resolve('vitest'); + > cat version.ts # vitest/package.json remains intentionally unre-written import metadata from 'vitest/package.json'; diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json index 2a598938ba..0f3fbd9146 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/steps.json @@ -3,6 +3,8 @@ "vp migrate --no-interactive # retained upstream references require package-local Vitest", "cat package.json # Vitest dependency and override stay aligned", "cat tsconfig.json # compilerOptions.types remains an upstream Vitest reference", + "cat config/tsconfig.test.json # nested compilerOptions.types is also retained", + "cat resolve.cjs # require.resolve remains an upstream Vitest reference", "cat version.ts # vitest/package.json remains intentionally unre-written", "vp migrate --no-interactive # retained references remain stable on rerun" ] diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index a5e02d2e71..0d5a5fd429 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -40,9 +40,80 @@ const { detectIncompatibleEslintIntegration, preflightGitHooksSetup, detectLegacyGitHooksMigrationCandidate, + detectYarnPnpMode, + configureYarnNodeModulesMode, setPackageManager, } = await import('../migrator.js'); +describe('Yarn PnP migration preflight', () => { + let tmpDir: string; + const savedEnv: Record = {}; + const isolatedEnv = ['HOME', 'USERPROFILE', 'YARN_NODE_LINKER'] as const; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-yarn-pnp-')); + for (const key of isolatedEnv) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + const cleanHome = path.join(tmpDir, '.home'); + fs.mkdirSync(cleanHome); + process.env.HOME = cleanHome; + process.env.USERPROFILE = cleanHome; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + for (const key of isolatedEnv) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it('detects explicit and implicit Yarn Berry PnP modes', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'configuration' }); + + fs.rmSync(path.join(tmpDir, '.yarnrc.yml')); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'default' }); + expect(detectYarnPnpMode(tmpDir, 'latest')).toEqual({ source: 'default' }); + }); + + it('does not classify Yarn Classic or node-modules configuration as PnP', () => { + expect(detectYarnPnpMode(tmpDir, '1.22.22')).toBeUndefined(); + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('honours YARN_NODE_LINKER over project configuration', () => { + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + process.env.YARN_NODE_LINKER = 'pnp'; + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toEqual({ source: 'environment' }); + + process.env.YARN_NODE_LINKER = 'node-modules'; + fs.writeFileSync(path.join(tmpDir, '.yarnrc.yml'), 'nodeLinker: pnp\n'); + expect(detectYarnPnpMode(tmpDir, '4.12.0')).toBeUndefined(); + }); + + it('converts the project rc without discarding other settings and is idempotent', () => { + fs.writeFileSync( + path.join(tmpDir, '.yarnrc.yml'), + 'nodeLinker: pnp\nnmHoistingLimits: workspaces\ncatalog:\n react: ^19.0.0\n', + ); + + expect(configureYarnNodeModulesMode(tmpDir)).toBe(true); + expect(readYamlObject(path.join(tmpDir, '.yarnrc.yml'))).toEqual({ + nodeLinker: 'node-modules', + nmHoistingLimits: 'workspaces', + catalog: { react: '^19.0.0' }, + }); + expect(configureYarnNodeModulesMode(tmpDir)).toBe(false); + }); +}); + describe('rewritePackageJson', () => { it('should rewrite package.json scripts and extract staged config', async () => { const pkg = { @@ -1586,6 +1657,37 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.yarn)).toBe(false); }); + it('preserves existing Vitest when dependency peer metadata is unavailable', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'latest', + 'vite-plugin-gherkin': '0.2.0', + vitest: '^4.1.0', + }, + overrides: { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: '^4.1.0', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); + expect(pkg.overrides.vitest).toBe(VITEST_VERSION); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + }); + it.each([ { name: 'compilerOptions.types', @@ -1595,6 +1697,17 @@ describe('ensureVitePlusBootstrap', () => { JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), ), }, + { + name: 'nested compilerOptions.types', + writeReference: (projectPath: string) => { + const configDir = path.join(projectPath, 'config'); + fs.mkdirSync(configDir); + fs.writeFileSync( + path.join(configDir, 'tsconfig.test.json'), + JSON.stringify({ compilerOptions: { types: ['vitest/globals'] } }), + ); + }, + }, { name: 'vitest/package.json', writeReference: (projectPath: string) => @@ -1603,6 +1716,14 @@ describe('ensureVitePlusBootstrap', () => { "import metadata from 'vitest/package.json';\nconsole.log(metadata.version);\n", ), }, + { + name: 'require.resolve', + writeReference: (projectPath: string) => + fs.writeFileSync( + path.join(projectPath, 'resolve.cjs'), + "module.exports = require.resolve('vitest');\n", + ), + }, ])('keeps package-local Vitest for retained $name references', ({ writeReference }) => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 038cf029a8..34e36837ce 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -58,6 +58,7 @@ import { detectPendingCoreMigration, detectPrettierProject, detectVitePlusBootstrapPending, + detectYarnPnpMode, ensureVitePlusBootstrap, finalizeCoreMigrationForExistingVitePlus, hasFrameworkShim, @@ -68,6 +69,7 @@ import { migrateEslintToOxlint, migrateNodeVersionManagerFile, migratePrettierToOxfmt, + configureYarnNodeModulesMode, preflightGitHooksSetup, rewriteMonorepo, rewriteStandaloneProject, @@ -125,6 +127,47 @@ async function confirmFrameworkShim(framework: Framework, interactive: boolean): return true; } +async function ensureYarnNodeModulesMode( + rootDir: string, + packageManager: PackageManager | undefined, + packageManagerVersion: string, + interactive: boolean, +): Promise { + if (packageManager !== PackageManager.yarn) { + return false; + } + + const pnp = detectYarnPnpMode(rootDir, packageManagerVersion); + if (!pnp) { + return false; + } + + prompts.log.warn(`⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP).`); + if (pnp.source === 'environment') { + cancelAndExit( + 'YARN_NODE_LINKER=pnp overrides project configuration. Set it to node-modules or unset it, then re-run `vp migrate`.', + 1, + ); + } + + if (interactive) { + const confirmed = await prompts.confirm({ + message: 'Switch this project to Yarn node-modules mode and continue?', + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + if (!confirmed) { + cancelAndExit('Migration cancelled. Vite+ requires Yarn node-modules mode.'); + } + } + + configureYarnNodeModulesMode(rootDir); + prompts.log.success('✔ Switched Yarn to node-modules mode'); + return true; +} + async function fixBaseUrlForWorkspace( workspaceInfo: { rootDir: string; packages?: WorkspacePackage[] }, fixBaseUrl: boolean, @@ -341,6 +384,7 @@ interface MigrationSetupPlan { interface MigrationPlan extends MigrationSetupPlan { packageManager: PackageManager; + yarnPnpConverted: boolean; migratePrettier: boolean; prettierConfigFile?: string; fixBaseUrl: boolean; @@ -629,12 +673,19 @@ function getExistingVitePlusSetupOptions( async function collectMigrationPlan( rootDir: string, detectedPackageManager: PackageManager | undefined, + detectedPackageManagerVersion: string, options: MigrationOptions, packages?: WorkspacePackage[], ): Promise { // 1. Package manager selection const packageManager = detectedPackageManager ?? (await selectPackageManager(options.interactive, true)); + const yarnPnpConverted = await ensureYarnNodeModulesMode( + rootDir, + packageManager, + detectedPackageManager ? detectedPackageManagerVersion : 'latest', + options.interactive, + ); // 2. Shared setup/tooling decisions const setupPlan = await collectMigrationSetupPlan(rootDir, packageManager, options, packages); @@ -668,6 +719,7 @@ async function collectMigrationPlan( const plan: MigrationPlan = { packageManager, + yarnPnpConverted, ...setupPlan, migratePrettier, prettierConfigFile: prettierProject.configFile, @@ -914,6 +966,7 @@ async function executeMigrationPlan( report: MigrationReport; }> { const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = plan.yarnPnpConverted; const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; let migrationProgressStarted = false; const updateMigrationProgress = (message: string) => { @@ -1148,10 +1201,17 @@ async function main() { workspaceInfoOptional.rootDir, ) as PackageDependencies | null; if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) { - let didMigrate = false; + const yarnPnpConverted = await ensureYarnNodeModulesMode( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, + options.interactive, + ); + let didMigrate = yarnPnpConverted; let installDurationMs = 0; let finalInstallOk = true; const report = createMigrationReport(); + report.packageManagerBootstrapConfigured = yarnPnpConverted; const migrationProgress = options.interactive ? prompts.spinner({ indicator: 'timer' }) : undefined; @@ -1266,7 +1326,7 @@ async function main() { workspaceInfoOptional.packages, ); - let needsInstall = false; + let needsInstall = yarnPnpConverted; if (vitePlusBootstrapPending) { const downloadResult = await ensureExistingPackageManager(); if (downloadResult && packageManager) { @@ -1498,6 +1558,7 @@ async function main() { const plan = await collectMigrationPlan( workspaceInfoOptional.rootDir, workspaceInfoOptional.packageManager, + workspaceInfoOptional.packageManagerVersion, options, workspaceInfoOptional.packages, ); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 309e735c50..da00075a59 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -581,16 +581,27 @@ function projectListsRequiredVitestPeer( optionalDependencies?: Record; }, ): boolean { + const installGroups = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]; + const hasExistingVitest = installGroups.some( + (dependencies) => dependencies?.vitest !== undefined, + ); const dependencyNames = new Set([ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), ...Object.keys(pkg.optionalDependencies ?? {}), ]); dependencyNames.delete('vitest'); + dependencyNames.delete('vite'); + dependencyNames.delete(VITE_PLUS_NAME); + for (const name of VITEST_DIRECT_USAGE_EXCLUDED) { + dependencyNames.delete(name); + } + let metadataUnavailable = false; for (const name of dependencyNames) { const metadata = detectPackageMetadata(projectPath, name); if (!metadata) { + metadataUnavailable = true; continue; } try { @@ -605,11 +616,15 @@ function projectListsRequiredVitestPeer( return true; } } catch { - // Missing or unreadable installed metadata cannot provide a peer signal; - // retain the existing package-name and source-based fallbacks below. + metadataUnavailable = true; } } - return false; + // A clean checkout may not have node_modules/.pnp metadata yet. If the user + // already carries a direct Vitest while any dependency's peer contract is + // unknown, preserve it rather than risk removing the provider for an + // arbitrary integration such as vite-plugin-gherkin. A later migration with + // complete metadata can safely remove a genuinely redundant pin. + return metadataUnavailable && hasExistingVitest; } // True iff the project uses vitest DIRECTLY — via a dependency that is expected @@ -2701,6 +2716,51 @@ function resolveEffectiveYarnConfigValue( return home ? readYarnrcValue(home, key) : undefined; } +export interface YarnPnpDetection { + source: 'environment' | 'configuration' | 'default'; +} + +/** + * Detect Yarn Plug'n'Play using the same precedence Yarn applies to + * `nodeLinker`. Yarn 2+ defaults to PnP when no value is configured, while + * Yarn Classic defaults to node_modules. Unknown/`latest` Yarn versions are + * treated as modern because that is the version `vp` will provision. + */ +export function detectYarnPnpMode( + projectPath: string, + yarnVersion: string, +): YarnPnpDetection | undefined { + const environmentLinker = process.env.YARN_NODE_LINKER?.trim(); + if (environmentLinker) { + return environmentLinker.toLowerCase() === 'pnp' ? { source: 'environment' } : undefined; + } + + const configuredLinker = resolveEffectiveYarnConfigValue( + projectPath, + 'nodeLinker', + 'YARN_NODE_LINKER', + ); + if (configuredLinker) { + return configuredLinker.toLowerCase() === 'pnp' ? { source: 'configuration' } : undefined; + } + + const coercedVersion = semver.coerce(yarnVersion); + return coercedVersion?.major === 1 ? undefined : { source: 'default' }; +} + +/** Set the project-local Yarn linker while preserving every other rc setting. */ +export function configureYarnNodeModulesMode(projectPath: string): boolean { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf8') : undefined; + if (before === undefined) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + editYamlFile(yarnrcYmlPath, (doc) => { + doc.set('nodeLinker', 'node-modules'); + }); + return before !== fs.readFileSync(yarnrcYmlPath, 'utf8'); +} + // True when `dir`'s package.json declares a `workspaces` field — i.e. `dir` is a // workspace (Yarn project) root. `workspaces` may be an array or an object // (`{ packages: [...] }`); both are truthy. @@ -4607,6 +4667,33 @@ function sourceTreeReferencesAny(projectPath: string, hints: readonly string[]): return sourceTreeMatches(projectPath, (content) => hints.some((hint) => content.includes(hint))); } +function findPackageTsconfigFiles(projectPath: string): string[] { + const files: string[] = []; + const scanDir = (dir: string, isRoot: boolean): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + if (!isRoot && entries.some((entry) => entry.isFile() && entry.name === 'package.json')) { + return; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + scanDir(entryPath, false); + } + } else if (entry.isFile() && /^tsconfig(?:\.[\w-]+)?\.json$/i.test(entry.name)) { + files.push(entryPath); + } + } + }; + scanDir(projectPath, true); + return files; +} + const UPSTREAM_VITEST_MODULE_REFERENCE = /(?:\bfrom\s*|\b(?:import|require)\s*\(\s*|\bimport\s*)['"]vitest(?:\/[^'"]+)?['"]/m; @@ -4651,11 +4738,13 @@ export function detectNuxtTestUtilsVitestImportFiles( // identity, so keep Vitest package-local for those surfaces. function sourceTreeReferencesRetainedVitestModule(projectPath: string): boolean { return ( - findTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || + findPackageTsconfigFiles(projectPath).some(hasVitestTypesInTsconfig) || sourceTreeMatches(projectPath, (content) => { return ( /\bdeclare\s+module\s+['"]vitest(?:\/[^'"]*)?['"]/.test(content) || - content.includes('vitest/package.json') + content.includes('vitest/package.json') || + /\brequire\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) || + /\bimport\.meta\.resolve\s*\(\s*['"]vitest(?:\/[^'"]*)?['"]/.test(content) ); }) ); diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index 55b29ed441..c394fc603a 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -19,22 +19,29 @@ Both are needed, and the order matters. `vp migrate` normally runs the project's ## Migrate rules -Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under Yarn PnP and strict pnpm, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. - -Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm or Yarn PnP, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. - -| Area | Rule | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | -| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | -| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | -| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. Other retained references include module augmentations, `compilerOptions.types`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | -| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | -| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | -| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | -| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | -| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | +Run on an existing Vite+ project, in order. The guiding fact for vitest: `vite-plus` declares `vitest` (and the `@vitest/*` runtime family) as dependencies at the bundled version, so ordinary node-mode projects using only `vite-plus/test*` do not need their own `vitest`. A direct package with a required `vitest` peer is different: under strict dependency layouts, the copy nested below the sibling `vite-plus` dependency cannot satisfy that peer. Such a package needs a package-local direct `vitest`, plus a shared override when the package manager supports one. This applies whether the peer range is exact or broad. + +Removing the old direct dependency was exercised on `node-modules/urllib` across pnpm, npm, and yarn (PRs [#832](https://github.com/node-modules/urllib/pull/832) / [#833](https://github.com/node-modules/urllib/pull/833) / [#834](https://github.com/node-modules/urllib/pull/834)). Those node-modules layouts can hoist an exact peer, but that is not portable to strict pnpm, so the migration still provisions required peers explicitly. Required-peer handling is covered for official `@vitest/*` packages and the third-party `vitest-browser-svelte` case. + +### Yarn Plug'n'Play preflight + +Vite+ does not currently support Yarn Plug'n'Play. Before collecting the other migration decisions or installing dependencies, `vp migrate` resolves the effective Yarn linker from `YARN_NODE_LINKER`, project/ancestor/home `.yarnrc.yml` files, and Yarn's version-dependent default. Explicit `nodeLinker: pnp` and the implicit Yarn 2+ default are both PnP mode. + +When PnP is active, interactive migration prints the incompatibility and asks whether to switch the project to `nodeLinker: node-modules` and continue. Accepting writes the project-root `.yarnrc.yml` without discarding its other settings; declining cancels before the remaining migration mutates the project. `--no-interactive` uses the affirmative default, reports the conversion, and continues. The conversion happens before the initial install so a clean checkout gets physical dependency metadata for required-peer detection. A process-level `YARN_NODE_LINKER=pnp` cannot be persistently repaired in project files, so migration stops with instructions to unset it or change it to `node-modules`. + +| Area | Rule | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | +| Yarn linker | Vite+ does not currently support Yarn PnP. Detect explicit and implicit PnP before migration, ask to switch to `nodeLinker: node-modules`, and continue only after conversion. Non-interactive migration accepts this conversion by default. | +| `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | +| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | +| `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | +| `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | +| Workspaces | Reconcile every package manifest, not only the root. Localize the direct `vitest` dependency to packages that need it; keep shared catalogs/overrides only when at least one package needs them. Re-pin existing plain `vite-plus` ranges consistently while preserving deliberate protocol specs. | +| Legacy wrapper | Remove every `@voidzero-dev/vite-plus-test` alias (deps, overrides, catalogs); repoint direct wrapper imports to `vite-plus/test`. `vite-plus/test*` imports are left unchanged (stable public API). | +| pnpm config location | An empty `"pnpm": {}` with an existing `pnpm-workspace.yaml` reconciles the workspace file (instead of writing a second, conflicting override block into `package.json`). | +| Reinstall + verify | One reinstall with lockfile refresh (`--no-frozen-lockfile` / `--force`); before npm reinstalls, remove a stale real-`vite` install/lock entry that npm otherwise retains after the dependency becomes the Vite+ core alias. A failed install warns and sets a non-zero exit. | Force-override/CI mode (`VP_OVERRIDE_PACKAGES`) is respected: when `vitest` is not a managed key there, the project's own `vitest` is never stripped and its `@vitest/*` ecosystem dependencies are not realigned. Object-valued nested npm/Bun overrides are user-owned scopes rather than managed version pins and are preserved. @@ -82,12 +89,12 @@ How each package the `vitest` ecosystem rule covers is handled, verified against ## Implementation -| Area | Change | -| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | -| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | -| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | -| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | +| Area | Change | +| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/vite_global_cli` (`commands/migrate.rs`, `js_executor.rs`) | `delegate_migrate`: compare local `vite-plus` vs global `vp` version; escalate to the global CLI when older. | +| `crates/vite_migration` (`import_rewriter.rs`) | Support a package-scoped Nuxt compatibility mode that preserves `vitest` and `vitest/*` specifiers throughout packages that declare `@nuxt/test-utils`, while continuing scoped `@vitest/browser*` rewrites; return the preserved-file count for the migration summary. | +| `packages/cli/src/migration/{migrator,npm-reinstall,bin}.ts` | Yarn PnP preflight and `node-modules` conversion; usage-aware managed override set; per-package dependency reconciliation; `vitest` removal across every sink; full `@vitest/*` alignment; browser-provider restoration; behind `vite-plus`/`vite` re-pin; empty/unrelated-`pnpm` routing fix; stale npm Vite install cleanup; package-level Nuxt dependency detection and retained Vitest provisioning. | +| Oxlint `prefer-vite-plus-imports` rule | Apply the same Nuxt package-level `vitest` / `vitest/*` exception so diagnostics and autofix preserve the migration's compatible result. | Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provisioning, ecosystem alignment, browser-provider restoration, workspace localization, behind re-pin, empty-`pnpm` reconciliation), `npm-reinstall.spec.ts` (stale npm install and lock cleanup), and a routing test in `vite_global_cli`. @@ -97,7 +104,7 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Stale local CLI escalation, plain-range re-pin, stale wrapper removal, empty `pnpm` routing | `migration-upgrade-stale-local-pnpm` | | Default direct-`vitest` removal and ordinary import rewrite | `migration-already-vite-plus`, `migration-vitest-import-only` | -| Official exact peers under npm and Yarn PnP | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | +| Official exact peers under npm and Yarn after PnP-to-node-modules conversion | `migration-upgrade-vitest-exact-peer-npm`, `migration-upgrade-vitest-exact-peer-yarn4` | | Third-party range peer | `migration-vitest-peer-dep` | | Internal `@vitest/*` packages and `@vitest/eslint-plugin` exclusions | `migration-upgrade-vitest-non-runtime-only-npm` | | Playwright and WebdriverIO browser restoration, including pnpm driver approvals | `migration-upgrade-browser-source-only-pnpm`, `migration-upgrade-browser-webdriverio-pnpm` | @@ -111,7 +118,7 @@ Covered by unit tests in `migrator.spec.ts` (vitest removal, required-peer provi | Peer-only browser providers are promoted with direct and shared Vitest | `migration-upgrade-browser-peer-only-pnpm` | | Whitespace-tolerant Vitest directives rewrite without leaving transient pins | `migration-upgrade-vitest-reference-whitespace-pnpm` | | Object-valued nested Vitest overrides remain user-owned and idempotent | `migration-upgrade-nested-vitest-override-npm` | -| Retained `compilerOptions.types` and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | +| Retained tsconfig, resolver, and `vitest/package.json` references keep direct Vitest | `migration-upgrade-vitest-retained-references-npm` | | Required Vitest peers discovered from installed dependency metadata | `migration-upgrade-required-vitest-peer-metadata-npm` | | Deprecated `@vitest/coverage-c8` is not assigned a nonexistent Vitest 4 version | `migration-upgrade-deprecated-coverage-c8-npm` | | Standalone Yarn writes catalog specs in one pass and is idempotent | `migration-standalone-yarn4-idempotent` | From 493fe0ca112da687cb030072c60a7307930253f2 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:14:36 +0800 Subject: [PATCH 21/32] test(ecosystem): install Playwright for npmx.dev --- ecosystem-ci/repo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index dad175cc9b..3a5940a04e 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -95,7 +95,8 @@ "repository": "https://github.com/npmx-dev/npmx.dev.git", "branch": "main", "hash": "035776c96cf8f089c44e6011264b534b0bcde53c", - "forceFreshMigration": true + "forceFreshMigration": true, + "playwright": true }, "vite-plus-jest-dom-repro": { "repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git", From 4a07a8b658d92ba5cb06f777a73270ec77bd141d Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 17:26:04 +0800 Subject: [PATCH 22/32] test(migrate): cover conservative monorepo retention --- .../migration-monorepo-bun/snap.txt | 7 ++++++- .../migration-monorepo-pnpm/snap.txt | 7 +++++++ .../migration-monorepo-yarn4/snap.txt | 12 +++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 28d20df0c0..8a36eae8d9 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -45,6 +45,7 @@ export default defineConfig({ ], "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "", "vite-plus": "latest" } }, @@ -62,11 +63,13 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "bun@", "overrides": { - "vite": "catalog:" + "vite": "catalog:", + "vitest": "catalog:" } } @@ -86,6 +89,7 @@ export default defineConfig({ "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } @@ -103,6 +107,7 @@ export default defineConfig({ }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 0aae76b6b2..4181032dc4 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -66,6 +66,7 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "resolutions": { @@ -82,16 +83,20 @@ catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: vite-plus: latest minimumReleaseAge: 1440 overrides: vite: 'catalog:' + vitest: 'catalog:' peerDependencyRules: allowAny: - vite + - vitest allowedVersions: vite: '*' + vitest: '*' minimumReleaseAgeExclude: - vite-plus - '@voidzero-dev/*' @@ -120,6 +125,7 @@ minimumReleaseAgeExclude: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -140,6 +146,7 @@ minimumReleaseAgeExclude: }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index 1ee2f91b8b..f31071a5a8 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -1,10 +1,15 @@ > vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc +⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP). + +✔ Switched Yarn to node-modules mode + ✔ Merged .oxlintrc.json into vite.config.ts ◇ Migrated . to Vite+ • Node yarn • 2 config updates applied, 1 file had imports rewritten • Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt +• Package manager settings configured > cat vite.config.ts # check vite.config.ts import react from '@vitejs/plugin-react'; @@ -60,11 +65,13 @@ export default defineConfig({ "devDependencies": { "@vitejs/plugin-react": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "" } } @@ -75,6 +82,7 @@ npmPreapprovedPackages: - '@vitest/*' catalog: vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: vite-plus: latest > cat packages/app/package.json # check app package.json @@ -93,6 +101,7 @@ catalog: "devDependencies": { "test-vite-plus-package": "1.0.0", "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" }, "optionalDependencies": { @@ -113,6 +122,7 @@ catalog: }, "devDependencies": { "vite": "catalog:", + "vitest": "catalog:", "vite-plus": "catalog:" } } From 8674c094f2c3390d1892c07999d41eba450ca6b0 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 21:44:58 +0800 Subject: [PATCH 23/32] fix(migrate): pin pkg.pr.new targets in test helper --- .github/scripts/test-pkg-pr-new-migrate.sh | 144 ++++++++++++++++++ .../package.json | 13 ++ .../migration-upgrade-pkg-pr-new-npm/snap.txt | 29 ++++ .../steps.json | 15 ++ .../src/migration/__tests__/migrator.spec.ts | 52 ++++++- packages/cli/src/migration/migrator.ts | 19 +-- 6 files changed, 252 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/test-pkg-pr-new-migrate.sh create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh new file mode 100755 index 0000000000..b35e0e27e8 --- /dev/null +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: .github/scripts/test-pkg-pr-new-migrate.sh [migrate-options...] + +Examples: + .github/scripts/test-pkg-pr-new-migrate.sh 1891 /path/to/npmx.dev + .github/scripts/test-pkg-pr-new-migrate.sh 4eb2104c /path/to/project --no-interactive + +Environment variables: + VP_PKG_PR_NEW_HOME Override the isolated global CLI installation directory. + ALLOW_DIRTY=1 Allow migration in a dirty Git worktree. +EOF +} + +if [ "$#" -lt 2 ]; then + usage >&2 + exit 2 +fi + +pr_ref="$1" +project_input="$2" +shift 2 + +case "$pr_ref" in + '' | *[![:alnum:]._-]*) + echo "error: PR or SHA contains unsupported characters: $pr_ref" >&2 + exit 2 + ;; +esac + +if [ ! -d "$project_input" ]; then + echo "error: project directory does not exist: $project_input" >&2 + exit 2 +fi + +project_dir="$(cd "$project_input" && pwd -P)" +if [ ! -f "$project_dir/package.json" ]; then + echo "error: package.json not found in project: $project_dir" >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/../.." && pwd -P)" +installer="$repo_root/packages/cli/install.sh" + +if [ ! -f "$installer" ]; then + echo "error: Vite+ installer not found: $installer" >&2 + exit 2 +fi + +is_git_repo=0 +if command -v git >/dev/null 2>&1 && git -C "$project_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + is_git_repo=1 + if [ "${ALLOW_DIRTY:-0}" != "1" ] && [ -n "$(git -C "$project_dir" status --porcelain)" ]; then + echo "error: project worktree is dirty: $project_dir" >&2 + echo "Commit or stash its changes, or rerun with ALLOW_DIRTY=1." >&2 + exit 2 + fi +fi + +original_home="$HOME" +cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" +pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" +installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" + +cleanup() { + rm -rf "$installer_home" +} +trap cleanup EXIT + +echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" +HOME="$installer_home" \ + VP_HOME="$pr_home" \ + VP_PR_VERSION="$pr_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + +vp_bin="$pr_home/bin/vp" +if [ ! -x "$vp_bin" ]; then + echo "error: installed vp executable not found: $vp_bin" >&2 + exit 1 +fi + +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +if [ ! -f "$vite_plus_package_json" ]; then + echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 + exit 1 +fi + +vitest_version="$(awk -F '"' '$2 == "vitest" { print $4; exit }' "$vite_plus_package_json")" +if [ -z "$vitest_version" ]; then + echo "error: could not determine the bundled Vitest version from $vite_plus_package_json" >&2 + exit 1 +fi + +pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" +vite_plus_spec="$pkg_pr_new_base@$pr_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" + +export VP_HOME="$pr_home" +export PATH="$VP_HOME/bin:$PATH" +export VP_VERSION="$vite_plus_spec" +export VP_OVERRIDE_PACKAGES="$(printf \ + '{"vite":"%s","@voidzero-dev/vite-plus-core":"%s","vitest":"%s"}' \ + "$vite_plus_core_spec" \ + "$vite_plus_core_spec" \ + "$vitest_version")" +export VP_FORCE_MIGRATE=1 +hash -r + +echo +echo "Using isolated global CLI:" +echo " executable: $vp_bin" +echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" +echo " vite-plus spec: $VP_VERSION" +echo " vite spec: $vite_plus_core_spec" +"$vp_bin" --version + +echo +echo "Running vp migrate in $project_dir" +runner_dir="$installer_home/runner" +mkdir -p "$runner_dir" +set +e +( + # Resolve the CLI from an empty directory so a project-local vite-plus at the + # same semver cannot take precedence over the installed pkg.pr.new build. + cd "$runner_dir" + "$vp_bin" migrate "$project_dir" "$@" +) +migrate_status=$? +set -e + +if [ "$is_git_repo" -eq 1 ]; then + echo + echo "Migration worktree changes:" + git -C "$project_dir" status --short + git -C "$project_dir" diff --stat +fi + +exit "$migrate_status" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json new file mode 100644 index 0000000000..a104286713 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "npm@11.11.1" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt new file mode 100644 index 0000000000..b8dd7dbe73 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -0,0 +1,29 @@ +> vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec +◇ Migrated . to Vite+ +• Node npm +• 2 config updates applied + +> cat package.json # direct dependencies and npm overrides use the same PR URLs +{ + "name": "migration-upgrade-pkg-pr-new-npm", + "devDependencies": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "@voidzero-dev/vite-plus-core": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + }, + "packageManager": "npm@", + "scripts": { + "prepare": "vp config" + } +} + +> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)" # pkg.pr.new specs are coherent +> node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new migration is idempotent +◇ Migrated . to Vite+ +• Node npm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)" # rerun leaves package.json unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json new file mode 100644 index 0000000000..e00e423559 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -0,0 +1,15 @@ +{ + "env": { + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"@voidzero-dev/vite-plus-core\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", + "cat package.json # direct dependencies and npm overrides use the same PR URLs", + "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)\" # pkg.pr.new specs are coherent", + "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 0d5a5fd429..50671bab92 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1325,22 +1325,62 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); const result = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); - // The `vite` alias still points at the live `@voidzero-dev/vite-plus-core` - // package, so it satisfies the migration and is left untouched. The project - // does NOT use vitest directly (no @vitest/* dep, no vitest source), so the - // stale `vitest` wrapper override (the DELETED `@voidzero-dev/vite-plus-test`) - // is REMOVED entirely — vitest arrives transitively through vite-plus. + // Both managed aliases must match the active toolchain target. Keeping the + // old core alias while rewriting a direct `vite` dependency causes npm's + // EOVERRIDE error. The project does NOT use vitest directly (no @vitest/* + // dep, no vitest source), so the stale deleted wrapper override is removed. expect(result.changed).toBe(true); const pkg = readJson(path.join(tmpDir, 'package.json')) as { overrides: Record; }; - expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@0.1.0'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.overrides['@vitest/expect']).toBeUndefined(); expect(pkg.overrides['@vitest/coverage-v8']).toBeUndefined(); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); }); + it('replaces protocol-pinned migration targets in force-override mode', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + process.env.VP_FORCE_MIGRATE = '1'; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { + 'vite-plus': 'https://pkg.pr.new/voidzero-dev/vite-plus@old', + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + overrides: { + vite: 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@old', + }, + devEngines: { + packageManager: { name: 'npm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.npm)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.devDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.npm)).toBe(false); + } finally { + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('rewrites direct npm Vite dependencies before adding overrides', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index da00075a59..17f1f3d37a 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -3642,16 +3642,6 @@ export type VitePlusBootstrapResult = { packageManagerField: boolean; }; -function getVitePlusOverridePackageName(dependencyName: string): string | undefined { - if (dependencyName === 'vite') { - return '@voidzero-dev/vite-plus-core'; - } - if (dependencyName === 'vitest') { - return '@voidzero-dev/vite-plus-test'; - } - return undefined; -} - function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | undefined): boolean { if (!spec) { return false; @@ -3667,8 +3657,7 @@ function isSemanticVitePlusOverrideSpec(dependencyName: string, spec: string | u if (spec === VITE_PLUS_OVERRIDE_PACKAGES[dependencyName]) { return true; } - const packageName = getVitePlusOverridePackageName(dependencyName); - return packageName !== undefined && spec.includes(packageName); + return false; } function overrideSpecSatisfiesVitePlus( @@ -4182,8 +4171,10 @@ function ensureVitePlusDependencySpecs( } // Plain (non-protocol-pinned) range like `^0.1.24` → rewrite to the target // (`catalog:` for catalog-supporting projects, otherwise the concrete - // version). Already-`catalog:` / other protocol pins are left untouched. - if (!isProtocolPinnedSpec(spec)) { + // version). Already-`catalog:` / other protocol pins are left untouched, + // except in force-override mode where ecosystem/pkg.pr.new validation must + // replace every prior target with the requested artifact. + if (isForceOverrideMode() || !isProtocolPinnedSpec(spec)) { dependencies[VITE_PLUS_NAME] = version; changed = true; } From 6a797e5a6d28a6c2d674d6ac80533a41c0c8efad Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 22:12:33 +0800 Subject: [PATCH 24/32] fix(test): keep pkg.pr.new overrides minimal --- .github/scripts/test-pkg-pr-new-migrate.sh | 3 +-- .../migration-upgrade-pkg-pr-new-npm/snap.txt | 5 ++--- .../migration-upgrade-pkg-pr-new-npm/steps.json | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index b35e0e27e8..9b4f1a867d 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -105,8 +105,7 @@ export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" export VP_OVERRIDE_PACKAGES="$(printf \ - '{"vite":"%s","@voidzero-dev/vite-plus-core":"%s","vitest":"%s"}' \ - "$vite_plus_core_spec" \ + '{"vite":"%s","vitest":"%s"}' \ "$vite_plus_core_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt index b8dd7dbe73..1bc76fd5f3 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -11,8 +11,7 @@ "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" }, "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", - "@voidzero-dev/vite-plus-core": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" }, "packageManager": "npm@", "scripts": { @@ -20,7 +19,7 @@ } } -> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)" # pkg.pr.new specs are coherent +> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)" # pkg.pr.new specs use the minimal override shape > node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json index e00e423559..5f2a8b74ab 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -1,13 +1,13 @@ { "env": { "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"@voidzero-dev/vite-plus-core\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", "cat package.json # direct dependencies and npm overrides use the same PR URLs", - "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== core) process.exit(1)\" # pkg.pr.new specs are coherent", + "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)\" # pkg.pr.new specs use the minimal override shape", "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" From 577e84a6ce192839a88c87d6c2d2f64490484e4e Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 23 Jun 2026 22:50:13 +0800 Subject: [PATCH 25/32] fix(migrate): allow pkg.pr.new pnpm subdependencies --- .github/scripts/test-pkg-pr-new-migrate.sh | 5 ++ .../package.json | 10 ++++ .../pnpm-workspace.yaml | 21 +++++++ .../snap.txt | 57 ++++++++++++++++++ .../steps.json | 17 ++++++ .../src/migration/__tests__/migrator.spec.ts | 60 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 57 ++++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 9b4f1a867d..442aaff0e5 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -109,6 +109,11 @@ export VP_OVERRIDE_PACKAGES="$(printf \ "$vite_plus_core_spec" \ "$vitest_version")" export VP_FORCE_MIGRATE=1 +# pkg.pr.new packages depend on URL-resolved platform binaries. pnpm blocks +# those transitive URL dependencies when blockExoticSubdeps is enabled. The +# migration persists the corresponding workspace setting, while this temporary +# override also lets its pre-rewrite install recover a partially migrated tree. +export PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS=false hash -r echo diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json new file mode 100644 index 0000000000..541f6d14f1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.6", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", + "vite-plus": "^0.1.20", + "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" + }, + "packageManager": "pnpm@10.33.2" +} diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml new file mode 100644 index 0000000000..f7476db5c0 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/pnpm-workspace.yaml @@ -0,0 +1,21 @@ +packages: + - . + +blockExoticSubdeps: true + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@^0.1.20 + vite-plus: ^0.1.20 + vitest: npm:@voidzero-dev/vite-plus-test@^0.1.20 + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt new file mode 100644 index 0000000000..2d08a0ae0d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -0,0 +1,57 @@ +> vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build +{ + "name": "migration-upgrade-pkg-pr-new-pnpm", + "devDependencies": { + "@vitest/coverage-v8": "4.1.9", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@", + "pnpm": { + "overrides": { + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vitest": "", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + } + }, + "scripts": { + "prepare": "vp config" + } +} + +> cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed +packages: + - . + +blockExoticSubdeps: false + +catalog: + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@1891 + vitest: + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' + +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)" # pnpm policy and PR targets are persisted +> node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result +> vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent +◇ Migrated . to Vite+ +• Node pnpm + +> node -e "const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)" # rerun leaves manifests unchanged \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json new file mode 100644 index 0000000000..38f0648435 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -0,0 +1,17 @@ +{ + "env": { + "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", + "VP_FORCE_MIGRATE": "1", + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + }, + "commands": [ + "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", + "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", + "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)\" # pnpm policy and PR targets are persisted", + "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", + "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", + "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 50671bab92..d792df0b72 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1455,6 +1455,66 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); }); + it('allows pkg.pr.new transitive URLs in pnpm workspace config and is idempotent', () => { + const savedForceMigrate = process.env.VP_FORCE_MIGRATE; + const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + const viteOverride = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + process.env.VP_FORCE_MIGRATE = '1'; + VITE_PLUS_OVERRIDE_PACKAGES.vite = viteOverride; + try { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'blockExoticSubdeps: true', + 'catalog:', + ` vite: '${viteOverride}'`, + ' vite-plus: latest', + 'overrides:', + " vite: 'catalog:'", + 'peerDependencyRules:', + ' allowAny:', + ' - vite', + ' allowedVersions:', + " vite: '*'", + ].join('\n'), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true); + const first = ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)); + + expect(first.packageManagerConfig).toBe(true); + expect( + ( + readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + blockExoticSubdeps: boolean; + } + ).blockExoticSubdeps, + ).toBe(false); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false); + expect(ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.pnpm)).changed).toBe( + false, + ); + } finally { + VITE_PLUS_OVERRIDE_PACKAGES.vite = savedViteOverride; + if (savedForceMigrate === undefined) { + delete process.env.VP_FORCE_MIGRATE; + } else { + process.env.VP_FORCE_MIGRATE = savedForceMigrate; + } + } + }); + it('detects missing pnpm workspace catalog entry for vite-plus', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 17f1f3d37a..93700ba68f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1924,6 +1924,10 @@ export function rewriteStandaloneProject( }); } + if (packageManager === PackageManager.pnpm) { + ensurePnpmWorkspaceExoticSubdepsSetting(projectPath); + } + if (packageManager === PackageManager.yarn) { rewriteYarnrcYml(projectPath, usesVitest); } else if (packageManager === PackageManager.bun) { @@ -2188,6 +2192,8 @@ function rewritePnpmWorkspaceYaml( const managed = managedOverridePackages(usesVitest); editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + ensurePnpmExoticSubdepsSetting(doc); + // catalog rewriteCatalog(doc, usesVitest); if (pnpmMajorVersion !== undefined) { @@ -3802,6 +3808,50 @@ function readPnpmWorkspacePeerDependencyRules( return doc?.peerDependencyRules; } +function forceOverrideUsesExoticPnpmSpec(): boolean { + if (!isForceOverrideMode()) { + return false; + } + return [VITE_PLUS_VERSION, ...Object.values(VITE_PLUS_OVERRIDE_PACKAGES)].some((spec) => + /^(?:file|https?):/.test(spec), + ); +} + +function pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return true; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return false; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { blockExoticSubdeps?: boolean } | null; + return doc?.blockExoticSubdeps === false; +} + +function ensurePnpmExoticSubdepsSetting(doc: YamlDocument): boolean { + if (!forceOverrideUsesExoticPnpmSpec() || doc.get('blockExoticSubdeps') === false) { + return false; + } + doc.set('blockExoticSubdeps', false); + return true; +} + +function ensurePnpmWorkspaceExoticSubdepsSetting(projectPath: string): boolean { + if (!forceOverrideUsesExoticPnpmSpec()) { + return false; + } + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + let changed = false; + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + changed = ensurePnpmExoticSubdepsSetting(doc); + }); + return changed; +} + function yarnrcSatisfiesVitePlus(projectPath: string, usesVitest: boolean): boolean { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { @@ -4120,6 +4170,9 @@ export function detectVitePlusBootstrapPending( ); } if (packageManager === PackageManager.pnpm) { + if (!pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath)) { + return true; + } if (pnpmConfigLivesInPackageJson(pkg, projectPath)) { return ( vitePlusDependencyNeedsConcreteVersion(pkg) || @@ -4390,6 +4443,7 @@ export function ensureVitePlusBootstrap( const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( result.packageJson || + !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( readPnpmWorkspaceOverrides(projectPath), @@ -4415,6 +4469,9 @@ export function ensureVitePlusBootstrap( ? fs.readFileSync(pnpmWorkspaceYamlPath, 'utf-8') : undefined; result.packageManagerConfig = before !== after; + } else if (ensurePnpmWorkspaceExoticSubdepsSetting(projectPath)) { + ensurePnpmWorkspacePackages(projectPath, workspaceInfo.workspacePatterns); + result.packageManagerConfig = true; } } else if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); From ae00efed49db7ab9301dbf611bcb31cfc53b9e66 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 09:21:20 +0800 Subject: [PATCH 26/32] fix(test): refresh mutable pkg.pr.new installs --- .github/scripts/test-pkg-pr-new-migrate.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 442aaff0e5..8419dfe450 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -67,6 +67,22 @@ cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +# Numeric pkg.pr.new references are mutable PR aliases. The installer reuses a +# version directory named after the reference, so its lockfile can retain the +# checksum from an older publish of the same PR and fail with +# ERR_PNPM_TARBALL_INTEGRITY after the alias is refreshed. Keep the downloaded +# runtime/package-manager cache, but force the wrapper dependency to resolve +# and install again for every PR-alias run. Commit SHA references are immutable +# and can safely retain their installed dependency state. +case "$pr_ref" in + *[!0-9]*) ;; + *) + cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" + rm -rf "$cached_version_dir/node_modules" + rm -f "$cached_version_dir/pnpm-lock.yaml" + ;; +esac + cleanup() { rm -rf "$installer_home" } From 8b7e5686573aa676d105e09ca07fd78d57534601 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 10:26:33 +0800 Subject: [PATCH 27/32] fix(migrate): preserve Vitest ecosystem catalogs --- .../src/migration/__tests__/migrator.spec.ts | 82 ++++++ packages/cli/src/migration/migrator.ts | 278 +++++++++++++++--- 2 files changed, 319 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d792df0b72..a976dc57ce 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1675,6 +1675,88 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.devDependencies.vitest).toBe(VITEST_VERSION); }); + it('prefers existing catalogs for Vitest ecosystem packages and pins unsupported ones', () => { + const appDir = path.join(tmpDir, 'packages/app'); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'root', + private: true, + devDependencies: { 'vite-plus': 'catalog:' }, + devEngines: { + packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' }, + }, + }), + ); + fs.writeFileSync( + path.join(appDir, 'package.json'), + JSON.stringify({ + name: 'app', + devDependencies: { + // Reproduce the output from the prior migration: the package was + // hard-pinned even though the default catalog already owned it. + '@vitest/coverage-istanbul': VITEST_VERSION, + '@vitest/ui': 'catalog:test', + '@vitest/web-worker': '^4.1.0', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vite-plus: latest', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + ` vitest: ${VITEST_VERSION}`, + " '@vitest/coverage-istanbul': 4.1.4", + 'catalogs:', + ' test:', + " '@vitest/ui': 4.1.4", + 'blockExoticSubdeps: false', + 'overrides:', + " vite: 'catalog:'", + " vitest: 'catalog:'", + 'peerDependencyRules:', + ' allowAny: [vite, vitest]', + ' allowedVersions:', + " vite: '*'", + " vitest: '*'", + '', + ].join('\n'), + ); + const workspaceInfo = { + ...makeWorkspaceInfo(tmpDir, PackageManager.pnpm), + isMonorepo: true, + workspacePatterns: ['packages/*'], + packages: [{ name: 'app', path: 'packages/app' }], + }; + + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(true); + ensureVitePlusBootstrap(workspaceInfo); + + const pkg = readJson(path.join(appDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies['@vitest/coverage-istanbul']).toBe('catalog:'); + expect(pkg.devDependencies['@vitest/ui']).toBe('catalog:test'); + expect(pkg.devDependencies['@vitest/web-worker']).toBe(VITEST_VERSION); + + const workspace = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; + catalogs: Record>; + }; + expect(workspace.catalog['@vitest/coverage-istanbul']).toBe(VITEST_VERSION); + expect(workspace.catalogs.test['@vitest/ui']).toBe(VITEST_VERSION); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages), + ).toBe(false); + }); + it('does not align deprecated @vitest/coverage-c8 to a nonexistent Vitest 4 version', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 93700ba68f..7cab73984e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1725,6 +1725,7 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); let extractedStagedConfig: Record | null = null; let remainingPnpmOverrides: Record | undefined; @@ -1910,6 +1911,7 @@ export function rewriteStandaloneProject( pnpmMajorVersion, shouldAllowBrowserProviderBuilds, usesVitest, + vitestEcosystemPackages, ); } @@ -1929,7 +1931,7 @@ export function rewriteStandaloneProject( } if (packageManager === PackageManager.yarn) { - rewriteYarnrcYml(projectPath, usesVitest); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); } else if (packageManager === PackageManager.bun) { ensureBunfigPeerSuppression(projectPath); } @@ -1989,6 +1991,10 @@ export function rewriteMonorepo( workspaceInfo.packages, importOptions?.preserveNuxtVitestImports !== false, ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( @@ -1996,11 +2002,12 @@ export function rewriteMonorepo( pnpmMajorVersion, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, + vitestEcosystemPackages, ); } else if (workspaceInfo.packageManager === PackageManager.yarn) { - rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest); + rewriteYarnrcYml(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } else if (workspaceInfo.packageManager === PackageManager.bun) { - rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest); + rewriteBunCatalog(workspaceInfo.rootDir, workspaceUsesVitest, vitestEcosystemPackages); } rewriteRootWorkspacePackageJson( workspaceInfo.rootDir, @@ -2184,6 +2191,7 @@ function rewritePnpmWorkspaceYaml( pnpmMajorVersion: number | undefined, shouldAllowBrowserBuilds: boolean, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); if (!fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -2195,7 +2203,7 @@ function rewritePnpmWorkspaceYaml( ensurePnpmExoticSubdepsSetting(doc); // catalog - rewriteCatalog(doc, usesVitest); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); if (pnpmMajorVersion !== undefined) { applyBuildAllowanceToWorkspaceYaml(doc, pnpmMajorVersion, shouldAllowBrowserBuilds); } @@ -2894,7 +2902,11 @@ function applyYarnWorkspaceHoistingFix( } } -function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { +function rewriteYarnrcYml( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcYmlPath)) { fs.writeFileSync(yarnrcYmlPath, ''); @@ -2925,7 +2937,7 @@ function rewriteYarnrcYml(projectPath: string, usesVitest: boolean): void { } doc.setIn(['npmPreapprovedPackages'], npmPreapprovedPackages); // catalog - rewriteCatalog(doc, usesVitest); + rewriteCatalog(doc, usesVitest, vitestEcosystemPackages); }); } @@ -3104,7 +3116,11 @@ function pruneYamlMapLegacyWrapperAliases(map: unknown): void { } } -function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { +function rewriteCatalog( + doc: YamlDocument, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const managed = managedOverridePackages(usesVitest); // Common case (no direct vitest): remove any lingering managed `vitest` // catalog entry so it resolves transitively through vite-plus. @@ -3130,6 +3146,7 @@ function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { } // Drop any entry still pointing at the deleted `vite-plus-test` wrapper. pruneYamlMapLegacyWrapperAliases(doc.getIn(['catalog'])); + rewriteVitestEcosystemYamlCatalog(doc.getIn(['catalog']), vitestEcosystemPackages); const catalogs = doc.getIn(['catalogs']); if (!(catalogs instanceof YAMLMap)) { @@ -3162,6 +3179,26 @@ function rewriteCatalog(doc: YamlDocument, usesVitest: boolean): void { } } pruneYamlMapLegacyWrapperAliases(item.value); + rewriteVitestEcosystemYamlCatalog(item.value, vitestEcosystemPackages); + } +} + +function rewriteVitestEcosystemYamlCatalog( + catalog: unknown, + vitestEcosystemPackages: ReadonlySet, +): void { + if (!VITEST_IS_MANAGED_OVERRIDE || !(catalog instanceof YAMLMap)) { + return; + } + for (const item of catalog.items) { + const name = item.key instanceof Scalar ? item.key.value : undefined; + if ( + typeof name === 'string' && + vitestEcosystemPackages.has(name) && + isAlignableVitestEcosystemPackage(name) + ) { + catalog.set(item.key, scalarString(VITEST_VERSION)); + } } } @@ -3169,6 +3206,7 @@ function rewriteCatalogObject( catalog: Record, addMissing: boolean, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { const managed = managedOverridePackages(usesVitest); // Common case (no direct vitest): strip a lingering managed `vitest` catalog @@ -3188,14 +3226,22 @@ function rewriteCatalogObject( for (const name of REMOVE_PACKAGES) { delete catalog[name]; } + if (VITEST_IS_MANAGED_OVERRIDE) { + for (const name of Object.keys(catalog)) { + if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { + catalog[name] = VITEST_VERSION; + } + } + } } function rewriteCatalogsObject( catalogs: Record>, usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, ): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false, usesVitest); + rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); } } @@ -3241,7 +3287,11 @@ function ensureBunfigPeerSuppression(projectPath: string): void { * unlike pnpm which uses pnpm-workspace.yaml. * @see https://bun.sh/docs/pm/catalogs */ -function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { +function rewriteBunCatalog( + projectPath: string, + usesVitest: boolean, + vitestEcosystemPackages: ReadonlySet, +): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; @@ -3264,30 +3314,30 @@ function rewriteBunCatalog(projectPath: string, usesVitest: boolean): void { ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - rewriteCatalogObject(catalog, true, usesVitest); + rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(catalog); if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { - rewriteCatalogObject(pkg.catalog, false, usesVitest); + rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(pkg.catalog); } } else { pkg.catalog = catalog; if (workspacesObj?.catalog) { - rewriteCatalogObject(workspacesObj.catalog, false, usesVitest); + rewriteCatalogObject(workspacesObj.catalog, false, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(workspacesObj.catalog); } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs, usesVitest); + rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs, usesVitest); + rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -3920,22 +3970,66 @@ function pnpmConfigLivesInPackageJson(pkg: BootstrapPackageJson, projectPath: st return Object.hasOwn(pkg.pnpm, 'overrides') || Object.hasOwn(pkg.pnpm, 'peerDependencyRules'); } -// Pin every alignable `@vitest/*` package the project lists to the bundled -// vitest version. Returns true if any spec changed. These are plain dependency -// entries (not overrides), so this is package-manager agnostic. -function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { +function getAlignedVitestEcosystemDependencySpec( + current: string, + dependencyName: string, + dependencyField: PackageJsonDependencyField, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): string { + const catalogSpec = current.startsWith('catalog:') ? current : 'catalog:'; + const catalogSupported = + supportCatalog && catalogDependencyResolver?.(catalogSpec, dependencyName) !== undefined; + return getCatalogDependencySpec(current, VITEST_VERSION, catalogSupported, { + dependencyField, + dependencyName, + packageManager, + catalogDependencyResolver, + }); +} + +// Align every declared official `@vitest/*` package with the bundled Vitest. +// Prefer an existing default or named catalog entry when the package manager +// supports catalogs; otherwise use the concrete bundled version. Returns true +// if any package.json spec changed. Catalog values are reconciled separately by +// the package-manager config writers above. +function alignVitestEcosystemPackages( + pkg: BootstrapPackageJson, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { if (!VITEST_IS_MANAGED_OVERRIDE) { return false; } - const dependencyGroups = [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]; + const dependencyGroups: Array<{ + dependencyField: PackageJsonDependencyField; + dependencies: Record | undefined; + }> = [ + { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies', dependencies: pkg.dependencies }, + { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, + ]; let changed = false; - for (const dependencies of dependencyGroups) { + for (const { dependencyField, dependencies } of dependencyGroups) { if (!dependencies) { continue; } for (const name of Object.keys(dependencies)) { - if (isAlignableVitestEcosystemPackage(name) && dependencies[name] !== VITEST_VERSION) { - dependencies[name] = VITEST_VERSION; + if (!isAlignableVitestEcosystemPackage(name)) { + continue; + } + const aligned = getAlignedVitestEcosystemDependencySpec( + dependencies[name], + name, + dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); + if (dependencies[name] !== aligned) { + dependencies[name] = aligned; changed = true; } } @@ -3943,6 +4037,30 @@ function alignVitestEcosystemPackages(pkg: BootstrapPackageJson): boolean { return changed; } +function vitestEcosystemCatalogReferencesPending( + pkg: BootstrapPackageJson, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!VITEST_IS_MANAGED_OVERRIDE || !catalogDependencyResolver) { + return false; + } + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + if (!dependencies) { + continue; + } + for (const [name, spec] of Object.entries(dependencies)) { + if ( + isAlignableVitestEcosystemPackage(name) && + spec.startsWith('catalog:') && + catalogDependencyResolver(spec, name) !== VITEST_VERSION + ) { + return true; + } + } + } + return false; +} + /** * Reconcile the install dependencies in one package during an existing-Vite+ * bootstrap. Package-manager overrides are intentionally handled separately at @@ -3990,7 +4108,7 @@ function reconcileVitePlusBootstrapPackage( } } - alignVitestEcosystemPackages(pkg); + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); normalizeVitestPeerCatalogSpec(pkg.peerDependencies, catalogDependencyResolver); const providerSourceModes = collectProviderSourceModes(projectPath); @@ -4003,12 +4121,24 @@ function reconcileVitePlusBootstrapPackage( continue; } usesAnyOptInProvider = true; - const installGroup = installGroups.find( - (dependencies) => dependencies?.[provider] !== undefined, - ); - if (installGroup) { + const installGroupEntry = [ + { dependencyField: 'devDependencies' as const, dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies' as const, dependencies: pkg.dependencies }, + { + dependencyField: 'optionalDependencies' as const, + dependencies: pkg.optionalDependencies, + }, + ].find(({ dependencies }) => dependencies?.[provider] !== undefined); + if (installGroupEntry?.dependencies) { if (VITEST_IS_MANAGED_OVERRIDE) { - installGroup[provider] = VITEST_VERSION; + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); } } else { pkg.devDependencies ??= {}; @@ -4085,6 +4215,45 @@ function bootstrapProjectPaths( return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } +function collectVitestEcosystemInstallDependencyNames( + rootDir: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + for (const packagePath of bootstrapProjectPaths(rootDir, packages)) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as BootstrapPackageJson; + for (const dependencies of [pkg.devDependencies, pkg.dependencies, pkg.optionalDependencies]) { + for (const name of Object.keys(dependencies ?? {})) { + if (isAlignableVitestEcosystemPackage(name)) { + names.add(name); + } + } + } + } + return names; +} + +function workspaceVitestEcosystemCatalogReferencesPending( + rootDir: string, + packages: WorkspacePackage[] | undefined, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + return bootstrapProjectPaths(rootDir, packages).some((packagePath) => { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + return vitestEcosystemCatalogReferencesPending( + readJsonFile(packageJsonPath) as BootstrapPackageJson, + catalogDependencyResolver, + ); + }); +} + export function detectVitePlusBootstrapPending( projectPath: string, packageManager: PackageManager | undefined, @@ -4118,6 +4287,15 @@ export function detectVitePlusBootstrapPending( packageManager === PackageManager.bun); const canonicalVitePlusSpec = supportCatalog ? 'catalog:' : VITE_PLUS_VERSION; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); + if ( + workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + packages, + catalogDependencyResolver, + ) + ) { + return true; + } for (const [index, packagePath] of bootstrapProjectPaths(projectPath, packages).entries()) { const childPackageJsonPath = path.join(packagePath, 'package.json'); if (!fs.existsSync(childPackageJsonPath)) { @@ -4343,6 +4521,15 @@ export function ensureVitePlusBootstrap( projectPath, workspaceInfo.packageManager, ); + const ecosystemCatalogReferencesPending = workspaceVitestEcosystemCatalogReferencesPending( + projectPath, + workspaceInfo.packages, + catalogDependencyResolver, + ); + const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames( + projectPath, + workspaceInfo.packages, + ); editJsonFile< BootstrapPackageJson & { @@ -4443,6 +4630,7 @@ export function ensureVitePlusBootstrap( const catalogDependencyResolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath); if ( result.packageJson || + ecosystemCatalogReferencesPending || !pnpmWorkspaceExoticSubdepsSettingSatisfied(projectPath) || defaultCatalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus( @@ -4460,6 +4648,7 @@ export function ensureVitePlusBootstrap( pnpmMajorVersion, shouldAllowBrowserBuilds, usesVitest, + vitestEcosystemPackages, ); } if (fs.existsSync(pnpmWorkspaceYamlPath)) { @@ -4478,12 +4667,12 @@ export function ensureVitePlusBootstrap( const before = fs.existsSync(yarnrcYmlPath) ? fs.readFileSync(yarnrcYmlPath, 'utf-8') : undefined; - rewriteYarnrcYml(projectPath, usesVitest); + rewriteYarnrcYml(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(yarnrcYmlPath, 'utf-8'); result.packageManagerConfig = before !== after; } else if (workspaceInfo.packageManager === PackageManager.bun) { const before = fs.readFileSync(packageJsonPath, 'utf-8'); - rewriteBunCatalog(projectPath, usesVitest); + rewriteBunCatalog(projectPath, usesVitest, vitestEcosystemPackages); const after = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageJson = result.packageJson || before !== after; } @@ -4937,7 +5126,7 @@ export function rewritePackageJson( // every declared official @vitest/* package on the bundled version during a // fresh migration too; existing-Vite+ upgrades use the same rule in the // bootstrap path. - alignVitestEcosystemPackages(pkg); + alignVitestEcosystemPackages(pkg, packageManager, supportCatalog, catalogDependencyResolver); // Force-override mode (ecosystem CI / `vp create` E2E) must re-pin any // pre-existing `vite-plus` range to the local tgz. Otherwise pnpm reads the // published vite-plus metadata for transitive dep resolution (e.g. @@ -4993,12 +5182,11 @@ export function rewritePackageJson( // rewritten `vite-plus/test/browser-` import to resolve. Unlike the // rest of the `@vitest/*` family they are deliberately NOT in // VITE_PLUS_OVERRIDE_PACKAGES (so projects not using a provider stay - // untouched), which means the normalization loop above does not pin them. We - // pin each used provider here, to a CONCRETE version (no catalog entry is - // written for an opt-in provider) so it self-resolves and stays aligned with - // the bundled vitest, and we ensure its runtime framework peer - // (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + - // stripped, handled in the REMOVE_PACKAGES loop above.) + // untouched), which means the normalization loop above does not add them. We + // align each installed provider here using its existing catalog when present, + // or the concrete bundled version otherwise, and ensure its runtime framework + // peer (`webdriverio` / `playwright`). (`@vitest/browser`/preview stay bundled + // + stripped, handled in the REMOVE_PACKAGES loop above.) let usesAnyOptInProvider = false; for (const provider of OPT_IN_BROWSER_PROVIDERS) { const usesProvider = @@ -5013,12 +5201,20 @@ export function rewritePackageJson( // resolve. Normalize an existing install-group declaration to the bundled // vitest version in place (the override loop above no longer pins it); // otherwise — a source-only or peer-only user — inject it into devDeps. - const installGroup = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].find( - (deps) => deps?.[provider] !== undefined, + const installGroupEntry = dependencyGroups.find( + ({ dependencyField, dependencies }) => + dependencyField !== 'peerDependencies' && dependencies?.[provider] !== undefined, ); - if (installGroup) { + if (installGroupEntry?.dependencies) { if (VITEST_IS_MANAGED_OVERRIDE) { - installGroup[provider] = VITEST_VERSION; + installGroupEntry.dependencies[provider] = getAlignedVitestEcosystemDependencySpec( + installGroupEntry.dependencies[provider], + provider, + installGroupEntry.dependencyField, + packageManager, + supportCatalog, + catalogDependencyResolver, + ); } } else { pkg.devDependencies ??= {}; From 4246db4a85951078660c6e8da4c12b2095d50268 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 11:51:06 +0800 Subject: [PATCH 28/32] fix(migrate): pin vite-plus toolchain versions --- .../migration-add-git-hooks/snap.txt | 4 +- .../migration-already-vite-plus/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-baseurl-tsconfig/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-composed-husky-prepare/snap.txt | 4 +- .../migration-env-prefix-lint-staged/snap.txt | 4 +- .../migration-eslint-lint-staged/snap.txt | 4 +- .../migration-eslint-lintstagedrc/snap.txt | 4 +- .../migration-eslint-npx-wrapper/snap.txt | 4 +- .../migration-eslint/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-existing-husky/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-from-tsdown/snap.txt | 4 +- .../migration-from-vitest-config/snap.txt | 4 +- .../migration-from-vitest-files/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-husky-catalog-version/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-husky-latest-dist-tag/snap.txt | 4 +- .../migration-husky-or-prepare/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-lazy-plugins-await/snap.txt | 4 +- .../migration-lint-staged-in-scripts/snap.txt | 4 +- .../migration-lint-staged-merge-fail/snap.txt | 4 +- .../migration-lint-staged-ts-config/snap.txt | 4 +- .../migration-lintstagedrc-json/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-merge-vite-config-js/snap.txt | 4 +- .../migration-merge-vite-config-ts/snap.txt | 4 +- .../migration-monorepo-bun/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-monorepo-pnpm/snap.txt | 4 +- .../migration-monorepo-yarn4/snap.txt | 6 +-- .../migration-no-git-repo/snap.txt | 4 +- .../migration-no-hooks-with-husky/snap.txt | 4 +- .../migration-no-hooks/snap.txt | 4 +- .../migration-other-hook-tool/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-oxlintrc-jsonc/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../migration-prettier-eslint-combo/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-prettier-lint-staged/snap.txt | 4 +- .../migration-prettier-pkg-json/snap.txt | 4 +- .../migration-prettier/snap.txt | 4 +- .../migration-rewrite-declare-module/snap.txt | 4 +- .../migration-skip-vite-dependency/snap.txt | 4 +- .../snap.txt | 4 +- .../migration-standalone-pnpm/snap.txt | 4 +- .../snap.txt | 7 ++-- .../migration-subpath/snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../snap.txt | 4 +- .../snap.txt | 2 +- .../snap.txt | 4 +- .../snap.txt | 6 +-- .../snap.txt | 4 +- .../snap.txt | 5 +-- .../snap.txt | 5 +-- .../migration-vite-version/snap.txt | 4 +- .../migration-vitest-import-only/snap.txt | 4 +- .../migration-vitest-peer-dep/snap.txt | 4 +- .../snap.txt | 3 +- .../new-vite-monorepo-bun/snap.txt | 4 +- .../new-vite-monorepo/snap.txt | 4 +- .../create-approve-builds-bun/snap.txt | 18 ++++----- .../snap.txt | 12 +++--- .../create-approve-builds-pnpm11/snap.txt | 12 +++--- .../create-approve-builds-yarn/snap.txt | 4 +- .../create-org-bundled-monorepo/snap.txt | 4 +- .../src/migration/__tests__/migrator.spec.ts | 9 ++++- .../cli/src/utils/__tests__/constants.spec.ts | 37 +++++++++++++++++++ packages/cli/src/utils/constants.ts | 6 ++- packages/tools/src/utils.ts | 8 ++++ rfcs/migrate-existing-projects.md | 2 +- 97 files changed, 258 insertions(+), 214 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/constants.spec.ts diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index f59ebbe259..6cc0357e2d 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt index ccefd20e87..110719bbfb 100644 --- a/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt @@ -12,10 +12,10 @@ { "name": "migration-already-vite-plus", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 6483161a6f..4884e5536a 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -56,8 +56,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 553bbf5680..5dbd5e2f13 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -59,8 +59,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index 43f904efba..08ac7a6659 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index 9077f28eb6..6fd81f4a5a 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index ee4d3f501a..b1fec8a9b4 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 2351276fc8..514f67d59b 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index 8f84735ba4..c43662a1f6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index 4ac2df74b8..6533b25c32 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index cfb60af6e8..527ec413a8 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -31,8 +31,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index a6795c9c48..4c46bc311c 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index 5bfc30a07b..ad7c85413a 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 7d7556c838..95ddbf983b 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index 1bd78cc26e..50ff3e3a31 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index 625779fd04..da674c99f3 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index 8ca67d4068..71d8b3ca22 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index 1e8305dbd6..b4d6dd5099 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index df00607092..2515239baa 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index dff04522cd..cf858af8ea 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -51,8 +51,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 1045b7499e..85684a25b1 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -53,8 +53,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index 9a6c718500..d1e01f3cd9 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt @@ -59,9 +59,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: allowBuilds: edgedriver: true geckodriver: true diff --git a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt index a72ade1a4e..c66403539c 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt @@ -32,9 +32,9 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 5dd710ab9f..758f2bd08c 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 132c4aff73..d898c5e5fd 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -34,8 +34,8 @@ packages: catalog: husky: ^9.1.7 lint-staged: ^16.2.6 - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index 553fb5694d..395807de91 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index fa4e63bf77..9c7b70b6a5 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -28,8 +28,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index a5cec54506..72d9aa6a38 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index f8da4e6f7a..8502bb4afe 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index acac9f1da0..89cc040a7f 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -32,8 +32,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt index bbdf28e64a..84dc9c9e94 100644 --- a/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt +++ b/packages/cli/snap-tests-global/migration-lazy-plugins-await/snap.txt @@ -35,8 +35,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index 7960ec688f..1c21e8be94 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index 360d056826..24d8e4eb9f 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -35,8 +35,8 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 4d1ecec73f..3fab705ccf 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -30,8 +30,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index 5a82a70583..f19be992ec 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -99,8 +99,8 @@ Documentation: https://viteplus.dev/guide/migrate > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index bd3f0f4a87..ddc3c2228e 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -32,8 +32,8 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 9154c73bdb..338e17c6f7 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -43,8 +43,8 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index 6d03dc48a1..9f39a98e4a 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index f2ba7beff2..c41684e788 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -57,8 +57,8 @@ export default { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt index d63ee649df..f3958fbf7c 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt @@ -91,9 +91,9 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt index 8a36eae8d9..e9aa907d74 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-bun/snap.txt @@ -44,9 +44,9 @@ export default defineConfig({ "packages/*" ], "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "", - "vite-plus": "latest" + "vite-plus": "" } }, "scripts": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt index ec987b96f3..7f09fd6b80 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt @@ -38,8 +38,8 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: '@vitejs/plugin-react>vite': 'npm:vite@' diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 4181032dc4..476de1b6be 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -82,9 +82,9 @@ packages: catalog: testnpm2: ^1.0.0 # test comment here to check if the comment is preserved - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: minimumReleaseAge: 1440 overrides: diff --git a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt index f31071a5a8..2e8b54bad1 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt @@ -70,7 +70,7 @@ export default defineConfig({ }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" } } @@ -81,9 +81,9 @@ npmPreapprovedPackages: - vitest - '@vitest/*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: > cat packages/app/package.json # check app package.json { diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index 39fbe1bd62..2d01e02028 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -24,8 +24,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index 7299b0296f..cbdf2664ae 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -31,8 +31,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index f9dcc0b68b..d00770f2f9 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -22,8 +22,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index f741059b24..2a78d94c6e 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -34,8 +34,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index 458a11d2f3..6ecb58bcb5 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -54,8 +54,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index 1da41f704e..0fc7d99567 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -56,8 +56,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt index 8b8b296ed1..83b5c2b3f1 100644 --- a/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-installed-vite-plus/snap.txt @@ -41,8 +41,8 @@ > cat pnpm-workspace.yaml # pnpm overrides and peerDependencyRules should be configured catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index efae8980d3..4c73dac2ae 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -29,8 +29,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index aab95fdf19..f4dfa42963 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -33,8 +33,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index 7b49f4cde1..7baeaff783 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -31,8 +31,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index c7ef71ca50..d14673abd6 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -28,8 +28,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index b199e2a1b1..3353b0422d 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -29,8 +29,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index 82bc36c019..98486f0ebc 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -31,8 +31,8 @@ Prettier configuration detected. Auto-migrating to Oxfmt... > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 670aa286d9..ff0de16915 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -55,9 +55,9 @@ declare module 'vitest/config' { > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index 042f2820f0..5ff6f9ab99 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -49,8 +49,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index ecce492eef..2cacaef26b 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -49,8 +49,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 244b0c7f87..0c6b49172b 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -16,8 +16,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt index 4f5d245b29..432e3c8c74 100644 --- a/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-yarn4-idempotent/snap.txt @@ -21,7 +21,7 @@ }, "packageManager": "yarn@", "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" } } @@ -31,8 +31,8 @@ npmPreapprovedPackages: - vitest - '@vitest/*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: > cat example.spec.ts # ordinary Vitest imports use the Vite+ public surface import { expect, it } from 'vite-plus/test'; @@ -41,4 +41,3 @@ it('works', () => expect(true).toBe(true)); > vp migrate --no-interactive # a freshly migrated standalone Yarn project is complete This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index b3a54aeb9c..fe0f5928bb 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -43,8 +43,8 @@ core.hooksPath is not set > cat foo/pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index 00262e60ed..5840a8b3a4 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -46,8 +46,8 @@ export default defineConfig({ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt index 51b2bb428e..72a860833a 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-peer-only-pnpm/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # promoted provider keeps shared Vitest management catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' @@ -42,4 +42,3 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt index 2e020716a1..70b04086d7 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-source-only-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # shared vitest catalog and override should be present catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt index e5f69418c0..b4da107e9f 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-browser-webdriverio-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # driver builds and shared vitest should be enabled catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt index 5d1d0d9b1c..2f2872b2b8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-deprecated-coverage-c8-npm/snap.txt @@ -8,11 +8,11 @@ "name": "migration-upgrade-deprecated-coverage-c8-npm", "devDependencies": { "@vitest/coverage-c8": "^0.33.0", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt index a790df7831..614252bdef 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-monorepo-vitest-localized-pnpm/snap.txt @@ -33,8 +33,8 @@ packages: - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: overrides: vite: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt index d170208fe3..4454394ee9 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nested-vitest-override-npm/snap.txt @@ -6,10 +6,10 @@ This project is already using Vite+! Happy coding! { "name": "migration-upgrade-nested-vitest-override-npm", "devDependencies": { - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": { "@vitest/runner": "" } @@ -25,4 +25,3 @@ This project is already using Vite+! Happy coding! > vp migrate --no-interactive # nested override must not make migration permanently pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt index e43f57906d..b5b7ef9636 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils-monorepo/snap.txt @@ -27,9 +27,9 @@ packages: - packages/* catalog: - vite-plus: latest + vite-plus: vitest: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ overrides: vite: 'catalog:' @@ -65,4 +65,3 @@ void expect; > vp migrate --no-interactive # workspace result is idempotent This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt index f0fca66ab3..c322569898 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-nuxt-test-utils/snap.txt @@ -10,11 +10,11 @@ "name": "migration-upgrade-nuxt-test-utils", "devDependencies": { "@nuxt/test-utils": "file:.fixture/nuxt-test-utils", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -43,4 +43,3 @@ expect(true).toBe(true); > vp migrate --no-interactive # the package-level compatibility result is idempotent This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt index 61c5a2be7b..1102051ca6 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-peer-vitest-catalog-pnpm/snap.txt @@ -23,8 +23,8 @@ > cat pnpm-workspace.yaml # unreferenced managed Vitest catalog is removed catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: catalogs: test: {} overrides: @@ -37,4 +37,3 @@ peerDependencyRules: > vp migrate --no-interactive # repaired project should no longer be pending This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt index fa679f6ff3..bd06ee6529 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-required-vitest-peer-metadata-npm/snap.txt @@ -8,11 +8,11 @@ "name": "migration-upgrade-required-vitest-peer-metadata-npm", "devDependencies": { "vite-plugin-gherkin": "0.2.0", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -27,4 +27,3 @@ > node -e "const fs = require('node:fs'); fs.mkdirSync('node_modules', { recursive: true }); fs.cpSync('.fixture/vite-plugin-gherkin', 'node_modules/vite-plugin-gherkin', { recursive: true })" # simulate installed dependency metadata > vp migrate --no-interactive # metadata confirms the unnamed required Vitest peer This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt index 009a844efb..a1d7a2f1d7 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-stale-local-pnpm/snap.txt @@ -30,5 +30,5 @@ peerDependencyRules: allowedVersions: vite: '*' catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: diff --git a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt index 2b67a8c5e1..2edd4a9266 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vite-plus-protocol-pin-npm/snap.txt @@ -10,7 +10,7 @@ "vite-plus": "file:../custom-vite-plus" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt index 06b21d930c..86fcab4b69 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-npm/snap.txt @@ -12,11 +12,11 @@ "@vitest/ui": "", "@vitest/utils": "", "@vitest/web-worker": "", - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt index 1a2c62b558..8a24282c5c 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-exact-peer-yarn4/snap.txt @@ -16,7 +16,7 @@ "vitest": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -31,8 +31,8 @@ > cat .yarnrc.yml # linker conversion and aligned Vitest catalog are persisted nodeLinker: node-modules catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: vitest: npmPreapprovedPackages: - vitest diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt index c2ec356064..4698aff3e8 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-non-runtime-only-npm/snap.txt @@ -10,10 +10,10 @@ "@vitest/eslint-plugin": "^1.6.0", "@vitest/utils": "", "@vitest/ws-client": "", - "vite-plus": "latest" + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt index be3bfa3b44..1b5bce1af1 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-reference-whitespace-pnpm/snap.txt @@ -27,8 +27,8 @@ > cat pnpm-workspace.yaml # rewritten directive does not retain shared Vitest management catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -39,4 +39,3 @@ peerDependencyRules: > vp migrate --no-interactive # directive rewriting is stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt index d58ec2197c..3269c7bb16 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-vitest-retained-references-npm/snap.txt @@ -7,11 +7,11 @@ { "name": "migration-upgrade-vitest-retained-references-npm", "devDependencies": { - "vite-plus": "latest", + "vite-plus": "", "vitest": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite": "npm:@voidzero-dev/vite-plus-core@", "vitest": "" }, "devEngines": { @@ -47,4 +47,3 @@ console.log(metadata.version); > vp migrate --no-interactive # retained references remain stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 31d179e307..c729e9012e 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -26,8 +26,8 @@ > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt index e51c39c3e3..ab2d6feba2 100644 --- a/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-import-only/snap.txt @@ -32,8 +32,8 @@ it('works', () => { > cat pnpm-workspace.yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: diff --git a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt index e6e3ef859a..930fcd96ff 100644 --- a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt @@ -29,9 +29,9 @@ > cat pnpm-workspace.yaml catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest + vite: npm:@voidzero-dev/vite-plus-core@ vitest: - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' vitest: 'catalog:' diff --git a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt index 0369832eb5..052cde0151 100644 --- a/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-unmanaged-override/snap.txt @@ -29,7 +29,7 @@ > cat pnpm-workspace.yaml # no vitest catalog or override should be introduced catalog: vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite-plus: overrides: vite: 'catalog:' peerDependencyRules: @@ -40,4 +40,3 @@ peerDependencyRules: > vp migrate --no-interactive # unmanaged Vitest ecosystem versions remain stable on rerun This project is already using Vite+! Happy coding! - diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 4d6f94a18d..e5dff2a89c 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -43,8 +43,8 @@ vite.config.ts "node": ">=22.18.0" }, "catalog": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" } } diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 465d62b5d8..a12fa92c4b 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -64,8 +64,8 @@ catalogMode: prefer catalog: "@types/node": ^24 typescript: ^5 - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 6ab57ecfe0..0a0534d8ea 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -16,11 +16,11 @@ "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -56,11 +56,11 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -91,11 +91,11 @@ bun pm trust v () "core-js": "3.39.0" }, "devDependencies": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest", - "vite-plus": "latest" + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" }, "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index c456d18458..49097fcdb8 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -10,8 +10,8 @@ Prettier detected in workspace packages but no root config found. Package-level allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -36,8 +36,8 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -54,8 +54,8 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index c4f467fee6..0596ddd334 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -8,8 +8,8 @@ allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -32,8 +32,8 @@ These dependencies may not work until built. Run vp pm approve-builds in the pro allowBuilds: core-js: set this to true or false catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: @@ -50,8 +50,8 @@ peerDependencyRules: allowBuilds: core-js: true catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 09e34b0952..497b533e1c 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -24,7 +24,7 @@ } }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { @@ -60,7 +60,7 @@ These dependencies may not work until built. Enable them in the workspace root p "vite-plus": "catalog:" }, "resolutions": { - "vite": "npm:@voidzero-dev/vite-plus-core@latest" + "vite": "npm:@voidzero-dev/vite-plus-core@" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index cc31c0c256..b256a7ba2b 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -25,8 +25,8 @@ packages: - apps/* - packages/* catalog: - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: overrides: vite: "catalog:" peerDependencyRules: diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index a976dc57ce..baed18b04d 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -14,7 +14,14 @@ import { createMigrationReport } from '../report.js'; // which would cause snapshot mismatches. vi.mock('../../utils/constants.js', async (importOriginal) => { const mod = await importOriginal(); - return { ...mod, VITE_PLUS_VERSION: 'latest' }; + return { + ...mod, + VITE_PLUS_VERSION: 'latest', + VITE_PLUS_OVERRIDE_PACKAGES: { + ...mod.VITE_PLUS_OVERRIDE_PACKAGES, + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + }, + }; }); const { diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts new file mode 100644 index 0000000000..5a2b514ef1 --- /dev/null +++ b/packages/cli/src/utils/__tests__/constants.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import cliPkg from '../../../package.json' with { type: 'json' }; + +describe('Vite+ dependency versions', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('uses the concrete CLI version for vite-plus and vite-plus-core by default', async () => { + vi.stubEnv('VP_VERSION', ''); + vi.stubEnv('VP_OVERRIDE_PACKAGES', ''); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(cliPkg.version); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe( + `npm:@voidzero-dev/vite-plus-core@${cliPkg.version}`, + ); + }); + + it('preserves explicit prerelease overrides', async () => { + const vitePlusUrl = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; + const viteCoreUrl = + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + vi.stubEnv('VP_VERSION', vitePlusUrl); + vi.stubEnv('VP_OVERRIDE_PACKAGES', JSON.stringify({ vite: viteCoreUrl, vitest: '4.1.9' })); + vi.resetModules(); + + const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js'); + + expect(VITE_PLUS_VERSION).toBe(vitePlusUrl); + expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(viteCoreUrl); + }); +}); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 2ae6ba9c52..d7a3810a6f 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,14 +1,16 @@ import { createRequire } from 'node:module'; +import cliPkg from '../../package.json' with { type: 'json' }; + export const VITE_PLUS_NAME = 'vite-plus'; -export const VITE_PLUS_VERSION = process.env.VP_VERSION || 'latest'; +export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version; export const VITEST_VERSION = '4.1.9'; export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES) : { - vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_VERSION}`, // Pin `vitest` only. The `@vitest/*` family (expect, runner, snapshot, spy, // utils, mocker, pretty-format) are EXACT (`4.1.9`) dependencies of `vitest` // itself, so a single `vitest` override cascades one consistent version to diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 597b47aaf7..d99bb96ef6 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -73,6 +73,14 @@ export function replaceUnstableOutput(output: string, cwd?: string) { /("(?:vitest|@vitest\/(?!coverage-)[\w-]+)": ")(?:[4-9]|[1-9]\d+)\.\d+\.\d+(?:-[\w.]+)?(")/g, '$1$2', ) + // Vite+ and its core package are written as exact lockstep versions by + // create/migrate. Mask JSON dependency values so release bumps do not + // create unrelated snapshot churn (YAML values and npm aliases are + // already covered by the generic semver normalization above). + .replaceAll( + /("(?:vite-plus|@voidzero-dev\/vite-plus-core)": ")\d+\.\d+\.\d+(?:-[\w.]+)?(")/g, + '$1$2', + ) // devEngines.packageManager auto-pin writes the exact resolved version // e.g.: `"name": "pnpm",\n "version": "11.5.1"` -> `"version": ""` // (the optional suffix covers prerelease and build metadata: -rc-1, +sha.abc) diff --git a/rfcs/migrate-existing-projects.md b/rfcs/migrate-existing-projects.md index c394fc603a..2d32d616ab 100644 --- a/rfcs/migrate-existing-projects.md +++ b/rfcs/migrate-existing-projects.md @@ -34,7 +34,7 @@ When PnP is active, interactive migration prints the incompatibility and asks wh | Routing | If the project's local `vite-plus` is older than the global `vp`, run `migrate` from the global CLI; otherwise keep local-first. | | Yarn linker | Vite+ does not currently support Yarn PnP. Detect explicit and implicit PnP before migration, ask to switch to `nodeLinker: node-modules`, and continue only after conversion. Non-interactive migration accepts this conversion by default. | | `vite-plus` spec | Re-pin a non-protocol-pinned spec (e.g. `^0.1.24`) to the toolchain target (`catalog:` in catalog projects, else the version) so the lockfile moves off the old resolution. Preserve deliberate protocol pins (`workspace:`/`file:`/`link:`/`npm:`/...). | -| `vite` override | Always managed: alias `vite` to `npm:@voidzero-dev/vite-plus-core@latest` in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | +| `vite` override | Always managed: alias `vite` to the concrete `@voidzero-dev/vite-plus-core` version matching the migrating `vite-plus` release in whatever override/resolution/catalog form the project uses; normalize a behind `core@` alias. | | `vitest` itself (default) | Provided by `vite-plus`, so by default not project-managed: remove any project-level `vitest` from dependency fields, string-valued `overrides`/`resolutions`/`pnpm.overrides`, `pnpm-workspace.yaml` `overrides`+`catalog(s)`, bun/yarn catalog, and the `vitest` entry in pnpm `peerDependencyRules`. Resolve a surviving `peerDependencies.vitest` catalog reference to its public range before pruning the catalog. A future `vp update vite-plus` then keeps it correct with no project pin to drift. | | `vitest`, peer/browser/Nuxt exception | Keep a managed `vitest` in the package that needs it (add to `devDependencies` and pin/override it to the bundled version) when that package directly installs a required-`vitest` peer consumer, uses browser mode, retains a direct upstream `vitest` package reference, or declares `@nuxt/test-utils`. Required peers are detected from installed package metadata, not package names alone, so integrations such as `vite-plugin-gherkin` are covered. When that metadata is unavailable in a clean checkout, preserve an existing direct Vitest conservatively. Other retained references include module augmentations, nested or root `compilerOptions.types`, `require.resolve` / `import.meta.resolve`, and the intentionally unre-written `vitest/package.json` export. In a Nuxt test-utils package, all `vitest` and `vitest/*` specifiers remain upstream consistently; in other packages, rewriteable imports and triple-slash directives do not leave a lasting pin. The direct dependency satisfies strict peer resolution; the shared override collapses the workspace to the bundled version. | | `vitest` ecosystem packages | When Vitest is managed, align current lockstep `@vitest/*` packages the project lists (`@vitest/coverage-v8`, `@vitest/coverage-istanbul`, `@vitest/ui`, `@vitest/web-worker`, ...) to the bundled `VITEST_VERSION`. Exclude `@vitest/eslint-plugin` (separate version line, optional `vitest: *` peer) and deprecated `@vitest/coverage-c8` (last published at `0.33.0`; no Vitest 4 release exists). When `VP_OVERRIDE_PACKAGES` omits Vitest, skip ecosystem alignment so user-owned exact-peer versions stay compatible. Browser packages keep their dedicated handling: `@vitest/browser` / `-preview` are bundled by `vite-plus`; `@vitest/browser-playwright` / `-webdriverio` are opt-in (pinned + framework peer kept). | From bc22b5547c7918de3898b83205e5dfe7d8b58155 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:20:07 +0800 Subject: [PATCH 29/32] fix(test): reuse unchanged pkg.pr.new install --- .github/scripts/test-pkg-pr-new-migrate.sh | 108 +++++++++++++++------ 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 8419dfe450..92df766efd 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -66,42 +66,100 @@ original_home="$HOME" cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" +cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" +vp_bin="$pr_home/bin/vp" +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +commit_marker="$cached_version_dir/.pkg-pr-new-commit" +pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" +vite_plus_spec="$pkg_pr_new_base@$pr_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" -# Numeric pkg.pr.new references are mutable PR aliases. The installer reuses a -# version directory named after the reference, so its lockfile can retain the -# checksum from an older publish of the same PR and fail with -# ERR_PNPM_TARBALL_INTEGRITY after the alias is refreshed. Keep the downloaded -# runtime/package-manager cache, but force the wrapper dependency to resolve -# and install again for every PR-alias run. Commit SHA references are immutable -# and can safely retain their installed dependency state. -case "$pr_ref" in - *[!0-9]*) ;; - *) - cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" - rm -rf "$cached_version_dir/node_modules" - rm -f "$cached_version_dir/pnpm-lock.yaml" - ;; -esac +resolve_pkg_pr_new_commit() { + curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' ' + tolower($1) == "x-commit-key" { + count = split($2, parts, ":") + print parts[count] + exit + } + ' +} + +read_installed_commit() { + if [ -f "$commit_marker" ]; then + head -n 1 "$commit_marker" + return + fi + + if [ -f "$vite_plus_package_json" ]; then + awk -F '"' ' + $2 == "@voidzero-dev/vite-plus-core" { + value = $4 + sub(/^.*@/, "", value) + print value + exit + } + ' "$vite_plus_package_json" + fi +} + +available_commit="$(resolve_pkg_pr_new_commit || true)" +installed_commit="$(read_installed_commit || true)" +current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" +reuse_install=0 + +if [ -n "$available_commit" ] && + [ "$installed_commit" = "$available_commit" ] && + [ "$current_target" = "pkg-pr-new-$pr_ref" ] && + [ -x "$vp_bin" ] && + [ -f "$vite_plus_package_json" ]; then + reuse_install=1 +fi cleanup() { rm -rf "$installer_home" } trap cleanup EXIT -echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" -HOME="$installer_home" \ - VP_HOME="$pr_home" \ - VP_PR_VERSION="$pr_ref" \ - VP_NODE_MANAGER=no \ - bash "$installer" +if [ "$reuse_install" -eq 1 ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home" +else + if [ -z "$available_commit" ]; then + echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref." + elif [ -n "$installed_commit" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $available_commit" + fi + + # Numeric pkg.pr.new references are mutable PR aliases. If the published + # commit changed, the reused lockfile can retain the checksum from the older + # tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded + # runtime/package-manager cache, but force the wrapper dependency to resolve + # again. Commit SHA references are immutable and use their own cache path. + case "$pr_ref" in + *[!0-9]*) ;; + *) + rm -rf "$cached_version_dir/node_modules" + rm -f "$cached_version_dir/pnpm-lock.yaml" + ;; + esac + + echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" + HOME="$installer_home" \ + VP_HOME="$pr_home" \ + VP_PR_VERSION="$pr_ref" \ + VP_NODE_MANAGER=no \ + bash "$installer" + + if [ -n "$available_commit" ]; then + printf '%s\n' "$available_commit" > "$commit_marker" + fi +fi -vp_bin="$pr_home/bin/vp" if [ ! -x "$vp_bin" ]; then echo "error: installed vp executable not found: $vp_bin" >&2 exit 1 fi -vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" if [ ! -f "$vite_plus_package_json" ]; then echo "error: installed vite-plus package not found: $vite_plus_package_json" >&2 exit 1 @@ -113,10 +171,6 @@ if [ -z "$vitest_version" ]; then exit 1 fi -pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" -vite_plus_spec="$pkg_pr_new_base@$pr_ref" -vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" - export VP_HOME="$pr_home" export PATH="$VP_HOME/bin:$PATH" export VP_VERSION="$vite_plus_spec" From b2c220de2ebe7025c0aa9a142e6866d8134671fa Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:40:52 +0800 Subject: [PATCH 30/32] fix(test): run pkg.pr.new migration from project root --- .github/scripts/test-pkg-pr-new-migrate.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 92df766efd..0a0c607f5c 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -69,6 +69,7 @@ installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" vp_bin="$pr_home/bin/vp" vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" commit_marker="$cached_version_dir/.pkg-pr-new-commit" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" vite_plus_spec="$pkg_pr_new_base@$pr_ref" @@ -111,7 +112,8 @@ if [ -n "$available_commit" ] && [ "$installed_commit" = "$available_commit" ] && [ "$current_target" = "pkg-pr-new-$pr_ref" ] && [ -x "$vp_bin" ] && - [ -f "$vite_plus_package_json" ]; then + [ -f "$vite_plus_package_json" ] && + [ -f "$global_cli_entry" ]; then reuse_install=1 fi @@ -165,6 +167,11 @@ if [ ! -f "$vite_plus_package_json" ]; then exit 1 fi +if [ ! -f "$global_cli_entry" ]; then + echo "error: installed Vite+ CLI entry not found: $global_cli_entry" >&2 + exit 1 +fi + vitest_version="$(awk -F '"' '$2 == "vitest" { print $4; exit }' "$vite_plus_package_json")" if [ -z "$vitest_version" ]; then echo "error: could not determine the bundled Vitest version from $vite_plus_package_json" >&2 @@ -196,14 +203,13 @@ echo " vite spec: $vite_plus_core_spec" echo echo "Running vp migrate in $project_dir" -runner_dir="$installer_home/runner" -mkdir -p "$runner_dir" set +e ( - # Resolve the CLI from an empty directory so a project-local vite-plus at the - # same semver cannot take precedence over the installed pkg.pr.new build. - cd "$runner_dir" - "$vp_bin" migrate "$project_dir" "$@" + # Run the installed JS entry directly so a project-local vite-plus at the + # same semver cannot take precedence. Keep cwd at the project root because + # project config and plugins may resolve dependencies from process.cwd(). + cd "$project_dir" + "$vp_bin" node "$global_cli_entry" migrate "$project_dir" "$@" ) migrate_status=$? set -e From 0ac4e9e06e1a849af465a16bbc4b5a62dd82323c Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:41:00 +0800 Subject: [PATCH 31/32] fix(migrate): isolate config compatibility checks --- .../package.json | 8 +++ .../snap.txt | 23 +++++++ .../steps.json | 6 ++ .../vite.config.ts | 14 ++++ .../migration/__tests__/compat-runner.spec.ts | 60 ++++++++++++++++ packages/cli/src/migration/bin.ts | 17 +---- packages/cli/src/migration/compat-protocol.ts | 1 + packages/cli/src/migration/compat-runner.ts | 68 +++++++++++++++++++ packages/cli/src/migration/compat-worker.ts | 38 +++++++++++ packages/cli/tsdown.config.ts | 1 + 10 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/package.json create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json create mode 100644 packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts create mode 100644 packages/cli/src/migration/__tests__/compat-runner.spec.ts create mode 100644 packages/cli/src/migration/compat-protocol.ts create mode 100644 packages/cli/src/migration/compat-runner.ts create mode 100644 packages/cli/src/migration/compat-worker.ts diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json new file mode 100644 index 0000000000..5c6bd19cb9 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/package.json @@ -0,0 +1,8 @@ +{ + "name": "migration-config-process-crash-isolated", + "version": "0.0.0", + "private": true, + "devDependencies": { + "vite": "^8.0.0" + } +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt new file mode 100644 index 0000000000..60cdac381a --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/snap.txt @@ -0,0 +1,23 @@ +> vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration +◇ Migrated . to Vite+ +• Node pnpm +• 1 file had imports rewritten + +> cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes +import { defineConfig } from 'vite-plus'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({ + fmt: {}, + lint: {"jsPlugins":[{"name":"vite-plus","specifier":"vite-plus/oxlint-plugin"}],"rules":{"vite-plus/prefer-vite-plus-imports":"error"},"options":{"typeAware":true,"typeCheck":true}}, +}); diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json new file mode 100644 index 0000000000..e0cef40f52 --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks 2>&1 # project config process handlers must not terminate migration", + "cat vite.config.ts # migration still rewrites the config after its compatibility probe crashes" + ] +} diff --git a/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts new file mode 100644 index 0000000000..ac019508ed --- /dev/null +++ b/packages/cli/snap-tests/migration-config-process-crash-isolated/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; + +// Models a project plugin that installs a process-level error backstop while +// its config is loaded. Re-throwing from this handler makes Node exit with code +// 7, which used to terminate `vp migrate` during its best-effort compatibility +// check instead of allowing migration to continue. +process.on('uncaughtException', (error) => { + throw error; +}); +queueMicrotask(() => { + throw new Error('simulated project config crash'); +}); + +export default defineConfig({}); diff --git a/packages/cli/src/migration/__tests__/compat-runner.spec.ts b/packages/cli/src/migration/__tests__/compat-runner.spec.ts new file mode 100644 index 0000000000..5282ad105f --- /dev/null +++ b/packages/cli/src/migration/__tests__/compat-runner.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/command.ts', () => ({ + runCommandSilently: vi.fn(), +})); + +import { runCommandSilently } from '../../utils/command.ts'; +import { checkRolldownCompatibility, ROLLDOWN_COMPAT_RESULT_PREFIX } from '../compat-runner.ts'; +import { createMigrationReport } from '../report.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +describe('checkRolldownCompatibility', () => { + beforeEach(() => { + mockRunCommandSilently.mockReset(); + }); + + it('merges warnings returned by the isolated config worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from( + `project config output\n${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['manualChunks warning'] })}\n`, + ), + stderr: Buffer.alloc(0), + }); + const report = createMigrationReport(); + + await checkRolldownCompatibility('/project', report); + + expect(report.warnings).toEqual(['manualChunks warning']); + expect(mockRunCommandSilently).toHaveBeenCalledWith({ + command: process.execPath, + args: [expect.stringMatching(/compat-worker\.js$/), '/project'], + cwd: '/project', + envs: process.env, + }); + }); + + it('skips compatibility checking when project config crashes the worker', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 7, + stdout: Buffer.from( + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: ['incomplete result'] })}\n`, + ), + stderr: Buffer.from('project config crashed'), + }); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); + + it('skips compatibility checking when the worker cannot start', async () => { + mockRunCommandSilently.mockRejectedValue(new Error('spawn failed')); + const report = createMigrationReport(); + + await expect(checkRolldownCompatibility('/project', report)).resolves.toBeUndefined(); + expect(report.warnings).toEqual([]); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 34e36837ce..da2203648a 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -45,6 +45,7 @@ import { } from '../utils/tsconfig.ts'; import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; +import { checkRolldownCompatibility } from './compat-runner.ts'; import { addFrameworkShim, checkVitestVersion, @@ -885,22 +886,6 @@ function showMigrationSummary(options: { } } -async function checkRolldownCompatibility(rootDir: string, report: MigrationReport): Promise { - try { - const { resolveConfig } = await import('../index.js'); - const { checkManualChunksCompat } = await import('./compat.js'); - // Use 'runner' configLoader to avoid Rolldown bundling the config file, - // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. - const config = await resolveConfig( - { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, - 'build', - ); - checkManualChunksCompat(config.build?.rollupOptions?.output, report); - } catch { - // Config resolution may fail — skip compatibility check silently - } -} - async function downloadSupportedPackageManager(options: { rootDir: string; packageManager: PackageManager; diff --git a/packages/cli/src/migration/compat-protocol.ts b/packages/cli/src/migration/compat-protocol.ts new file mode 100644 index 0000000000..64f8459db4 --- /dev/null +++ b/packages/cli/src/migration/compat-protocol.ts @@ -0,0 +1 @@ +export const ROLLDOWN_COMPAT_RESULT_PREFIX = 'VITE_PLUS_ROLLDOWN_COMPAT_RESULT='; diff --git a/packages/cli/src/migration/compat-runner.ts b/packages/cli/src/migration/compat-runner.ts new file mode 100644 index 0000000000..62ad62c319 --- /dev/null +++ b/packages/cli/src/migration/compat-runner.ts @@ -0,0 +1,68 @@ +import { fileURLToPath } from 'node:url'; + +import { runCommandSilently } from '../utils/command.ts'; +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { addMigrationWarning, type MigrationReport } from './report.ts'; + +export { ROLLDOWN_COMPAT_RESULT_PREFIX }; + +interface RolldownCompatibilityResult { + warnings: string[]; +} + +function parseRolldownCompatibilityResult(stdout: Buffer): RolldownCompatibilityResult | undefined { + const output = stdout.toString(); + const markerIndex = output.lastIndexOf(ROLLDOWN_COMPAT_RESULT_PREFIX); + if (markerIndex === -1) { + return undefined; + } + + const resultStart = markerIndex + ROLLDOWN_COMPAT_RESULT_PREFIX.length; + const resultEnd = output.indexOf('\n', resultStart); + const serialized = output.slice(resultStart, resultEnd === -1 ? undefined : resultEnd).trim(); + + try { + const result = JSON.parse(serialized) as Partial; + if ( + !Array.isArray(result.warnings) || + !result.warnings.every((item) => typeof item === 'string') + ) { + return undefined; + } + return { warnings: result.warnings }; + } catch { + return undefined; + } +} + +/** + * Resolve a project's Vite config in a child process before checking it for + * Rolldown-incompatible options. Config files execute arbitrary project code; + * isolating them prevents process-level handlers, explicit exits, and + * asynchronous crashes from terminating the migration itself. + */ +export async function checkRolldownCompatibility( + rootDir: string, + report: MigrationReport, +): Promise { + try { + const workerPath = fileURLToPath(new URL('./compat-worker.js', import.meta.url)); + const result = await runCommandSilently({ + command: process.execPath, + args: [workerPath, rootDir], + cwd: rootDir, + envs: process.env, + }); + + if (result.exitCode !== 0) { + return; + } + + const compatibilityResult = parseRolldownCompatibilityResult(result.stdout); + for (const warning of compatibilityResult?.warnings ?? []) { + addMigrationWarning(report, warning); + } + } catch { + // Config resolution is best-effort. Skip failures silently. + } +} diff --git a/packages/cli/src/migration/compat-worker.ts b/packages/cli/src/migration/compat-worker.ts new file mode 100644 index 0000000000..46c101e9a2 --- /dev/null +++ b/packages/cli/src/migration/compat-worker.ts @@ -0,0 +1,38 @@ +import { writeSync } from 'node:fs'; + +import { ROLLDOWN_COMPAT_RESULT_PREFIX } from './compat-protocol.ts'; +import { checkManualChunksCompat } from './compat.ts'; +import { createMigrationReport } from './report.ts'; + +async function main(): Promise { + const rootDir = process.argv[2]; + if (!rootDir) { + return; + } + + try { + const { resolveConfig } = await import('../index.js'); + // Use 'runner' configLoader to avoid Rolldown bundling the config file, + // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. + const config = await resolveConfig( + { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, + 'build', + ); + const report = createMigrationReport(); + checkManualChunksCompat(config.build?.rollupOptions?.output, report); + writeSync( + process.stdout.fd, + `${ROLLDOWN_COMPAT_RESULT_PREFIX}${JSON.stringify({ warnings: report.warnings })}\n`, + ); + } catch { + // Config resolution may fail — skip compatibility checking silently. + } +} + +// Config plugins may leave active handles behind. Once the result has been +// written synchronously, terminate this disposable worker without waiting for +// project-owned cleanup. +main().then( + () => process.exit(0), + () => process.exit(0), +); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 9b1f2e8bff..9724dfe744 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -36,6 +36,7 @@ export default defineConfig([ // Without these, tsdown inlines them into bin.js, breaking on-demand loading. 'create/bin': './src/create/bin.ts', 'migration/bin': './src/migration/bin.ts', + 'migration/compat-worker': './src/migration/compat-worker.ts', version: './src/version.ts', 'config/bin': './src/config/bin.ts', 'staged/bin': './src/staged/bin.ts', From d94cb65b25c3e4bfa4fdeb0c797c20c5a77497dd Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:18:39 +0800 Subject: [PATCH 32/32] fix(test): pin pkg.pr.new migration builds by commit --- .github/scripts/test-pkg-pr-new-migrate.sh | 86 +++++++++++-------- .../migration-upgrade-pkg-pr-new-npm/snap.txt | 10 +-- .../steps.json | 8 +- .../snap.txt | 10 +-- .../steps.json | 6 +- .../src/migration/__tests__/migrator.spec.ts | 2 +- 6 files changed, 70 insertions(+), 52 deletions(-) diff --git a/.github/scripts/test-pkg-pr-new-migrate.sh b/.github/scripts/test-pkg-pr-new-migrate.sh index 0a0c607f5c..e17a3c52ab 100755 --- a/.github/scripts/test-pkg-pr-new-migrate.sh +++ b/.github/scripts/test-pkg-pr-new-migrate.sh @@ -66,17 +66,11 @@ original_home="$HOME" cache_root="${XDG_CACHE_HOME:-$original_home/.cache}" pr_home="${VP_PKG_PR_NEW_HOME:-$cache_root/vite-plus/pkg-pr-new/$pr_ref}" installer_home="$(mktemp -d "${TMPDIR:-/tmp}/vite-plus-pr-installer.XXXXXX")" -cached_version_dir="$pr_home/pkg-pr-new-$pr_ref" -vp_bin="$pr_home/bin/vp" -vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" -global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" -commit_marker="$cached_version_dir/.pkg-pr-new-commit" pkg_pr_new_base="https://pkg.pr.new/voidzero-dev/vite-plus" -vite_plus_spec="$pkg_pr_new_base@$pr_ref" -vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$pr_ref" +requested_vite_plus_spec="$pkg_pr_new_base@$pr_ref" resolve_pkg_pr_new_commit() { - curl -fsSIL "$vite_plus_spec" | tr -d '\r' | awk -F ': ' ' + curl -fsSIL "$requested_vite_plus_spec" | tr -d '\r' | awk -F ': ' ' tolower($1) == "x-commit-key" { count = split($2, parts, ":") print parts[count] @@ -85,6 +79,32 @@ resolve_pkg_pr_new_commit() { ' } +available_commit="$(resolve_pkg_pr_new_commit || true)" +case "$available_commit" in + '' | *[!0-9a-fA-F]*) + echo "error: could not resolve an immutable pkg.pr.new commit for $pr_ref" >&2 + exit 1 + ;; +esac +if [ "${#available_commit}" -ne 40 ]; then + echo "error: pkg.pr.new returned an invalid commit for $pr_ref: $available_commit" >&2 + exit 1 +fi + +# PR-number URLs are mutable and pkg.pr.new packages reference their internal +# workspace dependencies by commit SHA. Persisting the PR URL alongside those +# SHA URLs makes package managers install duplicate copies of the same package. +# Resolve once, then use the immutable SHA for the global install and every +# dependency spec written by migration. +resolved_ref="$available_commit" +cached_version_dir="$pr_home/pkg-pr-new-$resolved_ref" +vp_bin="$pr_home/bin/vp" +vite_plus_package_json="$pr_home/current/node_modules/vite-plus/package.json" +global_cli_entry="$pr_home/current/node_modules/vite-plus/dist/bin.js" +commit_marker="$cached_version_dir/.pkg-pr-new-commit" +vite_plus_spec="$pkg_pr_new_base@$resolved_ref" +vite_plus_core_spec="$pkg_pr_new_base/@voidzero-dev/vite-plus-core@$resolved_ref" + read_installed_commit() { if [ -f "$commit_marker" ]; then head -n 1 "$commit_marker" @@ -103,14 +123,12 @@ read_installed_commit() { fi } -available_commit="$(resolve_pkg_pr_new_commit || true)" installed_commit="$(read_installed_commit || true)" current_target="$(readlink "$pr_home/current" 2>/dev/null || true)" reuse_install=0 -if [ -n "$available_commit" ] && - [ "$installed_commit" = "$available_commit" ] && - [ "$current_target" = "pkg-pr-new-$pr_ref" ] && +if [ "$installed_commit" = "$resolved_ref" ] && + [ "$current_target" = "pkg-pr-new-$resolved_ref" ] && [ -x "$vp_bin" ] && [ -f "$vite_plus_package_json" ] && [ -f "$global_cli_entry" ]; then @@ -123,38 +141,36 @@ cleanup() { trap cleanup EXIT if [ "$reuse_install" -eq 1 ]; then - printf '%s\n' "$available_commit" > "$commit_marker" - echo "Reusing installed Vite+ pkg.pr.new build $pr_ref ($available_commit) from $pr_home" + printf '%s\n' "$resolved_ref" > "$commit_marker" + echo "Reusing installed Vite+ pkg.pr.new build $resolved_ref (requested $pr_ref) from $pr_home" else - if [ -z "$available_commit" ]; then - echo "Could not verify the current pkg.pr.new commit; reinstalling $pr_ref." + if [ -n "$installed_commit" ] && [ "$installed_commit" != "$resolved_ref" ]; then + echo "pkg.pr.new build changed: $installed_commit -> $resolved_ref" elif [ -n "$installed_commit" ]; then - echo "pkg.pr.new build changed: $installed_commit -> $available_commit" + echo "Reinstalling pkg.pr.new build $resolved_ref with an immutable cache key" + fi + + # This helper owns a dedicated VP_HOME for each requested PR/ref. Remember + # the previous immutable install so it can be removed only after the new one + # succeeds, while retaining shared runtime and package-manager caches. + previous_target="" + if [ -n "$current_target" ] && [ "$current_target" != "pkg-pr-new-$resolved_ref" ]; then + case "$current_target" in + pkg-pr-new-*) previous_target="$current_target" ;; + esac fi - # Numeric pkg.pr.new references are mutable PR aliases. If the published - # commit changed, the reused lockfile can retain the checksum from the older - # tarball and fail with ERR_PNPM_TARBALL_INTEGRITY. Keep the downloaded - # runtime/package-manager cache, but force the wrapper dependency to resolve - # again. Commit SHA references are immutable and use their own cache path. - case "$pr_ref" in - *[!0-9]*) ;; - *) - rm -rf "$cached_version_dir/node_modules" - rm -f "$cached_version_dir/pnpm-lock.yaml" - ;; - esac - - echo "Installing Vite+ pkg.pr.new build $pr_ref into $pr_home" + echo "Installing Vite+ pkg.pr.new build $resolved_ref (requested $pr_ref) into $pr_home" HOME="$installer_home" \ VP_HOME="$pr_home" \ - VP_PR_VERSION="$pr_ref" \ + VP_PR_VERSION="$resolved_ref" \ VP_NODE_MANAGER=no \ bash "$installer" - if [ -n "$available_commit" ]; then - printf '%s\n' "$available_commit" > "$commit_marker" + if [ -n "$previous_target" ]; then + rm -rf "$pr_home/$previous_target" fi + printf '%s\n' "$resolved_ref" > "$commit_marker" fi if [ ! -x "$vp_bin" ]; then @@ -195,6 +211,8 @@ hash -r echo echo "Using isolated global CLI:" +echo " requested ref: $pr_ref" +echo " resolved commit: $resolved_ref" echo " executable: $vp_bin" echo " installation: $(readlink "$pr_home/current" 2>/dev/null || echo unknown)" echo " vite-plus spec: $VP_VERSION" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt index 1bc76fd5f3..3a77a693ac 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/snap.txt @@ -3,15 +3,15 @@ • Node npm • 2 config updates applied -> cat package.json # direct dependencies and npm overrides use the same PR URLs +> cat package.json # direct dependencies and npm overrides use the same immutable commit URLs { "name": "migration-upgrade-pkg-pr-new-npm", "devDependencies": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891" + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617" }, "packageManager": "npm@", "scripts": { @@ -19,7 +19,7 @@ } } -> node -e "const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)" # pkg.pr.new specs use the minimal override shape +> node -e "const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)" # pkg.pr.new specs use one immutable commit and the minimal override shape > node -e "require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json index 5f2a8b74ab..112332ff98 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-npm/steps.json @@ -1,13 +1,13 @@ { "env": { "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new targets replace every stale managed spec", - "cat package.json # direct dependencies and npm overrides use the same PR URLs", - "node -e \"const p = require('./package.json'); const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891'; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined) process.exit(1)\" # pkg.pr.new specs use the minimal override shape", + "cat package.json # direct dependencies and npm overrides use the same immutable commit URLs", + "node -e \"const p = require('./package.json'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; const vp = 'https://pkg.pr.new/voidzero-dev/vite-plus@' + sha; const core = 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha; if (p.devDependencies['vite-plus'] !== vp || p.devDependencies.vite !== core || p.overrides.vite !== core || p.overrides['@voidzero-dev/vite-plus-core'] !== undefined || JSON.stringify(p).includes('@1891')) process.exit(1)\" # pkg.pr.new specs use one immutable commit and the minimal override shape", "node -e \"require('node:fs').copyFileSync('package.json', 'package.after-first-migration.json')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8')) process.exit(1)\" # rerun leaves package.json unchanged" diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt index 2d08a0ae0d..992829edb5 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/snap.txt @@ -15,9 +15,9 @@ "packageManager": "pnpm@", "pnpm": { "overrides": { - "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891", + "vite": "https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617", "vitest": "", - "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "vite-plus": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" } }, "scripts": { @@ -32,8 +32,8 @@ packages: blockExoticSubdeps: false catalog: - vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891 - vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@1891 + vite: https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617 + vite-plus: https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617 vitest: overrides: @@ -48,7 +48,7 @@ peerDependencyRules: vite: '*' vitest: '*' -> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)" # pnpm policy and PR targets are persisted +> node -e "const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)" # pnpm policy and immutable commit targets are persisted > node -e "const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')" # capture first migration result > vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent ◇ Migrated . to Vite+ diff --git a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json index 38f0648435..ae70002660 100644 --- a/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-upgrade-pkg-pr-new-pnpm/steps.json @@ -2,14 +2,14 @@ "env": { "PNPM_CONFIG_BLOCK_EXOTIC_SUBDEPS": "false", "VP_FORCE_MIGRATE": "1", - "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891\",\"vitest\":\"4.1.9\"}", - "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@1891" + "VP_OVERRIDE_PACKAGES": "{\"vite\":\"https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617\",\"vitest\":\"4.1.9\"}", + "VP_VERSION": "https://pkg.pr.new/voidzero-dev/vite-plus@0c515e3fbf5c140db35280d700df0bd600838617" }, "commands": [ "vp migrate --no-interactive # pkg.pr.new pnpm migration allows URL-resolved subdependencies", "cat package.json # direct dependencies use catalogs aligned to the pkg.pr.new build", "cat pnpm-workspace.yaml # pkg.pr.new URLs are pinned and exotic subdependencies are allowed", - "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@1891') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891')) process.exit(1)\" # pnpm policy and PR targets are persisted", + "node -e \"const fs = require('node:fs'); const y = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); const sha = '0c515e3fbf5c140db35280d700df0bd600838617'; if (!y.includes('blockExoticSubdeps: false') || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus@' + sha) || !y.includes('https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@' + sha) || y.includes('@1891')) process.exit(1)\" # pnpm policy and immutable commit targets are persisted", "node -e \"const fs = require('node:fs'); fs.copyFileSync('package.json', 'package.after-first-migration.json'); fs.copyFileSync('pnpm-workspace.yaml', 'workspace.after-first-migration.yaml')\" # capture first migration result", "vp migrate --no-interactive # pkg.pr.new pnpm migration is idempotent", "node -e \"const fs = require('node:fs'); if (fs.readFileSync('package.json', 'utf8') !== fs.readFileSync('package.after-first-migration.json', 'utf8') || fs.readFileSync('pnpm-workspace.yaml', 'utf8') !== fs.readFileSync('workspace.after-first-migration.yaml', 'utf8')) process.exit(1)\" # rerun leaves manifests unchanged" diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index baed18b04d..b3ac4ccf1f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1466,7 +1466,7 @@ describe('ensureVitePlusBootstrap', () => { const savedForceMigrate = process.env.VP_FORCE_MIGRATE; const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; const viteOverride = - 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891'; + 'https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@0c515e3fbf5c140db35280d700df0bd600838617'; process.env.VP_FORCE_MIGRATE = '1'; VITE_PLUS_OVERRIDE_PACKAGES.vite = viteOverride; try {