support pytest results
This commit is contained in:
		
							parent
							
								
									e7b1ceaf14
								
							
						
					
					
						commit
						221c85685f
					
				
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
								
							| @ -192,6 +192,33 @@ GET  /api/admin/jobs?since=1577881845.999 | ||||
|       ] | ||||
|     } | ||||
| 
 | ||||
| POST /local/jobs/{job_id}?format=gitdeploy | ||||
| 
 | ||||
|     { "report": | ||||
|         { "name": "sleep test", | ||||
|           "status": "PASS", | ||||
|           "message": "the top level result", | ||||
|           "results": [ | ||||
|             { "name": "a sub test", | ||||
|               "status": "PASS", | ||||
|               "message": "a sub group", | ||||
|               "_detail": { "foo": "bar" } | ||||
|             } | ||||
|           ] | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| POST /local/jobs/{job_id}?format=pytest | ||||
| 
 | ||||
|     { "exitcode": 0, | ||||
|       "root": "/home/app/srv/status.example.com/e2e-selenium", | ||||
|       "tests": [ | ||||
|         { "nodeid": "pytest::idthing", | ||||
|           "outcome": "passed" | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
| 
 | ||||
| POST /api/admin/jobs | ||||
|     { "job_id": "xxxx", "kill": true } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										41
									
								
								examples/pyreport/pyreport.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								examples/pyreport/pyreport.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"io/ioutil" | ||||
| 	"os" | ||||
| 
 | ||||
| 	"git.rootprojects.org/root/gitdeploy/internal/api" | ||||
| ) | ||||
| 
 | ||||
| func main() { | ||||
| 	if len(os.Args) < 3 { | ||||
| 		fmt.Fprintf(os.Stderr, "Usage go run pytest-to-gitdeploy.go ./pytest/.result.json ./converted.json\n") | ||||
| 		os.Exit(1) | ||||
| 		return | ||||
| 	} | ||||
| 	jsonpath := os.Args[1] | ||||
| 	reportpath := os.Args[2] | ||||
| 
 | ||||
| 	b, err := ioutil.ReadFile(jsonpath) | ||||
| 	if nil != err { | ||||
| 		fmt.Fprintf(os.Stderr, "bad file path %s: %v\n", jsonpath, err) | ||||
| 		os.Exit(1) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	pyresult := api.PyResult{} | ||||
| 	if err := json.Unmarshal(b, &pyresult); nil != err { | ||||
| 		fmt.Fprintf(os.Stderr, "couldn't parse json %s: %v\n", string(b), err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	report := api.PyResultToReport(pyresult) | ||||
| 	b, _ = json.MarshalIndent(&report, "", "  ") | ||||
| 
 | ||||
| 	if err := ioutil.WriteFile(reportpath, b, 0644); nil != err { | ||||
| 		fmt.Fprintf(os.Stderr, "failed to write %s: %v", string(b), err) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
| @ -19,6 +19,7 @@ import ( | ||||
| 	"github.com/go-chi/chi" | ||||
| ) | ||||
| 
 | ||||
| // HTTPError for http errors | ||||
| type HTTPError struct { | ||||
| 	Success bool   `json:"success"` | ||||
| 	Code    string `json:"code,omitempty"` | ||||
| @ -26,8 +27,9 @@ type HTTPError struct { | ||||
| 	Detail  string `json:"detail,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type Report struct { | ||||
| 	Report *jobs.Report `json:"report"` | ||||
| // WrappedReport wraps results | ||||
| type WrappedReport struct { | ||||
| 	Report jobs.Result `json:"report"` | ||||
| } | ||||
| 
 | ||||
| // Route will set up the API and such | ||||
| @ -116,6 +118,7 @@ func RouteStopped(r chi.Router, runOpts *options.ServerConfig) { | ||||
| 					return | ||||
| 				} | ||||
| 
 | ||||
| 				// copies unused lock value | ||||
| 				jobCopy := *j | ||||
| 				logs := []jobs.Log{} | ||||
| 				for _, log := range j.Logs { | ||||
| @ -250,20 +253,39 @@ func RouteStopped(r chi.Router, runOpts *options.ServerConfig) { | ||||
| 			r.Post("/jobs/{jobID}", func(w http.ResponseWriter, r *http.Request) { | ||||
| 				w.Header().Set("Content-Type", "application/json") | ||||
| 
 | ||||
| 				report := WrappedReport{} | ||||
| 				decoder := json.NewDecoder(r.Body) | ||||
| 				report := &Report{} | ||||
| 				if err := decoder.Decode(report); nil != err { | ||||
| 					w.WriteHeader(http.StatusBadRequest) | ||||
| 					writeError(w, &HTTPError{ | ||||
| 						Code:    "E_PARSE", | ||||
| 						Message: "could not parse request body", | ||||
| 						Detail:  err.Error(), | ||||
| 					}) | ||||
| 					return | ||||
| 				format := r.URL.Query().Get("format") | ||||
| 
 | ||||
| 				switch format { | ||||
| 				case "pytest": | ||||
| 					pyresult := PyResult{} | ||||
| 					if err := decoder.Decode(&pyresult); nil != err { | ||||
| 						w.WriteHeader(http.StatusBadRequest) | ||||
| 						writeError(w, &HTTPError{ | ||||
| 							Code:    "E_PARSE", | ||||
| 							Message: "could not parse request body", | ||||
| 							Detail:  err.Error(), | ||||
| 						}) | ||||
| 						return | ||||
| 					} | ||||
| 					report = PyResultToReport(pyresult) | ||||
| 				case "gitdeploy": | ||||
| 					fallthrough | ||||
| 				case "": | ||||
| 					if err := decoder.Decode(&report); nil != err { | ||||
| 						w.WriteHeader(http.StatusBadRequest) | ||||
| 						writeError(w, &HTTPError{ | ||||
| 							Code:    "E_PARSE", | ||||
| 							Message: "could not parse request body", | ||||
| 							Detail:  err.Error(), | ||||
| 						}) | ||||
| 						return | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				jobID := webhooks.URLSafeRefID(chi.URLParam(r, "jobID")) | ||||
| 				if err := jobs.SetReport(jobID, report.Report); nil != err { | ||||
| 				if err := jobs.SetReport(jobID, &report.Report); nil != err { | ||||
| 					w.WriteHeader(http.StatusInternalServerError) | ||||
| 					writeError(w, &HTTPError{ | ||||
| 						Code:    "E_SERVER", | ||||
| @ -283,6 +305,7 @@ func RouteStopped(r chi.Router, runOpts *options.ServerConfig) { | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| // ParseSince will parse the query string into time.Time | ||||
| func ParseSince(sinceStr string) (time.Time, *HTTPError) { | ||||
| 	if 0 == len(sinceStr) { | ||||
| 		return time.Time{}, &HTTPError{ | ||||
| @ -303,6 +326,7 @@ func ParseSince(sinceStr string) (time.Time, *HTTPError) { | ||||
| 	return t, nil | ||||
| } | ||||
| 
 | ||||
| // ParseUnixTime turns a string of fractional seconds into time.Time | ||||
| func ParseUnixTime(seconds string) (time.Time, error) { | ||||
| 	secs, nano, err := ParseSeconds(seconds) | ||||
| 	if nil != err { | ||||
| @ -312,6 +336,7 @@ func ParseUnixTime(seconds string) (time.Time, error) { | ||||
| 	return time.Unix(secs, nano), nil | ||||
| } | ||||
| 
 | ||||
| // ParseSeconds turns a string of fractional seconds into Seconds and Nanoseconds | ||||
| func ParseSeconds(s string) (int64, int64, error) { | ||||
| 	seconds, err := strconv.ParseFloat(s, 64) | ||||
| 	if nil != err { | ||||
| @ -321,6 +346,7 @@ func ParseSeconds(s string) (int64, int64, error) { | ||||
| 	return secs, nanos, nil | ||||
| } | ||||
| 
 | ||||
| // SecondsToInts turns a float64 Second into Seconds and Nanoseconds | ||||
| func SecondsToInts(seconds float64) (int64, int64) { | ||||
| 	secs := math.Floor(seconds) | ||||
| 	nanos := math.Round((seconds - secs) * 1000000000) | ||||
|  | ||||
| @ -110,8 +110,8 @@ func TestCallback(t *testing.T) { | ||||
| 	} | ||||
| 
 | ||||
| 	job := &jobs.Job{} | ||||
| 	dec := json.NewDecoder(resp.Body) | ||||
| 	if err := dec.Decode(job); nil != err { | ||||
| 	b, _ := ioutil.ReadAll(resp.Body) | ||||
| 	if err := json.Unmarshal(b, job); nil != err { | ||||
| 		t.Errorf( | ||||
| 			"response decode error: %d %s\n%#v\n%#v", | ||||
| 			resp.StatusCode, reqURL, resp.Header, err, | ||||
| @ -120,7 +120,7 @@ func TestCallback(t *testing.T) { | ||||
| 	} | ||||
| 
 | ||||
| 	if len(job.Logs) < 3 { | ||||
| 		t.Errorf("too few logs: %s\n%#v", reqURL, job) | ||||
| 		t.Errorf("too few logs: %s\n%s\n%#v", reqURL, string(b), job) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										177
									
								
								internal/api/pytest.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								internal/api/pytest.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,177 @@ | ||||
| package api | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"git.rootprojects.org/root/gitdeploy/internal/jobs" | ||||
| ) | ||||
| 
 | ||||
| // PyResultToReport converts from pytest's result.json to the GitDeploy report format | ||||
| func PyResultToReport(pyresult PyResult) WrappedReport { | ||||
| 	report := jobs.Result{ | ||||
| 		Name:    "pytest", | ||||
| 		Status:  "failed", | ||||
| 		Message: fmt.Sprintf("Exited with status code %d", pyresult.ExitCode), | ||||
| 		Detail:  pyresult, | ||||
| 	} | ||||
| 
 | ||||
| 	var failed bool | ||||
| 	for i := range pyresult.Tests { | ||||
| 		unit := pyresult.Tests[i] | ||||
| 		report.Results = append(report.Results, jobs.Result{ | ||||
| 			Name:   unit.NodeID, | ||||
| 			Status: unit.Outcome, | ||||
| 			//Detail: unit, | ||||
| 		}) | ||||
| 		if "passed" != unit.Outcome { | ||||
| 			failed = true | ||||
| 		} | ||||
| 	} | ||||
| 	if !failed { | ||||
| 		report.Status = "passed" | ||||
| 	} | ||||
| 
 | ||||
| 	return WrappedReport{ | ||||
| 		Report: report, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // PyResult is the pytest report | ||||
| type PyResult struct { | ||||
| 	Created  float64 `json:"created"` | ||||
| 	Duration float64 `json:"duration"` | ||||
| 	ExitCode int     `json:"exitcode"` | ||||
| 	Root     string  `json:"root"` | ||||
| 	/* | ||||
| 		Environment struct { | ||||
| 			Python   string `json:"Python"` | ||||
| 			Platform string `json:"Platform"` | ||||
| 			Packages struct { | ||||
| 				Pytest string `json:"pytest"` | ||||
| 				Py     string `json:"py"` | ||||
| 				Pluggy string `json:"pluggy"` | ||||
| 			} `json:"Packages"` | ||||
| 			Plugins struct { | ||||
| 				HTML       string `json:"html"` | ||||
| 				Metadata   string `json:"metadata"` | ||||
| 				JSONReport string `json:"json-report"` | ||||
| 			} `json:"Plugins"` | ||||
| 		} `json:"environment"` | ||||
| 		Summary struct { | ||||
| 			Passed    int `json:"passed"` | ||||
| 			Total     int `json:"total"` | ||||
| 			Collected int `json:"collected"` | ||||
| 		} `json:"summary"` | ||||
| 		Collectors []struct { | ||||
| 			Nodeid  string `json:"nodeid"` | ||||
| 			Outcome string `json:"outcome"` | ||||
| 			Result  []struct { | ||||
| 				Nodeid string `json:"nodeid"` | ||||
| 				Type   string `json:"type"` | ||||
| 			} `json:"result"` | ||||
| 		} `json:"collectors"` | ||||
| 	*/ | ||||
| 	Tests []struct { | ||||
| 		NodeID   string   `json:"nodeid"` | ||||
| 		LineNo   int      `json:"lineno"` | ||||
| 		Outcome  string   `json:"outcome"` | ||||
| 		Keywords []string `json:"keywords"` | ||||
| 		Setup    struct { | ||||
| 			Duration float64 `json:"duration"` | ||||
| 			Outcome  string  `json:"outcome"` | ||||
| 		} `json:"setup"` | ||||
| 		Call struct { | ||||
| 			Duration float64 `json:"duration"` | ||||
| 			Outcome  string  `json:"outcome"` | ||||
| 		} `json:"call"` | ||||
| 		Teardown struct { | ||||
| 			Duration float64 `json:"duration"` | ||||
| 			Outcome  string  `json:"outcome"` | ||||
| 		} `json:"teardown"` | ||||
| 	} `json:"tests"` | ||||
| } | ||||
| 
 | ||||
| /* | ||||
| { | ||||
|   "created": 1614248016.458921, | ||||
|   "duration": 16.896488904953003, | ||||
|   "exitcode": 0, | ||||
|   "root": "/home/app/srv/status.example.com/e2e-selenium", | ||||
|   "environment": { | ||||
|     "Python": "3.9.1", | ||||
|     "Platform": "Linux-5.4.0-65-generic-x86_64-with-glibc2.31", | ||||
|     "Packages": { | ||||
|       "pytest": "6.2.1", | ||||
|       "py": "1.10.0", | ||||
|       "pluggy": "0.13.1" | ||||
|     }, | ||||
|     "Plugins": { | ||||
|       "html": "3.1.1", | ||||
|       "metadata": "1.11.0", | ||||
|       "json-report": "1.2.4" | ||||
|     } | ||||
|   }, | ||||
|   "summary": { | ||||
|     "passed": 3, | ||||
|     "total": 3, | ||||
|     "collected": 3 | ||||
|   }, | ||||
|   "collectors": [ | ||||
|     { | ||||
|       "nodeid": "", | ||||
|       "outcome": "passed", | ||||
|       "result": [ | ||||
|         { | ||||
|           "nodeid": "test_landing_200.py", | ||||
|           "type": "Module" | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "nodeid": "test_landing_200.py", | ||||
|       "outcome": "passed", | ||||
|       "result": [ | ||||
|         { | ||||
|           "nodeid": "test_landing_200.py::test_welcome_page_loads", | ||||
|           "type": "Function", | ||||
|           "lineno": 35 | ||||
|         }, | ||||
|         { | ||||
|           "nodeid": "test_landing_200.py::test_create_account", | ||||
|           "type": "Function", | ||||
|           "lineno": 49 | ||||
|         }, | ||||
|         { | ||||
|           "nodeid": "test_landing_200.py::test_login_existing", | ||||
|           "type": "Function", | ||||
|           "lineno": 85 | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "tests": [ | ||||
|     { | ||||
|       "nodeid": "test_landing_200.py::test_welcome_page_loads", | ||||
|       "lineno": 35, | ||||
|       "outcome": "passed", | ||||
|       "keywords": [ | ||||
|         "e2e-selenium", | ||||
|         "test_landing_200.py", | ||||
|         "test_welcome_page_loads" | ||||
|       ], | ||||
|       "setup": { | ||||
|         "duration": 0.0006089679664000869, | ||||
|         "outcome": "passed" | ||||
|       }, | ||||
|       "call": { | ||||
|         "duration": 2.512254447909072, | ||||
|         "outcome": "passed" | ||||
|       }, | ||||
|       "teardown": { | ||||
|         "duration": 0.00038311502430588007, | ||||
|         "outcome": "passed" | ||||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| */ | ||||
							
								
								
									
										29
									
								
								internal/api/testdata/deploy.sh
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								internal/api/testdata/deploy.sh
									
									
									
									
										vendored
									
									
								
							| @ -7,19 +7,30 @@ echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Started at ${GIT_DEPLOY_TIMESTAMP:-}" | ||||
| sleep ${GIT_DEPLOY_TEST_WAIT:-0.1} | ||||
| echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Finished" | ||||
| 
 | ||||
| echo "Reporting to ${GIT_DEPLOY_CALLBACK_URL} ..." | ||||
| curl -fsSL "${GIT_DEPLOY_CALLBACK_URL}" \ | ||||
| echo "Reporting to '${GIT_DEPLOY_CALLBACK_URL:-}' ..." | ||||
| #curl -fsSL "${GIT_DEPLOY_CALLBACK_URL}" \ | ||||
| curl -fL "${GIT_DEPLOY_CALLBACK_URL}?format=pytest" \ | ||||
|     -H 'Content-Type: application/json' \ | ||||
|     -d ' | ||||
|     { "report": | ||||
|         { "name": "sleep test", | ||||
|           "status": "PASS", | ||||
|           "message": "a top level result group", | ||||
|           "results": [ | ||||
|               { "name": "sub test", "status": "PASS", "message": "a sub group", "detail": "logs or smth" } | ||||
|           ] | ||||
|     { "exitcode": 0, | ||||
|       "root": "/home/app/srv/status.example.com/e2e-selenium", | ||||
|       "tests": [ | ||||
|         { "nodeid": "pytest::idthing", | ||||
|           "outcome": "passed" | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
| ' | ||||
| #    -d ' | ||||
| #    { "report": | ||||
| #        { "name": "sleep test", | ||||
| #          "status": "PASS", | ||||
| #          "message": "a top level result group", | ||||
| #          "results": [ | ||||
| #              { "name": "sub test", "status": "PASS", "message": "a sub group", "detail": "logs or smth" } | ||||
| #          ] | ||||
| #        } | ||||
| #    } | ||||
| #' | ||||
| 
 | ||||
| echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Generated Report" | ||||
|  | ||||
| @ -43,19 +43,22 @@ type Job struct { | ||||
| 	ExitCode  *int          `json:"exit_code,omitempty"`  // empty when running | ||||
| 	// full json | ||||
| 	Logs   []Log   `json:"logs,omitempty"`   // exist when requested | ||||
| 	Report *Report `json:"report,omitempty"` // empty unless given | ||||
| 	Report *Result `json:"report,omitempty"` // empty unless given | ||||
| 	// internal only | ||||
| 	cmd *exec.Cmd  `json:"-"` | ||||
| 	mux sync.Mutex `json:"-"` | ||||
| } | ||||
| 
 | ||||
| // Report should have many items | ||||
| type Report struct { | ||||
| 	Name    string   `json:"name"` | ||||
| 	Status  string   `json:"status,omitempty"` | ||||
| 	Message string   `json:"message,omitempty"` | ||||
| 	Detail  string   `json:"detail,omitempty"` | ||||
| 	Results []Report `json:"results,omitempty"` | ||||
| // TODO move cmd and mux here | ||||
| // type LockingJob struct { } | ||||
| 
 | ||||
| // Result may have many items and sub-items | ||||
| type Result struct { | ||||
| 	Name    string      `json:"name"` | ||||
| 	Status  string      `json:"status,omitempty"` | ||||
| 	Message string      `json:"message,omitempty"` | ||||
| 	Results []Result    `json:"results,omitempty"` | ||||
| 	Detail  interface{} `json:"_detail,omitempty"` | ||||
| } | ||||
| 
 | ||||
| var initialized = false | ||||
| @ -193,7 +196,7 @@ func All(then time.Time) []*Job { | ||||
| } | ||||
| 
 | ||||
| // SetReport will update jobs' logs | ||||
| func SetReport(urlRefID webhooks.URLSafeRefID, report *Report) error { | ||||
| func SetReport(urlRefID webhooks.URLSafeRefID, result *Result) error { | ||||
| 	b, err := base64.RawURLEncoding.DecodeString(string(urlRefID)) | ||||
| 	if nil != err { | ||||
| 		return err | ||||
| @ -206,7 +209,7 @@ func SetReport(urlRefID webhooks.URLSafeRefID, report *Report) error { | ||||
| 	} | ||||
| 	job := value.(*Job) | ||||
| 
 | ||||
| 	job.Report = report | ||||
| 	job.Report = result | ||||
| 	Actives.Store(refID, job) | ||||
| 
 | ||||
| 	return nil | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user