diff --git a/src/nodejs/pnpm/pnpm.go b/src/nodejs/pnpm/pnpm.go index 548076fe3..8d7e65aa6 100644 --- a/src/nodejs/pnpm/pnpm.go +++ b/src/nodejs/pnpm/pnpm.go @@ -19,6 +19,16 @@ type PNPM struct { Log *libbuildpack.Logger } +func (p *PNPM) Rebuild(buildDir string) error { + p.Log.Info("Rebuilding any native modules (pnpm)") + cmd := exec.Command("pnpm", "rebuild", "--recursive") + cmd.Dir = buildDir + cmd.Stdout = p.Log.Output() + cmd.Stderr = p.Log.Output() + cmd.Env = append(os.Environ(), "npm_config_nodedir="+os.Getenv("NODE_HOME")) + return p.Command.Run(cmd) +} + func (p *PNPM) Build(buildDir, cacheDir string) error { p.Log.Info("Installing node modules (pnpm-lock.yaml)") diff --git a/src/nodejs/pnpm/pnpm_suite_test.go b/src/nodejs/pnpm/pnpm_suite_test.go new file mode 100644 index 000000000..c9512e26e --- /dev/null +++ b/src/nodejs/pnpm/pnpm_suite_test.go @@ -0,0 +1,13 @@ +package pnpm_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPnpm(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pnpm Suite") +} diff --git a/src/nodejs/pnpm/pnpm_test.go b/src/nodejs/pnpm/pnpm_test.go new file mode 100644 index 000000000..92a59cc0b --- /dev/null +++ b/src/nodejs/pnpm/pnpm_test.go @@ -0,0 +1,150 @@ +package pnpm_test + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "reflect" + + "github.com/cloudfoundry/libbuildpack" + "github.com/cloudfoundry/libbuildpack/ansicleaner" + p "github.com/cloudfoundry/nodejs-buildpack/src/nodejs/pnpm" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +//go:generate mockgen -source=pnpm.go --destination=mocks_test.go --package=pnpm_test + +// MockCommand is a hand-written mock matching the pnpm.Command interface. +type MockCommand struct { + ctrl *gomock.Controller + recorder *MockCommandMockRecorder +} + +type MockCommandMockRecorder struct { + mock *MockCommand +} + +func NewMockCommand(ctrl *gomock.Controller) *MockCommand { + mock := &MockCommand{ctrl: ctrl} + mock.recorder = &MockCommandMockRecorder{mock} + return mock +} + +func (m *MockCommand) EXPECT() *MockCommandMockRecorder { return m.recorder } + +func (m *MockCommand) Execute(dir string, stdout, stderr io.Writer, program string, args ...string) error { + varargs := []interface{}{dir, stdout, stderr, program} + for _, a := range args { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Execute", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +func (mr *MockCommandMockRecorder) Execute(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommand)(nil).Execute), varargs...) +} + +func (m *MockCommand) Run(cmd *exec.Cmd) error { + ret := m.ctrl.Call(m, "Run", cmd) + ret0, _ := ret[0].(error) + return ret0 +} + +func (mr *MockCommandMockRecorder) Run(cmd interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockCommand)(nil).Run), cmd) +} + +var _ = Describe("PNPM", func() { + var ( + err error + buildDir string + cacheDir string + pnpm *p.PNPM + logger *libbuildpack.Logger + buffer *bytes.Buffer + mockCtrl *gomock.Controller + mockCommand *MockCommand + oldNodeHome string + ) + + BeforeEach(func() { + buildDir, err = os.MkdirTemp("", "nodejs-buildpack.build.") + Expect(err).To(BeNil()) + cacheDir, err = os.MkdirTemp("", "nodejs-buildpack.cache.") + Expect(err).To(BeNil()) + + buffer = new(bytes.Buffer) + logger = libbuildpack.NewLogger(ansicleaner.New(buffer)) + + mockCtrl = gomock.NewController(GinkgoT()) + mockCommand = NewMockCommand(mockCtrl) + + pnpm = &p.PNPM{ + Log: logger, + Command: mockCommand, + } + + oldNodeHome = os.Getenv("NODE_HOME") + Expect(os.Setenv("NODE_HOME", "test_node_home")).To(Succeed()) + }) + + AfterEach(func() { + mockCtrl.Finish() + Expect(os.Setenv("NODE_HOME", oldNodeHome)).To(Succeed()) + Expect(os.RemoveAll(buildDir)).To(Succeed()) + Expect(os.RemoveAll(cacheDir)).To(Succeed()) + }) + + Describe("Rebuild", func() { + It("runs pnpm rebuild with NODE_HOME set", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Args).To(Equal([]string{"pnpm", "rebuild", "--recursive"})) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElement("npm_config_nodedir=test_node_home")) + return nil + }) + Expect(pnpm.Rebuild(buildDir)).To(Succeed()) + Expect(buffer.String()).To(ContainSubstring("Rebuilding any native modules (pnpm)")) + }) + + It("propagates command errors", func() { + mockCommand.EXPECT().Run(gomock.Any()).Return(fmt.Errorf("pnpm rebuild failed")) + Expect(pnpm.Rebuild(buildDir)).To(MatchError("pnpm rebuild failed")) + }) + }) + + Describe("Build", func() { + Context("online mode (no .pnpm-store in buildDir)", func() { + It("runs pnpm install with cache dir", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Args).To(Equal([]string{"pnpm", "install", "--no-frozen-lockfile"})) + Expect(cmd.Dir).To(Equal(buildDir)) + Expect(cmd.Env).To(ContainElement("PNPM_HOME=" + cacheDir + "/.pnpm-store")) + return nil + }) + Expect(pnpm.Build(buildDir, cacheDir)).To(Succeed()) + }) + }) + + Context("offline mode (.pnpm-store exists in buildDir)", func() { + BeforeEach(func() { + Expect(os.MkdirAll(buildDir+"/.pnpm-store", 0755)).To(Succeed()) + }) + + It("runs pnpm install --offline", func() { + mockCommand.EXPECT().Run(gomock.Any()).DoAndReturn(func(cmd *exec.Cmd) error { + Expect(cmd.Args).To(Equal([]string{"pnpm", "install", "--no-frozen-lockfile", "--offline"})) + return nil + }) + Expect(pnpm.Build(buildDir, cacheDir)).To(Succeed()) + }) + }) + }) +}) diff --git a/src/nodejs/supply/mocks_test.go b/src/nodejs/supply/mocks_test.go index 3aa86e885..277559de1 100644 --- a/src/nodejs/supply/mocks_test.go +++ b/src/nodejs/supply/mocks_test.go @@ -208,6 +208,57 @@ func (mr *MockNPMMockRecorder) Rebuild(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rebuild", reflect.TypeOf((*MockNPM)(nil).Rebuild), arg0) } +// MockPNPM is a mock of PNPM interface. +type MockPNPM struct { + ctrl *gomock.Controller + recorder *MockPNPMMockRecorder +} + +// MockPNPMMockRecorder is the mock recorder for MockPNPM. +type MockPNPMMockRecorder struct { + mock *MockPNPM +} + +// NewMockPNPM creates a new mock instance. +func NewMockPNPM(ctrl *gomock.Controller) *MockPNPM { + mock := &MockPNPM{ctrl: ctrl} + mock.recorder = &MockPNPMMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPNPM) EXPECT() *MockPNPMMockRecorder { + return m.recorder +} + +// Build mocks base method. +func (m *MockPNPM) Build(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Build", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Build indicates an expected call of Build. +func (mr *MockPNPMMockRecorder) Build(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockPNPM)(nil).Build), arg0, arg1) +} + +// Rebuild mocks base method. +func (m *MockPNPM) Rebuild(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Rebuild", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Rebuild indicates an expected call of Rebuild. +func (mr *MockPNPMMockRecorder) Rebuild(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rebuild", reflect.TypeOf((*MockPNPM)(nil).Rebuild), arg0) +} + // MockYarn is a mock of Yarn interface. type MockYarn struct { ctrl *gomock.Controller diff --git a/src/nodejs/supply/supply.go b/src/nodejs/supply/supply.go index d908a0e4b..f4f732739 100644 --- a/src/nodejs/supply/supply.go +++ b/src/nodejs/supply/supply.go @@ -50,6 +50,7 @@ type Yarn interface { type PNPM interface { Build(string, string) error + Rebuild(string) error } type Stager interface { @@ -345,6 +346,12 @@ func (s *Supplier) BuildDependencies() error { return err } + case s.UsePNPM && s.IsVendored: + s.Log.Info("Prebuild detected (node_modules already exists)") + if err := s.PNPM.Rebuild(s.Stager.BuildDir()); err != nil { + return err + } + case s.UsePNPM: if err := s.PNPM.Build(s.Stager.BuildDir(), s.Stager.CacheDir()); err != nil { return err diff --git a/src/nodejs/supply/supply_test.go b/src/nodejs/supply/supply_test.go index 02b59aa81..88bed0663 100644 --- a/src/nodejs/supply/supply_test.go +++ b/src/nodejs/supply/supply_test.go @@ -31,6 +31,7 @@ var _ = Describe("Supply", func() { mockCtrl *gomock.Controller mockYarn *MockYarn mockNPM *MockNPM + mockPNPM *MockPNPM mockManifest *MockManifest mockInstaller *MockInstaller mockCommand *MockCommand @@ -63,6 +64,7 @@ var _ = Describe("Supply", func() { mockCommand = NewMockCommand(mockCtrl) mockYarn = NewMockYarn(mockCtrl) mockNPM = NewMockNPM(mockCtrl) + mockPNPM = NewMockPNPM(mockCtrl) installNode = func(dep libbuildpack.Dependency, nodeDir string) { err := os.MkdirAll(filepath.Join(nodeDir, "bin"), 0755) @@ -93,6 +95,7 @@ var _ = Describe("Supply", func() { Stager: stager, Yarn: mockYarn, NPM: mockNPM, + PNPM: mockPNPM, Log: logger, Manifest: mockManifest, Installer: mockInstaller, @@ -1201,6 +1204,32 @@ var _ = Describe("Supply", func() { }) }) + Context("using pnpm", func() { + BeforeEach(func() { + supplier.UsePNPM = true + }) + + It("runs pnpm build when node_modules does not exist", func() { + supplier.IsVendored = false + mockPNPM.EXPECT().Build(buildDir, cacheDir).Return(nil) + Expect(supplier.BuildDependencies()).To(Succeed()) + }) + + It("runs pnpm rebuild when node_modules already exists (vendored)", func() { + supplier.IsVendored = true + mockPNPM.EXPECT().Rebuild(buildDir).Return(nil) + Expect(supplier.BuildDependencies()).To(Succeed()) + }) + + It("calls PNPM.Rebuild (not NPM) when pnpm is vendored", func() { + supplier.IsVendored = true + mockPNPM.EXPECT().Rebuild(buildDir).Return(nil) + mockNPM.EXPECT().Rebuild(gomock.Any()).Times(0) + mockNPM.EXPECT().Build(gomock.Any(), gomock.Any()).Times(0) + Expect(supplier.BuildDependencies()).To(Succeed()) + }) + }) + Describe("using npm", func() { BeforeEach(func() { supplier.UseYarn = false