11import { createRequire } from 'node:module' ;
22import { tmpdir } from 'node:os' ;
3- import { mkdtemp , readFile , rm } from 'node:fs/promises' ;
4- import { describe , it , expect , vi , beforeEach } from 'vitest' ;
3+ import { mkdir , mkdtemp , readFile , rm } from 'node:fs/promises' ;
4+ import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
55import { writeFile , fileExists } from '@shopify/cli-kit/node/fs' ;
66import { joinPath } from '@shopify/cli-kit/node/path' ;
77import {
@@ -13,9 +13,11 @@ import {
1313import { mockAndCaptureOutput } from '@shopify/cli-kit/node/testing/output' ;
1414import { type PackageJson } from '@shopify/cli-kit/node/node-package-manager' ;
1515import { exec } from '@shopify/cli-kit/node/system' ;
16+ import * as system from '@shopify/cli-kit/node/system' ;
1617import {
1718 buildUpgradeCommandArgs ,
1819 displayConfirmation ,
20+ displayUpgradeSummary ,
1921 generateUpgradeInstructionsFile ,
2022 getAbsoluteVersion ,
2123 getAvailableUpgrades ,
@@ -31,6 +33,7 @@ import {
3133 getChangelog ,
3234 displayDevUpgradeNotice ,
3335} from './upgrade.js' ;
36+ import * as packageManagers from '../../lib/package-managers.js' ;
3437import { getSkeletonSourceDir } from '../../lib/build.js' ;
3538
3639// Test version numbers used to avoid conflicts with duplicate changelog versions
@@ -48,6 +51,19 @@ vi.mock('../../lib/package-managers.js', async () => {
4851 } ;
4952} ) ;
5053
54+ vi . mock ( '@shopify/cli-kit/node/system' , async ( ) => {
55+ const original = await vi . importActual <
56+ typeof import ( '@shopify/cli-kit/node/system' )
57+ > ( '@shopify/cli-kit/node/system' ) ;
58+
59+ return {
60+ ...original ,
61+ // Delegates to the real exec by default so helpers that drive git keep
62+ // working; individual tests override it to capture command invocations.
63+ exec : vi . fn ( original . exec ) ,
64+ } ;
65+ } ) ;
66+
5167vi . mock ( '../../lib/shell.js' , ( ) => ( { getCliCommand : vi . fn ( ( ) => 'h2' ) } ) ) ;
5268
5369vi . mock ( '@shopify/cli-kit/node/ui' , async ( ) => {
@@ -2010,6 +2026,130 @@ describe('upgrade', async () => {
20102026 expect ( args ) . toEqual ( result ) ;
20112027 } ) ;
20122028 } ) ;
2029+
2030+ // Regression coverage for monorepo workspaces, where the lockfile lives at
2031+ // the workspace root rather than in the app directory. These tests use the
2032+ // real findPackageManagerByLockfile (not the suite-wide 'pnpm' stub) and
2033+ // execute the command's task/summary paths end-to-end, so they fail if the
2034+ // upgrade command stops feeding ancestor-lockfile detection into the
2035+ // install/remove tasks or the undo instructions.
2036+ describe ( 'monorepo package manager detection' , ( ) => {
2037+ afterEach ( async ( ) => {
2038+ // Restore the suite-wide stubs for the rest of the file, since the
2039+ // module-level mock implementations persist across tests.
2040+ vi . mocked (
2041+ packageManagers . findPackageManagerByLockfile ,
2042+ ) . mockImplementation ( ( ) => Promise . resolve ( 'pnpm' ) ) ;
2043+ const actualSystem = await vi . importActual <
2044+ typeof import ( '@shopify/cli-kit/node/system' )
2045+ > ( '@shopify/cli-kit/node/system' ) ;
2046+ vi . mocked ( system . exec ) . mockImplementation ( actualSystem . exec ) ;
2047+ } ) ;
2048+
2049+ async function useRealLockfileDetection ( ) {
2050+ const { findPackageManagerByLockfile} = await vi . importActual <
2051+ typeof import ( '../../lib/package-managers.js' )
2052+ > ( '../../lib/package-managers.js' ) ;
2053+ vi . mocked (
2054+ packageManagers . findPackageManagerByLockfile ,
2055+ ) . mockImplementation ( findPackageManagerByLockfile ) ;
2056+ }
2057+
2058+ it ( 'runs the install and remove tasks with the workspace package manager from an ancestor lockfile' , async ( ) => {
2059+ await useRealLockfileDetection ( ) ;
2060+
2061+ const { releases} = await getChangelog ( ) ;
2062+ const selectedRelease = releases . find (
2063+ ( release ) => release . version === '2023.10.0' ,
2064+ ) as ( typeof releases ) [ 0 ] ;
2065+
2066+ const currentDependencies = {
2067+ ...OUTDATED_HYDROGEN_PACKAGE_JSON . dependencies ,
2068+ ...OUTDATED_HYDROGEN_PACKAGE_JSON . devDependencies ,
2069+ } ;
2070+
2071+ await inTemporaryDirectory ( async ( workspaceRoot ) => {
2072+ // Monorepo shape: the only lockfile is at the workspace root and the
2073+ // Hydrogen app lives in a nested directory with no lockfile of its own.
2074+ await writeFile ( joinPath ( workspaceRoot , 'pnpm-lock.yaml' ) , '' ) ;
2075+ const appPath = joinPath ( workspaceRoot , 'apps' , 'storefront' ) ;
2076+ await mkdir ( appPath , { recursive : true } ) ;
2077+
2078+ // Capture the package-manager invocation without running a real install.
2079+ const execSpy = vi
2080+ . mocked ( system . exec )
2081+ . mockResolvedValue ( undefined as never ) ;
2082+
2083+ await upgradeNodeModules ( {
2084+ appPath,
2085+ selectedRelease,
2086+ currentDependencies,
2087+ // present in currentDependencies, so the removal task is created
2088+ cumulativeRemoveDependencies : [ '@remix-run/react' ] ,
2089+ cumulativeRemoveDevDependencies : [ ] ,
2090+ } ) ;
2091+
2092+ // renderTasks is stubbed to a no-op, so run the task callbacks directly.
2093+ const tasks = vi . mocked ( renderTasks ) . mock . calls . at ( - 1 ) ?. [ 0 ] as Array < {
2094+ title : string ;
2095+ task : ( ) => Promise < void > ;
2096+ } > ;
2097+
2098+ const removalTask = tasks . find (
2099+ ( task ) => task . title === 'Removing deprecated dependencies' ,
2100+ ) ;
2101+ const upgradeTask = tasks . find (
2102+ ( task ) => task . title === 'Upgrading dependencies' ,
2103+ ) ;
2104+
2105+ await removalTask ?. task ( ) ;
2106+ await upgradeTask ?. task ( ) ;
2107+
2108+ expect ( execSpy ) . toHaveBeenCalledWith (
2109+ 'pnpm' ,
2110+ expect . arrayContaining ( [ 'remove' , '@remix-run/react' ] ) ,
2111+ expect . objectContaining ( { cwd : appPath } ) ,
2112+ ) ;
2113+ expect ( execSpy ) . toHaveBeenCalledWith (
2114+ 'pnpm' ,
2115+ expect . arrayContaining ( [ 'add' ] ) ,
2116+ expect . objectContaining ( { cwd : appPath } ) ,
2117+ ) ;
2118+ // Never falls back to npm + --legacy-peer-deps for a pnpm workspace.
2119+ expect ( execSpy ) . not . toHaveBeenCalledWith (
2120+ 'npm' ,
2121+ expect . anything ( ) ,
2122+ expect . anything ( ) ,
2123+ ) ;
2124+ } ) ;
2125+ } ) ;
2126+
2127+ it ( 'renders the workspace package manager in the undo instructions for an ancestor lockfile' , async ( ) => {
2128+ await useRealLockfileDetection ( ) ;
2129+
2130+ const { releases} = await getChangelog ( ) ;
2131+ const selectedRelease = releases . find (
2132+ ( release ) => release . version === '2023.10.0' ,
2133+ ) as ( typeof releases ) [ 0 ] ;
2134+
2135+ await inTemporaryDirectory ( async ( workspaceRoot ) => {
2136+ await writeFile ( joinPath ( workspaceRoot , 'pnpm-lock.yaml' ) , '' ) ;
2137+ const appPath = joinPath ( workspaceRoot , 'apps' , 'storefront' ) ;
2138+ await mkdir ( appPath , { recursive : true } ) ;
2139+
2140+ await displayUpgradeSummary ( {
2141+ appPath,
2142+ currentVersion : '2023.1.6' ,
2143+ selectedRelease,
2144+ } ) ;
2145+
2146+ const output = outputMock . info ( ) ;
2147+ expect ( output ) . toContain ( 'Undo these upgrades?' ) ;
2148+ expect ( output ) . toContain ( '&& pnpm i`' ) ;
2149+ expect ( output ) . not . toContain ( '&& npm i`' ) ;
2150+ } ) ;
2151+ } ) ;
2152+ } ) ;
20132153} ) ;
20142154
20152155// cumulative result when upgrading from 2023.1.6 (outdated) to 2023.4.1
0 commit comments