@@ -34,6 +34,31 @@ func TestCustomCode(t *testing.T) {
3434 t .Parallel ()
3535 testCustomCodeConflictResolutionAcceptOurs (t , speakeasyBinary )
3636 })
37+
38+ t .Run ("SequentialPatchesAppliedWithRegenerationBetween" , func (t * testing.T ) {
39+ t .Parallel ()
40+ testCustomCodeSequentialPatchesAppliedWithRegenerationBetween (t , speakeasyBinary )
41+ })
42+
43+ t .Run ("SequentialPatchesAppliedWithoutRegenerationBetween" , func (t * testing.T ) {
44+ t .Parallel ()
45+ testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween (t , speakeasyBinary )
46+ })
47+
48+ t .Run ("ConflictDetectionDuringCustomCodeRegistration" , func (t * testing.T ) {
49+ t .Parallel ()
50+ testCustomCodeConflictDetectionDuringRegistration (t , speakeasyBinary )
51+ })
52+
53+ t .Run ("NewFilePreservation" , func (t * testing.T ) {
54+ t .Parallel ()
55+ testCustomCodeNewFilePreservation (t , speakeasyBinary )
56+ })
57+
58+ t .Run ("NewFileDeletion" , func (t * testing.T ) {
59+ t .Parallel ()
60+ testCustomCodeNewFileDeletion (t , speakeasyBinary )
61+ })
3762}
3863
3964// testCustomCodeBasicWorkflow tests basic custom code registration and reapplication
@@ -60,14 +85,14 @@ func testCustomCodeConflictResolution(t *testing.T, speakeasyBinary string) {
6085 specPath := filepath .Join (temp , "customcodespec.yaml" )
6186 modifyLineInFile (t , specPath , 477 , " description: 'spec change'" )
6287
63- // Run speakeasy run to regenerate - this should produce a conflict
64- runRegeneration ( t , speakeasyBinary , temp , false )
65-
66- // Run customcode --resolve to enter conflict resolution mode
67- resolveCmd := exec . Command ( speakeasyBinary , "customcode" , "--resolve" , "--output" , "console" )
68- resolveCmd . Dir = temp
69- resolveOutput , resolveErr := resolveCmd . CombinedOutput ( )
70- require .NoError (t , resolveErr , "customcode --resolve should succeed: %s " , string ( resolveOutput ) )
88+ // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode
89+ // The process should exit with code 2 after setting up conflict resolution
90+ regenCmd := exec . Command ( speakeasyBinary , "run" , "-t" , "all" , "--pinned" , "--skip-compile" )
91+ regenCmd . Dir = temp
92+ regenOutput , regenErr := regenCmd . CombinedOutput ( )
93+ require . Error ( t , regenErr , "speakeasy run should exit with error after detecting conflicts: %s" , string ( regenOutput ))
94+ require . Contains ( t , string ( regenOutput ), "CUSTOM CODE CONFLICTS DETECTED" , "Output should show conflict detection banner" )
95+ require .Contains (t , string ( regenOutput ) , "Entering automatic conflict resolution mode " , "Output should indicate automatic resolution mode" )
7196
7297 // Check for conflict markers in the file
7398 getUserByNameContent , err := os .ReadFile (getUserByNamePath )
@@ -112,14 +137,14 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st
112137 specPath := filepath .Join (temp , "customcodespec.yaml" )
113138 modifyLineInFile (t , specPath , 477 , " description: 'spec change'" )
114139
115- // Run speakeasy run to regenerate - this should produce a conflict
116- runRegeneration ( t , speakeasyBinary , temp , false )
117-
118- // Run customcode --resolve to enter conflict resolution mode
119- resolveCmd := exec . Command ( speakeasyBinary , "customcode" , "--resolve" , "--output" , "console" )
120- resolveCmd . Dir = temp
121- resolveOutput , resolveErr := resolveCmd . CombinedOutput ( )
122- require .NoError (t , resolveErr , "customcode --resolve should succeed: %s " , string ( resolveOutput ) )
140+ // Run speakeasy run to regenerate - this should detect conflict and automatically enter resolution mode
141+ // The process should exit with code 2 after setting up conflict resolution
142+ regenCmd := exec . Command ( speakeasyBinary , "run" , "-t" , "all" , "--pinned" , "--skip-compile" )
143+ regenCmd . Dir = temp
144+ regenOutput , regenErr := regenCmd . CombinedOutput ( )
145+ require . Error ( t , regenErr , "speakeasy run should exit with error after detecting conflicts: %s" , string ( regenOutput ))
146+ require . Contains ( t , string ( regenOutput ), "CUSTOM CODE CONFLICTS DETECTED" , "Output should show conflict detection banner" )
147+ require .Contains (t , string ( regenOutput ) , "Entering automatic conflict resolution mode " , "Output should indicate automatic resolution mode" )
123148
124149 // Check for conflict markers in the file
125150 getUserByNameContent , err := os .ReadFile (getUserByNamePath )
@@ -173,6 +198,137 @@ func testCustomCodeConflictResolutionAcceptOurs(t *testing.T, speakeasyBinary st
173198 require .NotContains (t , string (finalContent ), "// custom code" , "Custom code should not be present after accepting ours" )
174199}
175200
201+ // testCustomCodeSequentialPatchesAppliedWithRegenerationBetween tests that patches can be updated
202+ // by registering a first patch, regenerating, then registering a second patch on the same line
203+ func testCustomCodeSequentialPatchesAppliedWithRegenerationBetween (t * testing.T , speakeasyBinary string ) {
204+ temp := setupSDKGeneration (t , speakeasyBinary , "customcodespec.yaml" )
205+
206+ getUserByNamePath := filepath .Join (temp , "models" , "operations" , "getuserbyname.go" )
207+
208+ // Step 1: Register first patch
209+ registerCustomCode (t , speakeasyBinary , temp , getUserByNamePath , 10 , "\t // first custom code" )
210+
211+ // Step 2: Verify first patch applies correctly on regeneration
212+ runRegeneration (t , speakeasyBinary , temp , true )
213+ verifyCustomCodePresent (t , getUserByNamePath , "// first custom code" )
214+
215+ // Step 2b: Commit the regenerated code with first patch applied
216+ gitCommit (t , temp , "regenerated with first patch" )
217+
218+ // Step 3: Modify the same line with different content (second patch)
219+ modifyLineInFile (t , getUserByNamePath , 10 , "\t // second custom code - updated" )
220+
221+ // Step 4: Register second patch (should update existing patch)
222+ customCodeCmd := exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
223+ customCodeCmd .Dir = temp
224+ customCodeOutput , customCodeErr := customCodeCmd .CombinedOutput ()
225+ require .NoError (t , customCodeErr , "customcode command should succeed for second patch: %s" , string (customCodeOutput ))
226+
227+ // Step 5: Verify patch file was updated (not appended)
228+ patchFile := filepath .Join (temp , ".speakeasy" , "patches" , "custom-code.diff" )
229+ patchContent , err := os .ReadFile (patchFile )
230+ require .NoError (t , err , "Failed to read patch file" )
231+ require .Contains (t , string (patchContent ), "second custom code - updated" , "Patch should contain second custom code" )
232+ require .NotContains (t , string (patchContent ), "first custom code" , "Patch should not contain first custom code" )
233+
234+ // Step 6: Verify second patch applies correctly on final regeneration
235+ runRegeneration (t , speakeasyBinary , temp , true )
236+
237+ // Step 7: Verify final file contains only second patch content
238+ finalContent , err := os .ReadFile (getUserByNamePath )
239+ require .NoError (t , err , "Failed to read getuserbyname.go after final regeneration" )
240+ require .Contains (t , string (finalContent ), "// second custom code - updated" , "File should contain second custom code" )
241+ require .NotContains (t , string (finalContent ), "// first custom code" , "File should not contain first custom code" )
242+ }
243+
244+ // testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween tests that patches can be updated
245+ // by registering a first patch, then immediately registering a second patch on the same line without regenerating
246+ func testCustomCodeSequentialPatchesAppliedWithoutRegenerationBetween (t * testing.T , speakeasyBinary string ) {
247+ temp := setupSDKGeneration (t , speakeasyBinary , "customcodespec.yaml" )
248+
249+ getUserByNamePath := filepath .Join (temp , "models" , "operations" , "getuserbyname.go" )
250+
251+ // Step 1: Register first patch
252+ registerCustomCode (t , speakeasyBinary , temp , getUserByNamePath , 10 , "\t // first custom code" )
253+
254+ // Step 2: Immediately modify the same line with different content (NO regeneration between)
255+ modifyLineInFile (t , getUserByNamePath , 10 , "\t // second custom code - updated" )
256+
257+ // Step 3: Register second patch (should update existing patch)
258+ customCodeCmd := exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
259+ customCodeCmd .Dir = temp
260+ customCodeOutput , customCodeErr := customCodeCmd .CombinedOutput ()
261+ require .NoError (t , customCodeErr , "customcode command should succeed for second patch: %s" , string (customCodeOutput ))
262+
263+ // Step 4: Verify patch file was updated (not appended)
264+ patchFile := filepath .Join (temp , ".speakeasy" , "patches" , "custom-code.diff" )
265+ patchContent , err := os .ReadFile (patchFile )
266+ require .NoError (t , err , "Failed to read patch file" )
267+ require .Contains (t , string (patchContent ), "second custom code - updated" , "Patch should contain second custom code" )
268+ require .NotContains (t , string (patchContent ), "first custom code" , "Patch should not contain first custom code" )
269+
270+ // Step 5: Verify second patch applies correctly on regeneration
271+ runRegeneration (t , speakeasyBinary , temp , true )
272+
273+ // Step 6: Verify final file contains only second patch content
274+ finalContent , err := os .ReadFile (getUserByNamePath )
275+ require .NoError (t , err , "Failed to read getuserbyname.go after final regeneration" )
276+ require .Contains (t , string (finalContent ), "// second custom code - updated" , "File should contain second custom code" )
277+ require .NotContains (t , string (finalContent ), "// first custom code" , "File should not contain first custom code" )
278+ }
279+
280+ // testCustomCodeConflictDetectionDuringRegistration tests that conflicts are detected during customcode registration
281+ // when the old patch conflicts with new changes
282+ func testCustomCodeConflictDetectionDuringRegistration (t * testing.T , speakeasyBinary string ) {
283+ temp := setupSDKGeneration (t , speakeasyBinary , "customcodespec.yaml" )
284+
285+ getUserByNamePath := filepath .Join (temp , "models" , "operations" , "getuserbyname.go" )
286+
287+ // Step 1: Modify the file
288+ modifyLineInFile (t , getUserByNamePath , 10 , "\t // first custom code" )
289+
290+ // Step 2: Register first patch
291+ customCodeCmd := exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
292+ customCodeCmd .Dir = temp
293+ customCodeOutput , customCodeErr := customCodeCmd .CombinedOutput ()
294+ require .NoError (t , customCodeErr , "customcode command should succeed: %s" , string (customCodeOutput ))
295+
296+ // Step 3: Run and commit
297+ runRegeneration (t , speakeasyBinary , temp , true )
298+ gitCommit (t , temp , "regenerated with first patch" )
299+
300+ // Step 4: Modify the same line again
301+ modifyLineInFile (t , getUserByNamePath , 10 , "\t // second custom code - conflicting" )
302+
303+ // Step 5: Modify the spec to change the same line (this will cause conflict during registration)
304+ specPath := filepath .Join (temp , "customcodespec.yaml" )
305+ modifyLineInFile (t , specPath , 477 , " description: 'spec change for conflict'" )
306+
307+ // Step 5b: Commit only the spec
308+ gitAddCmd := exec .Command ("git" , "add" , specPath )
309+ gitAddCmd .Dir = temp
310+ gitAddOutput , gitAddErr := gitAddCmd .CombinedOutput ()
311+ require .NoError (t , gitAddErr , "git add spec should succeed: %s" , string (gitAddOutput ))
312+
313+ gitCommitCmd := exec .Command ("git" , "commit" , "-m" , "update spec" )
314+ gitCommitCmd .Dir = temp
315+ gitCommitOutput , gitCommitErr := gitCommitCmd .CombinedOutput ()
316+ require .NoError (t , gitCommitErr , "git commit spec should succeed: %s" , string (gitCommitOutput ))
317+
318+ // Step 6: Register custom code - should fail with conflict error
319+ customCodeCmd = exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
320+ customCodeCmd .Dir = temp
321+ customCodeOutput , customCodeErr = customCodeCmd .CombinedOutput ()
322+
323+ // Step 7: Validate error - conflict happens when applying existing patch
324+ require .Error (t , customCodeErr , "customcode command should fail due to conflicts: %s" , string (customCodeOutput ))
325+ outputStr := string (customCodeOutput )
326+ // The conflict occurs when applying the existing patch (not the new patch)
327+ // because the spec changed and the old patch no longer applies cleanly
328+ require .Contains (t , outputStr , "failed to apply existing patch" , "Error message should mention failed to apply existing patch" )
329+ require .Contains (t , outputStr , "with conflicts" , "Error message should mention conflicts" )
330+ }
331+
176332// buildSpeakeasyBinaryOnce builds the speakeasy binary and returns the path to it
177333func buildSpeakeasyBinaryOnce (t * testing.T ) string {
178334 t .Helper ()
@@ -424,3 +580,144 @@ func verifyCustomCodePresent(t *testing.T, filePath, expectedContent string) {
424580 require .NoError (t , err , "Failed to read file: %s" , filePath )
425581 require .Contains (t , string (content ), expectedContent , "Custom code should be present in file" )
426582}
583+
584+ // testCustomCodeNewFilePreservation tests that custom code registration preserves entirely new files
585+ func testCustomCodeNewFilePreservation (t * testing.T , speakeasyBinary string ) {
586+ temp := setupSDKGeneration (t , speakeasyBinary , "customcodespec.yaml" )
587+
588+ // Create a new file with helper functions
589+ helperFilePath := filepath .Join (temp , "utils" , "helper.go" )
590+ helperFileContent := `package utils
591+
592+ import "fmt"
593+
594+ // FormatUserID formats a user ID with a prefix
595+ func FormatUserID(id int64) string {
596+ return fmt.Sprintf("user_%d", id)
597+ }
598+
599+ // ValidateUserID validates that a user ID is positive
600+ func ValidateUserID(id int64) bool {
601+ return id > 0
602+ }
603+ `
604+
605+ // Create the utils directory
606+ err := os .MkdirAll (filepath .Join (temp , "utils" ), 0o755 )
607+ require .NoError (t , err , "Failed to create utils directory" )
608+
609+ // Write the helper file
610+ err = os .WriteFile (helperFilePath , []byte (helperFileContent ), 0o644 )
611+ require .NoError (t , err , "Failed to write helper file" )
612+
613+ // Stage the new file so git diff HEAD can capture it
614+ gitAddCmd := exec .Command ("git" , "add" , helperFilePath )
615+ gitAddCmd .Dir = temp
616+ gitAddOutput , gitAddErr := gitAddCmd .CombinedOutput ()
617+ require .NoError (t , gitAddErr , "git add should succeed: %s" , string (gitAddOutput ))
618+
619+ // Register custom code
620+ customCodeCmd := exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
621+ customCodeCmd .Dir = temp
622+ customCodeOutput , customCodeErr := customCodeCmd .CombinedOutput ()
623+ require .NoError (t , customCodeErr , "customcode command should succeed: %s" , string (customCodeOutput ))
624+
625+ // Verify patch file was created
626+ patchFile := filepath .Join (temp , ".speakeasy" , "patches" , "custom-code.diff" )
627+ _ , err = os .Stat (patchFile )
628+ require .NoError (t , err , "patch file should exist at %s" , patchFile )
629+
630+ // Verify the file exists after registration (before regeneration)
631+ _ , err = os .Stat (helperFilePath )
632+ require .NoError (t , err , "Helper file should exist after registration" )
633+
634+ // Run speakeasy run to regenerate the SDK
635+ // This should apply the patch and preserve the new file
636+ runRegeneration (t , speakeasyBinary , temp , true )
637+
638+ // Verify the new file still exists after regeneration
639+ _ , err = os .Stat (helperFilePath )
640+ require .NoError (t , err , "Helper file should exist after regeneration" )
641+
642+ // Verify the file contents are preserved exactly
643+ verifyCustomCodePresent (t , helperFilePath , "FormatUserID" )
644+ verifyCustomCodePresent (t , helperFilePath , "ValidateUserID" )
645+ verifyCustomCodePresent (t , helperFilePath , "package utils" )
646+
647+ // Read the entire file and verify exact content match
648+ actualContent , err := os .ReadFile (helperFilePath )
649+ require .NoError (t , err , "Failed to read helper file after regeneration" )
650+ require .Equal (t , helperFileContent , string (actualContent ), "Helper file content should be preserved exactly" )
651+ }
652+
653+ // testCustomCodeNewFileDeletion tests that deleting a custom file is properly registered and persisted
654+ func testCustomCodeNewFileDeletion (t * testing.T , speakeasyBinary string ) {
655+ temp := setupSDKGeneration (t , speakeasyBinary , "customcodespec.yaml" )
656+
657+ // Create a new file with helper functions
658+ helperFilePath := filepath .Join (temp , "utils" , "helper.go" )
659+ helperFileContent := `package utils
660+
661+ import "fmt"
662+
663+ // FormatUserID formats a user ID with a prefix
664+ func FormatUserID(id int64) string {
665+ return fmt.Sprintf("user_%d", id)
666+ }
667+ `
668+
669+ // Create the utils directory
670+ err := os .MkdirAll (filepath .Join (temp , "utils" ), 0o755 )
671+ require .NoError (t , err , "Failed to create utils directory" )
672+
673+ // Write the helper file
674+ err = os .WriteFile (helperFilePath , []byte (helperFileContent ), 0o644 )
675+ require .NoError (t , err , "Failed to write helper file" )
676+
677+ // Stage the new file so git diff HEAD can capture it
678+ gitAddCmd := exec .Command ("git" , "add" , helperFilePath )
679+ gitAddCmd .Dir = temp
680+ _ , err = gitAddCmd .CombinedOutput ()
681+ require .NoError (t , err , "git add should succeed" )
682+
683+ // Register custom code (registers the new file)
684+ customCodeCmd := exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
685+ customCodeCmd .Dir = temp
686+ customCodeOutput , customCodeErr := customCodeCmd .CombinedOutput ()
687+ require .NoError (t , customCodeErr , "customcode command should succeed: %s" , string (customCodeOutput ))
688+
689+ // Verify patch file was created
690+ patchFile := filepath .Join (temp , ".speakeasy" , "patches" , "custom-code.diff" )
691+ _ , err = os .Stat (patchFile )
692+ require .NoError (t , err , "patch file should exist after registering new file" )
693+
694+ // Regenerate and verify the file is preserved
695+ runRegeneration (t , speakeasyBinary , temp , true )
696+ _ , err = os .Stat (helperFilePath )
697+ require .NoError (t , err , "Helper file should exist after first regeneration" )
698+
699+ // Commit the regeneration so the file becomes part of HEAD
700+ gitCommitCmd := exec .Command ("git" , "commit" , "-am" , "regeneration with custom file" )
701+ gitCommitCmd .Dir = temp
702+ _ , err = gitCommitCmd .CombinedOutput ()
703+ require .NoError (t , err , "git commit should succeed after regeneration" )
704+
705+ // Now delete the file
706+ err = os .Remove (helperFilePath )
707+ require .NoError (t , err , "Failed to delete helper file" )
708+
709+ // Register the deletion
710+ customCodeCmd = exec .Command (speakeasyBinary , "customcode" , "--output" , "console" )
711+ customCodeCmd .Dir = temp
712+ customCodeOutput , customCodeErr = customCodeCmd .CombinedOutput ()
713+ require .NoError (t , customCodeErr , "customcode command should succeed after deletion: %s" , string (customCodeOutput ))
714+
715+ // Verify patch file was removed (no custom code remaining)
716+ _ , err = os .Stat (patchFile )
717+ require .True (t , os .IsNotExist (err ), "patch file should not exist after deleting the only custom file" )
718+
719+ // Regenerate and verify the file remains deleted
720+ runRegeneration (t , speakeasyBinary , temp , true )
721+ _ , err = os .Stat (helperFilePath )
722+ require .True (t , os .IsNotExist (err ), "Helper file should not exist after regeneration with deletion registered" )
723+ }
0 commit comments