@@ -7,14 +7,38 @@ import (
77 "os"
88 "os/user"
99 "path/filepath"
10+ "slices"
11+ "sort"
1012 "strings"
1113 "sync"
1214
1315 . "github.com/ForceCLI/force/error"
1416 . "github.com/ForceCLI/force/lib"
17+
18+ "github.com/antlr4-go/antlr/v4"
19+ "github.com/octoberswimmer/apexfmt/parser"
1520 "github.com/spf13/cobra"
1621)
1722
23+ var importCmd = & cobra.Command {
24+ Use : "import" ,
25+ Short : "Import metadata from a local directory" ,
26+ Example : `
27+ force import
28+ force import -directory=my_metadata -c -r -v
29+ force import -checkonly -runalltests
30+ ` ,
31+ Run : func (cmd * cobra.Command , args []string ) {
32+ options := getDeploymentOptions (cmd )
33+ srcDir := sourceDir (cmd )
34+
35+ displayOptions := getDeploymentOutputOptions (cmd )
36+
37+ runImport (srcDir , options , displayOptions )
38+ },
39+ Args : cobra .MaximumNArgs (0 ),
40+ }
41+
1842func init () {
1943 // Deploy options
2044 importCmd .Flags ().BoolP ("rollbackonerror" , "r" , false , "roll back deployment on error" )
@@ -25,7 +49,8 @@ func init() {
2549 importCmd .Flags ().BoolP ("allowmissingfiles" , "m" , false , "set allow missing files" )
2650 importCmd .Flags ().BoolP ("autoupdatepackage" , "u" , false , "set auto update package" )
2751 importCmd .Flags ().BoolP ("ignorewarnings" , "i" , false , "ignore warnings" )
28- importCmd .Flags ().StringSliceP ("test" , "" , []string {}, "Test(s) to run" )
52+ importCmd .Flags ().BoolP ("splittests" , "s" , false , "split tests between deployments" )
53+ importCmd .Flags ().StringSliceP ("test" , "" , []string {}, "test(s) to run" )
2954
3055 // Output options
3156 importCmd .Flags ().BoolP ("ignorecoverage" , "w" , false , "suppress code coverage warnings" )
@@ -42,25 +67,6 @@ func init() {
4267 RootCmd .AddCommand (importCmd )
4368}
4469
45- var importCmd = & cobra.Command {
46- Use : "import" ,
47- Short : "Import metadata from a local directory" ,
48- Example : `
49- force import
50- force import -directory=my_metadata -c -r -v
51- force import -checkonly -runalltests
52- ` ,
53- Run : func (cmd * cobra.Command , args []string ) {
54- options := getDeploymentOptions (cmd )
55- srcDir := sourceDir (cmd )
56-
57- displayOptions := getDeploymentOutputOptions (cmd )
58-
59- runImport (srcDir , options , displayOptions )
60- },
61- Args : cobra .MaximumNArgs (0 ),
62- }
63-
6470func sourceDir (cmd * cobra.Command ) string {
6571 directory , _ := cmd .Flags ().GetString ("directory" )
6672
@@ -92,7 +98,7 @@ func sourceDir(cmd *cobra.Command) string {
9298 return root
9399}
94100
95- func runImport (root string , options ForceDeployOptions , displayOptions * deployOutputOptions ) {
101+ func runImport (root string , baseOptions ForceDeployOptions , displayOptions * deployOutputOptions ) {
96102 if displayOptions .quiet {
97103 previousLogger := Log
98104 var l quietLogger
@@ -124,16 +130,27 @@ func runImport(root string, options ForceDeployOptions, displayOptions *deployOu
124130
125131 var deployments sync.WaitGroup
126132 status := deployStatus {aborted : false }
133+ forces := manager .getAllForce ()
134+ var options []ForceDeployOptions
135+ if baseOptions .TestLevel == "NoTestRun" || len (forces ) == 1 {
136+ options = make ([]ForceDeployOptions , len (forces ))
137+ for i := range options {
138+ options [i ] = baseOptions
139+ }
140+ } else {
141+ options = splitTests (baseOptions , files , len (forces ))
142+ }
127143
128- for _ , f := range manager . getAllForce () {
144+ for i , f := range forces {
129145 if status .isAborted () {
130146 break
131147 }
132148 current := f
149+ index := i
133150 deployments .Add (1 )
134151 go func () {
135152 defer deployments .Done ()
136- err := deployWith (current , & status , files , & options , displayOptions )
153+ err := deployWith (current , & status , files , options [ index ] , displayOptions )
137154 if err == nil && displayOptions .reportFormat == "text" && ! displayOptions .quiet {
138155 fmt .Printf ("Imported from %s\n " , root )
139156 }
@@ -146,3 +163,111 @@ func runImport(root string, options ForceDeployOptions, displayOptions *deployOu
146163
147164 deployments .Wait ()
148165}
166+
167+ // Evenly distribute tests to be run among deployments by counting how many test methods each (test) class has
168+ func splitTests (ops ForceDeployOptions , files ForceMetadataFiles , deployments int ) []ForceDeployOptions {
169+ //what about RunAllTestsInOrg?
170+ options := make ([]ForceDeployOptions , deployments )
171+ testClasses := makeTestClasses (ops , files )
172+ split := splitByDeployments (testClasses , deployments )
173+
174+ for i , s := range split {
175+ options [i ] = ops
176+ options [i ].RunTests = make ([]string , len (s ))
177+
178+ for j , className := range s {
179+ options [i ].RunTests [j ] = className .name
180+ }
181+ }
182+
183+ return options
184+ }
185+
186+ type testClass struct {
187+ name string
188+ methods int
189+ }
190+
191+ func makeTestClasses (ops ForceDeployOptions , files ForceMetadataFiles ) []testClass {
192+ allTests := len (ops .RunTests ) == 0
193+ testClasses := make ([]testClass , 0 )
194+ folderPreffix := "classes" + string (os .PathSeparator )
195+
196+ for fileName , contents := range files {
197+ name , isClass := strings .CutSuffix (fileName , ".cls" )
198+
199+ if isClass && (allTests || slices .Contains (ops .RunTests , name )) {
200+ count := testMethods (contents )
201+
202+ if count > 0 {
203+ testClasses = append (testClasses , testClass {name : strings .TrimPrefix (name , folderPreffix ), methods : count })
204+ }
205+ }
206+ }
207+
208+ return testClasses
209+ }
210+
211+ type testClassListener struct {
212+ * parser.BaseApexParserListener
213+ methods int
214+ }
215+
216+ func (t * testClassListener ) EnterModifier (ctx * parser.ModifierContext ) {
217+ if ctx .TESTMETHOD () != nil {
218+ t .methods += 1
219+ }
220+ }
221+
222+ func (t * testClassListener ) EnterAnnotation (ctx * parser.AnnotationContext ) {
223+ annotation := ctx .QualifiedName ()
224+
225+ if annotation != nil && strings .ToUpper (annotation .GetText ()) == "ISTEST" {
226+ t .methods += 1
227+ }
228+ }
229+
230+ func testMethods (src []byte ) int {
231+ input := antlr .NewInputStream (string (src ))
232+ lexer := parser .NewApexLexer (input )
233+ stream := antlr .NewCommonTokenStream (lexer , antlr .TokenDefaultChannel )
234+ p := parser .NewApexParser (stream )
235+ listener := new (testClassListener )
236+
237+ p .RemoveErrorListeners ()
238+ antlr .ParseTreeWalkerDefault .Walk (listener , p .CompilationUnit ())
239+
240+ return listener .methods
241+ }
242+
243+ type byMethods []testClass
244+
245+ func (a byMethods ) Len () int { return len (a ) }
246+ func (a byMethods ) Swap (i , j int ) { a [i ], a [j ] = a [j ], a [i ] }
247+ func (a byMethods ) Less (i , j int ) bool { return a [i ].methods > a [j ].methods }
248+
249+ func splitByDeployments (classes []testClass , n int ) [][]testClass {
250+ sort .Sort (byMethods (classes ))
251+ groups := make ([][]testClass , n )
252+
253+ for i := range groups {
254+ groups [i ] = []testClass {}
255+ }
256+
257+ methodCounts := make ([]int , n )
258+
259+ for _ , class := range classes {
260+ // Find the group with the least number of methods
261+ minIdx := 0
262+ for i := 1 ; i < n ; i ++ {
263+ if methodCounts [i ] < methodCounts [minIdx ] {
264+ minIdx = i
265+ }
266+ }
267+
268+ groups [minIdx ] = append (groups [minIdx ], class )
269+ methodCounts [minIdx ] += class .methods
270+ }
271+
272+ return groups
273+ }
0 commit comments