Repository: qiniu/goc (go)

Issue:
add support for merging multiple coverage profiles

Pull request description:
add merge command
fix #89 

Most of logic is from:
https://github.com/kubernetes/test-infra/blob/master/gopherage/cmd/merge/merge.go

Patch:
diff --git a/cmd/merge.go b/cmd/merge.go
new file mode 100644
index 0000000..64e6495
--- /dev/null
+++ b/cmd/merge.go
@@ -0,0 +1,76 @@
+/*
+ Copyright 2020 Qiniu Cloud (qiniu.com)
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+	log "github.com/sirupsen/logrus"
+
+	"github.com/spf13/cobra"
+	"golang.org/x/tools/cover"
+	"k8s.io/test-infra/gopherage/pkg/cov"
+	"k8s.io/test-infra/gopherage/pkg/util"
+)
+
+var mergeCmd = &cobra.Command{
+	Use:   "merge [files...]",
+	Short: "Merge multiple coherent Go coverage files into a single file.",
+	Long: `merge will merge multiple Go coverage files into a single coverage file.
+merge requires that the files are 'coherent', meaning that if they both contain references to the
+same paths, then the contents of those source files were identical for the binary that generated
+each file.
+`,
+	Run: func(cmd *cobra.Command, args []string) {
+		runMerge(args, outputMergeProfile)
+	},
+}
+
+var outputMergeProfile string
+
+func init() {
+	mergeCmd.Flags().StringVarP(&outputMergeProfile, "output", "o", "mergeprofile.cov", "output file")
+
+	rootCmd.AddCommand(mergeCmd)
+}
+
+func runMerge(args []string, output string) {
+	if len(args) == 0 {
+		log.Fatalln("Expected at least one coverage file.")
+		return
+	}
+
+	profiles := make([][]*cover.Profile, len(args))
+	for _, path := range args {
+		profile, err := util.LoadProfile(path)
+		if err != nil {
+			log.Fatalf("failed to open %s: %v", path, err)
+			return
+		}
+		profiles = append(profiles, profile)
+	}
+
+	merged, err := cov.MergeMultipleProfiles(profiles)
+	if err != nil {
+		log.Fatalf("failed to merge files: %v", err)
+		return
+	}
+
+	err = util.DumpProfile(output, merged)
+	if err != nil {
+		log.Fatalln(err)
+		return
+	}
+}
diff --git a/pkg/cover/client.go b/pkg/cover/client.go
index 7bdbb44..6567d8b 100644
--- a/pkg/cover/client.go
+++ b/pkg/cover/client.go
@@ -20,12 +20,13 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
-	"log"
 	"net"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
+
+	log "github.com/sirupsen/logrus"
 )
 
 // Action provides methods to contact with the covered service under test


Test patch:
diff --git a/cmd/merge_test.go b/cmd/merge_test.go
new file mode 100644
index 0000000..ee35d79
--- /dev/null
+++ b/cmd/merge_test.go
@@ -0,0 +1,156 @@
+/*
+ Copyright 2020 Qiniu Cloud (qiniu.com)
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package cmd
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+)
+
+// use a variable to record if the tested function has failed
+// so unittest can only be exectued sequential
+var fatal = false
+var fatalStr string
+
+type TestLogHook struct{}
+
+func (h *TestLogHook) Levels() []log.Level {
+	return []log.Level{log.FatalLevel}
+}
+
+func (h *TestLogHook) Fire(e *log.Entry) error {
+	fatalStr = e.Message
+	return nil
+}
+
+func TestMain(m *testing.M) {
+	// setup
+	originalExitFunc := log.StandardLogger().ExitFunc
+	defer func() {
+		log.StandardLogger().ExitFunc = originalExitFunc
+		log.StandardLogger().Hooks = make(log.LevelHooks)
+	}()
+
+	// replace exit function, so log.Fatal wont exit
+	log.StandardLogger().ExitFunc = func(int) { fatal = true }
+	// add hook, so fatal string will be recorded
+	log.StandardLogger().Hooks.Add(&TestLogHook{})
+
+	code := m.Run()
+
+	os.Exit(code)
+}
+
+func TestMergeNormalProfiles(t *testing.T) {
+	profileA := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/a.voc")
+	profileB := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/b.voc")
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	runMerge([]string{profileA, profileB}, mergeprofile)
+
+	contents, err := ioutil.ReadFile(mergeprofile)
+	assert.NoError(t, err)
+	assert.Contains(t, string(contents), "qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 60")
+	assert.Contains(t, string(contents), "qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 2")
+	assert.Equal(t, fatal, false)
+}
+
+// test with no profiles
+func TestMergeWithNoProfiles(t *testing.T) {
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	// clear fatal string in setup
+	fatalStr = ""
+	fatal = false
+
+	runMerge([]string{}, mergeprofile)
+
+	// there is fatal
+	assert.Equal(t, fatal, true)
+	assert.Equal(t, fatalStr, "Expected at least one coverage file.")
+}
+
+// pass a non-existed profile to runMerge
+func TestWithWrongProfileName(t *testing.T) {
+	profileA := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/notexist.voc")
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	// clear fatal string in setup
+	fatalStr = ""
+	fatal = false
+
+	runMerge([]string{profileA}, mergeprofile)
+
+	// there is fatal
+	assert.Equal(t, fatal, true)
+	assert.Contains(t, fatalStr, "failed to open")
+}
+
+// merge two different modes' profiles should fail
+func TestMergeTwoDifferentModeProfile(t *testing.T) {
+	profileA := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/a.voc")
+	profileSet := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/setmode.voc")
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	// clear fatal string in setup
+	fatalStr = ""
+	fatal = false
+
+	runMerge([]string{profileA, profileSet}, mergeprofile)
+
+	// there is fatal
+	assert.Equal(t, fatal, true)
+	assert.Contains(t, fatalStr, "mode for qiniu.com/kodo/apiserver/server/main.go mismatches")
+}
+
+// merge two overlaped profiles should fail
+func TestMergeTwoOverLapProfile(t *testing.T) {
+	profileA := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/a.voc")
+	profileOverlap := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/overlap.voc")
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	// clear fatal string in setup
+	fatalStr = ""
+	fatal = false
+
+	runMerge([]string{profileA, profileOverlap}, mergeprofile)
+
+	// there is fatal
+	assert.Equal(t, fatal, true)
+	assert.Contains(t, fatalStr, "coverage block mismatch")
+}
+
+// merge empty file should fail
+func TestMergeEmptyProfile(t *testing.T) {
+	profileA := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/empty.voc")
+	mergeprofile := filepath.Join(baseDir, "../tests/samples/merge_profile_samples/merge.cov")
+
+	// clear fatal string in setup
+	fatalStr = ""
+	fatal = false
+
+	runMerge([]string{profileA}, mergeprofile)
+
+	// there is fatal
+	assert.Equal(t, fatal, true)
+	assert.Contains(t, fatalStr, "failed to dump profile")
+}
diff --git a/tests/merge.bats b/tests/merge.bats
new file mode 100755
index 0000000..0b3e4cc
--- /dev/null
+++ b/tests/merge.bats
@@ -0,0 +1,67 @@
+#!/usr/bin/env bats
+# Copyright 2020 Qiniu Cloud (七牛云)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load util.sh
+
+setup_file() {
+    # run centered server
+    goc server 3>&- &
+    GOC_PID=$!
+    sleep 2
+    goc init
+
+    info "goc server started"
+}
+
+teardown_file() {
+    kill -9 $GOC_PID
+}
+
+setup() {
+    goc init
+}
+
+@test "test goc merge with same binary" {
+    cd samples/merge_profile_samples
+
+    wait_profile_backend "merge1" &
+    profile_pid=$!
+
+    # merge two profiles with same binary
+    run gocc merge a.voc b.voc --output mergeprofile.voc1 --debug --debugcisyncfile ci-sync.bak;
+    info merge1 output: $output
+    [ "$status" -eq 0 ]
+    run cat mergeprofile.voc1
+    [[ "$output" == *"qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 60"* ]]
+    [[ "$output" == *"qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 2"* ]]
+}
+
+@test "test goc merge with two binaries, but has some source code in common" {
+    cd samples/merge_profile_samples
+
+    wait_profile_backend "merge2" &
+    profile_pid=$!
+
+    # merge two profiles from two binaries, but has some source code in common
+    run gocc merge a.voc c.voc --output mergeprofile.voc2 --debug --debugcisyncfile ci-sync.bak;
+    info merge2 output: $output
+    [ "$status" -eq 0 ]
+    run cat mergeprofile.voc2
+    [[ "$output" == *"qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 60"* ]]
+    [[ "$output" == *"qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 0"* ]]
+    [[ "$output" == *"qiniu.com/kodo/apiserver/server/wala.go:42.49,43.13 1 0"* ]]
+
+    wait $profile_pid
+}
diff --git a/tests/run-ci-actions.sh b/tests/run-ci-actions.sh
index 48ac66f..14bef93 100755
--- a/tests/run-ci-actions.sh
+++ b/tests/run-ci-actions.sh
@@ -43,4 +43,6 @@ bats -t cover.bats
 
 bats -t agent.bats
 
+bats -t merge.bats
+
 bash <(curl -s https://codecov.io/bash) -f 'filtered*' -F e2e-$GOVERSION
\ No newline at end of file
diff --git a/tests/samples/merge_profile_samples/a.voc b/tests/samples/merge_profile_samples/a.voc
new file mode 100644
index 0000000..5c8b06e
--- /dev/null
+++ b/tests/samples/merge_profile_samples/a.voc
@@ -0,0 +1,3 @@
+mode: atomic
+qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 30
+qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 0
\ No newline at end of file
diff --git a/tests/samples/merge_profile_samples/b.voc b/tests/samples/merge_profile_samples/b.voc
new file mode 100644
index 0000000..c361181
--- /dev/null
+++ b/tests/samples/merge_profile_samples/b.voc
@@ -0,0 +1,3 @@
+mode: atomic
+qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 30
+qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 2
\ No newline at end of file
diff --git a/tests/samples/merge_profile_samples/c.voc b/tests/samples/merge_profile_samples/c.voc
new file mode 100644
index 0000000..348ef73
--- /dev/null
+++ b/tests/samples/merge_profile_samples/c.voc
@@ -0,0 +1,4 @@
+mode: atomic
+qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 30
+qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 0
+qiniu.com/kodo/apiserver/server/wala.go:42.49,43.13 1 0
\ No newline at end of file
diff --git a/tests/samples/merge_profile_samples/empty.voc b/tests/samples/merge_profile_samples/empty.voc
new file mode 100644
index 0000000..e69de29
diff --git a/tests/samples/merge_profile_samples/overlap.voc b/tests/samples/merge_profile_samples/overlap.voc
new file mode 100644
index 0000000..cc87489
--- /dev/null
+++ b/tests/samples/merge_profile_samples/overlap.voc
@@ -0,0 +1,3 @@
+mode: atomic
+qiniu.com/kodo/apiserver/server/main.go:32.49,33.12 1 30
+qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 0
\ No newline at end of file
diff --git a/tests/samples/merge_profile_samples/setmode.voc b/tests/samples/merge_profile_samples/setmode.voc
new file mode 100644
index 0000000..8ea14ce
--- /dev/null
+++ b/tests/samples/merge_profile_samples/setmode.voc
@@ -0,0 +1,3 @@
+mode: set
+qiniu.com/kodo/apiserver/server/main.go:32.49,33.13 1 30
+qiniu.com/kodo/apiserver/server/main.go:42.49,43.13 1 0
\ No newline at end of file