Skip to content

Commit 5d4392a

Browse files
committed
Split test runs among deployments
If more than one account is passed for a deployment with `force import` divide test runs between deployments.
1 parent 75cc0f4 commit 5d4392a

File tree

7 files changed

+179
-35
lines changed

7 files changed

+179
-35
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
.DS_Store
22
metadata/
3+
src/
4+
.sfdx/
35

46
# Force CLI specific things
57
force
68
metadata
7-
test
9+
test
810

911
*.json
1012

command/deploy.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ func (c *deployStatus) isAborted() bool {
5555
return c.aborted
5656
}
5757

58-
func deploy(force *Force, files ForceMetadataFiles, deployOptions *ForceDeployOptions, outputOptions *deployOutputOptions) error {
58+
func deploy(force *Force, files ForceMetadataFiles, deployOptions ForceDeployOptions, outputOptions *deployOutputOptions) error {
5959
status := deployStatus{aborted: false}
6060

6161
return deployWith(force, &status, files, deployOptions, outputOptions)
6262
}
6363

64-
func deployWith(force *Force, status *deployStatus, files ForceMetadataFiles, deployOptions *ForceDeployOptions, outputOptions *deployOutputOptions) error {
64+
func deployWith(force *Force, status *deployStatus, files ForceMetadataFiles, deployOptions ForceDeployOptions, outputOptions *deployOutputOptions) error {
6565
startTime := time.Now()
66-
deployId, err := force.Metadata.StartDeploy(files, *deployOptions)
66+
deployId, err := force.Metadata.StartDeploy(files, deployOptions)
6767
if err != nil {
6868
return err
6969
}

command/field.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func updateFLSOnProfile(force *Force, objectName string, fieldName string) (err
173173
}
174174
displayOptions := defaultDeployOutputOptions()
175175
displayOptions.quiet = true
176-
return deploy(force, pb.ForceMetadataFiles(), new(ForceDeployOptions), displayOptions)
176+
return deploy(force, pb.ForceMetadataFiles(), ForceDeployOptions{}, displayOptions)
177177
}
178178

179179
func getFLSUpdateXML(objectName string, fieldName string) string {

command/import.go

Lines changed: 148 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1842
func 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-
6470
func 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+
}

command/push.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func pushByPaths(resourcePaths []string, deployOptions *ForceDeployOptions, disp
173173
ErrorAndExit("Could not add %s: %s", p, err.Error())
174174
}
175175
}
176-
err = deploy(force, pb.ForceMetadataFiles(), deployOptions, displayOptions)
176+
err = deploy(force, pb.ForceMetadataFiles(), *deployOptions, displayOptions)
177177
if err != nil {
178178
ErrorAndExit(err.Error())
179179
}
@@ -198,7 +198,7 @@ func pushByMetadataType(metadataType string, metadataNames []string, deployOptio
198198
}
199199
}
200200

201-
err = deploy(force, pb.ForceMetadataFiles(), deployOptions, displayOptions)
201+
err = deploy(force, pb.ForceMetadataFiles(), *deployOptions, displayOptions)
202202
if err != nil {
203203
ErrorAndExit(err.Error())
204204
}
@@ -217,7 +217,7 @@ func pushMetadataTypes(metadataTypes []string, deployOptions *ForceDeployOptions
217217
}
218218
}
219219

220-
err = deploy(force, pb.ForceMetadataFiles(), deployOptions, displayOptions)
220+
err = deploy(force, pb.ForceMetadataFiles(), *deployOptions, displayOptions)
221221
if err != nil {
222222
ErrorAndExit(err.Error())
223223
}

go.mod

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
module github.com/ForceCLI/force
22

3-
go 1.20
3+
go 1.22
4+
5+
toolchain go1.22.4
46

57
require (
68
github.com/ForceCLI/config v0.0.0-20230217143549-9149d42a3c99
79
github.com/ForceCLI/force-md v0.0.0-20240126214027-5b906037b66a
810
github.com/ForceCLI/inflect v0.0.0-20130829110746-cc00b5ad7a6a
911
github.com/ViViDboarder/gotifier v0.0.0-20140619195515-0f19f3d7c54c
12+
github.com/antlr4-go/antlr/v4 v4.13.1
1013
github.com/antonmedv/expr v1.15.3
1114
github.com/bgentry/speakeasy v0.1.0
1215
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869
@@ -19,12 +22,13 @@ require (
1922
github.com/hamba/avro/v2 v2.16.0
2023
github.com/linkedin/goavro/v2 v2.12.0
2124
github.com/obeattie/ohmyglob v0.0.0-20150811221449-290764208a0d
25+
github.com/octoberswimmer/apexfmt v0.0.0-20240326123316-6d1990ec1d8b
2226
github.com/olekukonko/tablewriter v0.0.5
2327
github.com/onsi/ginkgo v1.12.0
2428
github.com/onsi/gomega v1.10.0
2529
github.com/pkg/errors v0.9.1
2630
github.com/rgalanakis/golangal v0.0.0-20210923203926-e36008487518
27-
github.com/spf13/cobra v1.7.0
31+
github.com/spf13/cobra v1.8.0
2832
golang.org/x/crypto v0.14.0
2933
google.golang.org/grpc v1.39.0-dev
3034
google.golang.org/protobuf v1.28.1
@@ -36,7 +40,7 @@ require (
3640
github.com/charmbracelet/harmonica v0.2.0 // indirect
3741
github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect
3842
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
39-
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
43+
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
4044
github.com/fsnotify/fsnotify v1.4.9 // indirect
4145
github.com/golang/protobuf v1.5.2 // indirect
4246
github.com/golang/snappy v0.0.4 // indirect
@@ -62,8 +66,9 @@ require (
6266
github.com/russross/blackfriday/v2 v2.1.0 // indirect
6367
github.com/sirupsen/logrus v1.9.3 // indirect
6468
github.com/spf13/pflag v1.0.5 // indirect
69+
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
6570
golang.org/x/net v0.17.0 // indirect
66-
golang.org/x/sync v0.1.0 // indirect
71+
golang.org/x/sync v0.7.0 // indirect
6772
golang.org/x/sys v0.13.0 // indirect
6873
golang.org/x/term v0.13.0 // indirect
6974
golang.org/x/text v0.13.0 // indirect

0 commit comments

Comments
 (0)