├── .gitignore
├── apexcover.sublime-project
├── LICENSE
├── login.go
├── README.md
└── apexcov.go
/.gitignore:
--------------------------------------------------------------------------------
1 | *.sublime-workspace
--------------------------------------------------------------------------------
/apexcover.sublime-project:
--------------------------------------------------------------------------------
1 | {
2 | "folders":
3 | [
4 | {
5 | "path": "."
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Jean-Philippe Monette
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/login.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/xml"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "net/url"
10 | "strings"
11 | )
12 |
13 | // login executes the login to the SOAP API and returns the Instance URL and Session ID
14 | func login(instance, username, password string) (instanceUrl, sessionId string, err error) {
15 | client := &http.Client{}
16 |
17 | soap := `
18 |
20 |
21 |
22 | %s
23 | %s
24 |
25 |
26 |
27 | `
28 |
29 | rbody := fmt.Sprintf(soap, username, password)
30 |
31 | req, err := http.NewRequest("POST", instance+"/services/Soap/u/39.0", strings.NewReader(rbody))
32 | req.Header.Add("Content-Type", `text/xml`)
33 | req.Header.Add("SOAPAction", `login`)
34 | response, err := client.Do(req)
35 |
36 | if err != nil {
37 | return
38 | }
39 |
40 | defer response.Body.Close()
41 |
42 | if response.StatusCode == 401 {
43 | err = errors.New("Unauthorized")
44 | return
45 | }
46 |
47 | body, err := ioutil.ReadAll(response.Body)
48 |
49 | if err != nil {
50 | return
51 | }
52 |
53 | err = processError(body)
54 |
55 | if err != nil {
56 | return
57 | }
58 |
59 | var loginResponse SoapLoginResponse
60 |
61 | if err = xml.Unmarshal(body, &loginResponse); err != nil {
62 | return
63 | }
64 |
65 | u, err := url.Parse(loginResponse.Instance_url)
66 | sessionId = loginResponse.SessionId
67 | instanceUrl = "https://" + u.Host
68 |
69 | return
70 | }
71 |
72 | // processError process the error returned by the SOAP API
73 | func processError(body []byte) (err error) {
74 | var soapError SoapErrorResponse
75 | xml.Unmarshal(body, &soapError)
76 | if soapError.FaultCode != "" {
77 | return errors.New(soapError.FaultString)
78 | }
79 | return
80 | }
81 |
82 | // SoapLoginResponse represents the response of the "login" SOAPAction
83 | type SoapLoginResponse struct {
84 | SessionId string `xml:"Body>loginResponse>result>sessionId"`
85 | Id string `xml:"Body>loginResponse>result>userId"`
86 | Instance_url string `xml:"Body>loginResponse>result>serverUrl"`
87 | }
88 |
89 | // SoapErrorResponse represents the error response of the SOAP API
90 | type SoapErrorResponse struct {
91 | FaultCode string `xml:"Body>Fault>faultcode"`
92 | FaultString string `xml:"Body>Fault>faultstring"`
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # apexcov
2 | Maintaining a well-tested codebase is mission-critical. `apexcov` generates public [Apex](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_intro_what_is_apex.htm) test coverage reports for your [Force.com](https://force.com) open-source projects.
3 |
4 | [](https://circleci.com/gh/jpmonette/apexcov)
5 |
6 | ## Installation
7 |
8 | ```sh
9 | $ go get -u github.com/jpmonette/apexcov
10 | ```
11 |
12 | ## Usage
13 |
14 | To generate your test coverage report:
15 |
16 | ```sh
17 | $ apexcov --username="jpmonette@example.com" --password="my-password"
18 | ```
19 |
20 | You can shorten the command by setting the global options as environment variables:
21 |
22 | - `APEXCOV_INSTANCE`: Salesforce instance URL
23 | - `APEXCOV_USERNAME`: Account username
24 | - `APEXCOV_PASSWORD`: Account password
25 |
26 | ### Coveralls
27 |
28 | #### Travis CI
29 |
30 | Add this to your `.travis.yml`:
31 |
32 | ```yaml
33 | env:
34 | - GOPATH=$HOME/go PATH=$GOPATH/bin:$PATH
35 | before_script:
36 | - npm install -g coveralls
37 | - go get github.com/jpmonette/apexcov
38 | script:
39 | - apexcov
40 | - codeclimate-test-reporter < ./coverage/lcov.info
41 | ```
42 |
43 | (make sure you set your `COVERALLS_REPO_TOKEN` environment variable)
44 |
45 | #### CircleCI
46 |
47 | Add this to your `circle.yml`:
48 |
49 | ```yaml
50 | machine:
51 | pre:
52 | - npm install -g coveralls
53 | - go get -u github.com/jpmonette/apexcov
54 | test:
55 | post:
56 | - apexcov
57 | - cat ./coverage/lcov.info | coveralls
58 | ```
59 |
60 | ### Code Climate
61 |
62 | #### Travis CI
63 |
64 | Add this to your `.travis.yml`:
65 |
66 | ```yaml
67 | env:
68 | - GOPATH=$HOME/go PATH=$GOPATH/bin:$PATH
69 | - CC_TEST_REPORTER_ID=YOUR_CODE_CLIMATE_REPORTER_ID
70 | before_script:
71 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
72 | - chmod +x ./cc-test-reporter
73 | - go get github.com/jpmonette/apexcov
74 | - ./cc-test-reporter before-build
75 | script:
76 | - apexcov
77 | - ./cc-test-reporter format-coverage -t lcov ./coverage/lcov.info
78 | - ./cc-test-reporter upload-coverage
79 | ```
80 |
81 | #### CircleCI 1.0
82 |
83 | Add this to your `circle.yml`:
84 |
85 | ```yaml
86 | machine:
87 | pre:
88 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
89 | - chmod +x ./cc-test-reporter
90 | - go get -u github.com/jpmonette/apexcov
91 | test:
92 | post:
93 | - apexcov
94 | - ./cc-test-reporter format-coverage -t lcov ./coverage/lcov.info
95 | - ./cc-test-reporter upload-coverage
96 | ```
97 |
98 | (make sure you set your `CC_TEST_REPORTER_ID` environment variable)
99 |
100 | #### CircleCI 2.0
101 |
102 | Add this to your `.circleci/config.yml`:
103 |
104 | ```yaml
105 | build:
106 | environment:
107 | CC_TEST_REPORTER_ID: YOUR_CODE_CLIMATE_REPORTER_ID
108 | steps:
109 | - go get -u github.com/jpmonette/apexcov
110 | - run: curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
111 | - run: chmod +x ./cc-test-reporter
112 | - apexcov
113 | - ./cc-test-reporter format-coverage -t lcov ./coverage/lcov.info
114 | - ./cc-test-reporter upload-coverage
115 | ```
116 |
117 | ## Help
118 |
119 | ```sh
120 | NAME:
121 | apexcov - a Test Coverage Generator for Apex
122 |
123 | USAGE:
124 | apexcov [global options] command [command options] [arguments...]
125 |
126 | VERSION:
127 | 1.0.0
128 |
129 | AUTHOR:
130 | Jean-Philippe Monette
131 |
132 | COMMANDS:
133 | help, h Shows a list of commands or help for one command
134 |
135 | GLOBAL OPTIONS:
136 | --instance value Salesforce instance to use (default: "https://login.salesforce.com")
137 | --username value Username of the Salesforge org
138 | --password value Password of the Salesforge org
139 | --help, -h show help
140 | --version, -v print the version
141 | ```
142 |
143 |
144 | ## License
145 |
146 | This application is distributed under the MIT license found in the [LICENSE](./LICENSE)
147 | file.
148 |
--------------------------------------------------------------------------------
/apexcov.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "net/http"
7 | "net/url"
8 | "os"
9 | "strconv"
10 | "strings"
11 |
12 | "github.com/urfave/cli"
13 | )
14 |
15 | // main is the entry point for the apexcov CLI application
16 | func main() {
17 | app := cli.NewApp()
18 | app.Usage = "a Test Coverage Generator for Apex"
19 | app.Version = "1.0.0"
20 | app.Author = "Jean-Philippe Monette"
21 | app.Email = "contact@jpmonette.net"
22 |
23 | app.Flags = []cli.Flag{
24 | cli.StringFlag{
25 | Name: "instance,i",
26 | Value: "https://login.salesforce.com",
27 | Usage: "instance to use",
28 | },
29 | cli.StringFlag{
30 | Name: "username,u",
31 | Value: os.Getenv("APEXCOV_USERNAME"),
32 | Usage: "username of the Salesforge org",
33 | },
34 | cli.StringFlag{
35 | Name: "password,p",
36 | Value: os.Getenv("APEXCOV_PASSWORD"),
37 | Usage: "password of the Salesforge org",
38 | },
39 | }
40 |
41 | app.Action = apexcov
42 | app.Run(os.Args)
43 | }
44 |
45 | // apexcov handles the code coverage command
46 | func apexcov(c *cli.Context) error {
47 | username := c.String("username")
48 | password := c.String("password")
49 | instance := c.String("instance")
50 |
51 | if os.Getenv("APEXCOV_INSTANCE") != "" {
52 | instance = os.Getenv("APEXCOV_INSTANCE")
53 | }
54 |
55 | if username == "" {
56 | return cli.NewExitError("You must provide a username", 1)
57 | } else if password == "" {
58 | return cli.NewExitError("You must provide a password", 1)
59 | } else if _, err := url.ParseRequestURI(instance); err != nil {
60 | return cli.NewExitError("You must provide a valid instance URL", 1)
61 | }
62 |
63 | instanceUrl, sessionId, err := login(instance, username, password)
64 |
65 | if err != nil {
66 | return cli.NewExitError(err.Error(), 1)
67 | }
68 |
69 | data, err := getCoverage(instanceUrl, sessionId)
70 |
71 | if err != nil {
72 | return cli.NewExitError(err.Error(), 1)
73 | }
74 |
75 | body := "TN:\n"
76 |
77 | dir, err := os.Getwd()
78 |
79 | for _, class := range data.Records {
80 | if strings.HasPrefix(class.Id, "01p") {
81 | body += "SF:" + dir + "/src/classes/" + class.ApexClassOrTrigger.Name + ".cls\n"
82 | } else {
83 | body += "SF:" + dir + "/src/triggers/" + class.ApexClassOrTrigger.Name + ".cls\n"
84 |
85 | }
86 |
87 | for _, line := range class.Coverage.CoveredLines {
88 | body += "DA:" + strconv.Itoa(line) + ",1\n"
89 | }
90 |
91 | for _, line := range class.Coverage.UncoveredLines {
92 | body += "DA:" + strconv.Itoa(line) + ",0\n"
93 | }
94 |
95 | body += "end_of_record\n"
96 | }
97 |
98 | persistCoverage(body)
99 | return nil
100 | }
101 |
102 | // getCoverage gets the Apex code coverage from the Salesforce instance
103 | func getCoverage(instanceUrl, session string) (coverage CoverageResponse, err error) {
104 | client := &http.Client{}
105 |
106 | endpoint := instanceUrl + "/services/data/v39.0/tooling/query?q="
107 | query := "SELECT ApexClassOrTriggerId, ApexClassorTrigger.Name, Coverage FROM ApexCodeCoverageAggregate"
108 |
109 | req, err := http.NewRequest("GET", endpoint+url.QueryEscape(query), nil)
110 | req.Header.Add("Authorization", "Bearer "+session)
111 | req.Header.Add("Content-Type", "application/json")
112 | req.Header.Add("User-Agent", "apexcov")
113 | response, err := client.Do(req)
114 |
115 | if err != nil {
116 | return coverage, err
117 | }
118 |
119 | defer response.Body.Close()
120 |
121 | responseData, err := ioutil.ReadAll(response.Body)
122 |
123 | if err != nil {
124 | return coverage, err
125 | }
126 |
127 | err = json.Unmarshal(responseData, &coverage)
128 |
129 | if err != nil {
130 | return coverage, err
131 | }
132 | return
133 | }
134 |
135 | // persistCoverage stores the coverage in the lcov.info file
136 | func persistCoverage(body string) error {
137 | _, err := os.Stat("./coverage")
138 | if os.IsNotExist(err) {
139 | os.Mkdir("./coverage", 0777)
140 | }
141 |
142 | err = ioutil.WriteFile("./coverage/lcov.info", []byte(body), 0666)
143 | return err
144 | }
145 |
146 | // CoverageResponse represents the format of the ApexCodeCoverageAggregate query response
147 | type CoverageResponse struct {
148 | Records []struct {
149 | Id string `json:"ApexClassOrTriggerId"`
150 | ApexClassOrTrigger struct {
151 | Name string `json:"Name"`
152 | } `json:"ApexClassOrTrigger"`
153 | Coverage struct {
154 | CoveredLines []int `json:"coveredLines"`
155 | UncoveredLines []int `json:"uncoveredLines"`
156 | } `json:"Coverage"`
157 | } `json:"records"`
158 | }
159 |
--------------------------------------------------------------------------------