From 221c85685feac20b9536b6c0ea6f6c7ffb3ed4ab Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 25 Feb 2021 18:41:35 -0700 Subject: [PATCH] support pytest results --- README.md | 27 +++++ examples/pyreport/pyreport.go | 41 ++++++++ internal/api/api.go | 50 ++++++--- internal/api/api_test.go | 6 +- internal/api/pytest.go | 177 ++++++++++++++++++++++++++++++++ internal/api/testdata/deploy.sh | 29 ++++-- internal/jobs/job.go | 23 +++-- 7 files changed, 319 insertions(+), 34 deletions(-) create mode 100644 examples/pyreport/pyreport.go create mode 100644 internal/api/pytest.go diff --git a/README.md b/README.md index 99c6eec..96632dc 100644 --- a/README.md +++ b/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 } diff --git a/examples/pyreport/pyreport.go b/examples/pyreport/pyreport.go new file mode 100644 index 0000000..4e1e9e9 --- /dev/null +++ b/examples/pyreport/pyreport.go @@ -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) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 73f5cb7..05c91b5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 364a01a..95fa382 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 } diff --git a/internal/api/pytest.go b/internal/api/pytest.go new file mode 100644 index 0000000..3c1c656 --- /dev/null +++ b/internal/api/pytest.go @@ -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" + } + } + ] +} +*/ diff --git a/internal/api/testdata/deploy.sh b/internal/api/testdata/deploy.sh index 7600d25..120df80 100644 --- a/internal/api/testdata/deploy.sh +++ b/internal/api/testdata/deploy.sh @@ -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" diff --git a/internal/jobs/job.go b/internal/jobs/job.go index 04a7fa3..9b52533 100644 --- a/internal/jobs/job.go +++ b/internal/jobs/job.go @@ -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