├── .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 | [![CircleCI Build Status](https://circleci.com/gh/jpmonette/apexcov.png?style=shield&circle-token=:circle-token)](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 | --------------------------------------------------------------------------------