11import { afterEach , beforeEach , describe , expect , it } from 'bun:test' ;
22import { join } from 'path' ;
3- import { mkdtempSync , mkdirSync , rmSync , writeFileSync } from 'fs' ;
3+ import { existsSync , mkdtempSync , mkdirSync , rmSync , writeFileSync } from 'fs' ;
44import { tmpdir } from 'os' ;
55import type { JobSpec } from '../../src/lib/plan-types' ;
6- import { MergeTrain , detectTestCommand } from '../../src/lib/merge-train' ;
6+ import { MergeTrain , detectInstallCommand , detectTestCommand } from '../../src/lib/merge-train' ;
77
88type TestRepo = {
99 rootDir : string ;
@@ -66,6 +66,7 @@ async function setupRepo(): Promise<TestRepo> {
6666 join ( repoDir , 'package.json' ) ,
6767 JSON . stringify ( { scripts : { test : 'true' } } , null , 2 ) ,
6868 ) ;
69+ writeFileSync ( join ( repoDir , '.gitignore' ) , 'node_modules\n' ) ;
6970 writeFileSync ( join ( repoDir , 'base.txt' ) , 'base\n' ) ;
7071
7172 await mustExec ( [ 'git' , 'add' , '.' ] , repoDir ) ;
@@ -74,6 +75,7 @@ async function setupRepo(): Promise<TestRepo> {
7475
7576 await mustExec ( [ 'git' , 'branch' , 'integration' ] , repoDir ) ;
7677 await mustExec ( [ 'git' , 'worktree' , 'add' , integrationWorktree , 'integration' ] , repoDir ) ;
78+ mkdirSync ( join ( integrationWorktree , 'node_modules' ) , { recursive : true } ) ;
7779
7880 return { rootDir, repoDir, integrationWorktree } ;
7981}
@@ -190,6 +192,106 @@ describe('MergeTrain', () => {
190192 expect ( command ) . toBe ( 'bun test tests/smoke.test.ts' ) ;
191193 } ) ;
192194
195+ it ( 'detects install command from lockfile' , async ( ) => {
196+ writeFileSync (
197+ join ( testRepo . integrationWorktree , 'package-lock.json' ) ,
198+ '{"name":"repo","lockfileVersion":3}' ,
199+ ) ;
200+
201+ const command = await detectInstallCommand ( testRepo . integrationWorktree ) ;
202+ expect ( command ) . toBe ( 'npm ci' ) ;
203+ } ) ;
204+
205+ it ( 'installs dependencies when node_modules is missing before tests' , async ( ) => {
206+ await createBranchCommit ( testRepo . repoDir , 'feature-install' , 'install.txt' , 'install\n' ) ;
207+
208+ rmSync ( join ( testRepo . integrationWorktree , 'node_modules' ) , {
209+ recursive : true ,
210+ force : true ,
211+ } ) ;
212+
213+ writeFileSync (
214+ join ( testRepo . integrationWorktree , 'package.json' ) ,
215+ JSON . stringify (
216+ {
217+ name : 'repo' ,
218+ version : '1.0.0' ,
219+ packageManager :
'[email protected] ' , 220+ scripts : { test : 'test -d node_modules' } ,
221+ } ,
222+ null ,
223+ 2 ,
224+ ) ,
225+ ) ;
226+
227+ const train = new MergeTrain ( testRepo . integrationWorktree , {
228+ testCommand : 'test -d node_modules' ,
229+ testTimeout : 60000 ,
230+ } ) ;
231+ train . enqueue ( makeJob ( 'feature-install' ) ) ;
232+
233+ const result = await train . processNext ( ) ;
234+
235+ expect ( result . success ) . toBe ( true ) ;
236+ expect ( existsSync ( join ( testRepo . integrationWorktree , 'node_modules' ) ) ) . toBe ( true ) ;
237+ } ) ;
238+
239+ it ( 'runs configured setup commands before tests' , async ( ) => {
240+ await createBranchCommit ( testRepo . repoDir , 'feature-setup' , 'setup.txt' , 'setup\n' ) ;
241+
242+ rmSync ( join ( testRepo . integrationWorktree , '.deps-ready' ) , {
243+ recursive : true ,
244+ force : true ,
245+ } ) ;
246+
247+ const train = new MergeTrain ( testRepo . integrationWorktree , {
248+ setupCommands : [ 'touch .deps-ready' ] ,
249+ testCommand : 'test -f .deps-ready' ,
250+ testTimeout : 60000 ,
251+ } ) ;
252+ train . enqueue ( makeJob ( 'feature-setup' ) ) ;
253+
254+ const result = await train . processNext ( ) ;
255+
256+ expect ( result . success ) . toBe ( true ) ;
257+ expect ( existsSync ( join ( testRepo . integrationWorktree , '.deps-ready' ) ) ) . toBe ( true ) ;
258+ } ) ;
259+
260+ it ( 'rolls back merge when setup command fails' , async ( ) => {
261+ await createBranchCommit (
262+ testRepo . repoDir ,
263+ 'feature-setup-fail' ,
264+ 'setup-fail.txt' ,
265+ 'setup-fail\n' ,
266+ ) ;
267+
268+ const headBefore = await mustExec (
269+ [ 'git' , 'rev-parse' , 'HEAD' ] ,
270+ testRepo . integrationWorktree ,
271+ ) ;
272+
273+ const train = new MergeTrain ( testRepo . integrationWorktree , {
274+ setupCommands : [ 'false' ] ,
275+ testCommand : 'true' ,
276+ testTimeout : 60000 ,
277+ } ) ;
278+ train . enqueue ( makeJob ( 'feature-setup-fail' ) ) ;
279+
280+ const result = await train . processNext ( ) ;
281+
282+ expect ( result . success ) . toBe ( false ) ;
283+ if ( ! result . success ) {
284+ expect ( result . type ) . toBe ( 'test_failure' ) ;
285+ expect ( result . output ) . toContain ( 'Dependency setup command failed' ) ;
286+ }
287+
288+ const headAfter = await mustExec (
289+ [ 'git' , 'rev-parse' , 'HEAD' ] ,
290+ testRepo . integrationWorktree ,
291+ ) ;
292+ expect ( headAfter ) . toBe ( headBefore ) ;
293+ } ) ;
294+
193295 it ( 'rolls back merge when tests fail' , async ( ) => {
194296 await createBranchCommit ( testRepo . repoDir , 'feature-fail' , 'fail.txt' , 'fail\n' ) ;
195297
0 commit comments