support pytest results

This commit is contained in:
AJ ONeal 2021-02-25 18:41:35 -07:00
parent e7b1ceaf14
commit 221c85685f
7 changed files with 319 additions and 34 deletions

View File

@ -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 POST /api/admin/jobs
{ "job_id": "xxxx", "kill": true } { "job_id": "xxxx", "kill": true }

View 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)
}
}

View File

@ -19,6 +19,7 @@ import (
"github.com/go-chi/chi" "github.com/go-chi/chi"
) )
// HTTPError for http errors
type HTTPError struct { type HTTPError struct {
Success bool `json:"success"` Success bool `json:"success"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
@ -26,8 +27,9 @@ type HTTPError struct {
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
} }
type Report struct { // WrappedReport wraps results
Report *jobs.Report `json:"report"` type WrappedReport struct {
Report jobs.Result `json:"report"`
} }
// Route will set up the API and such // Route will set up the API and such
@ -116,6 +118,7 @@ func RouteStopped(r chi.Router, runOpts *options.ServerConfig) {
return return
} }
// copies unused lock value
jobCopy := *j jobCopy := *j
logs := []jobs.Log{} logs := []jobs.Log{}
for _, log := range j.Logs { 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) { r.Post("/jobs/{jobID}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
report := WrappedReport{}
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
report := &Report{} format := r.URL.Query().Get("format")
if err := decoder.Decode(report); nil != err {
w.WriteHeader(http.StatusBadRequest) switch format {
writeError(w, &HTTPError{ case "pytest":
Code: "E_PARSE", pyresult := PyResult{}
Message: "could not parse request body", if err := decoder.Decode(&pyresult); nil != err {
Detail: err.Error(), w.WriteHeader(http.StatusBadRequest)
}) writeError(w, &HTTPError{
return 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")) 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) w.WriteHeader(http.StatusInternalServerError)
writeError(w, &HTTPError{ writeError(w, &HTTPError{
Code: "E_SERVER", 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) { func ParseSince(sinceStr string) (time.Time, *HTTPError) {
if 0 == len(sinceStr) { if 0 == len(sinceStr) {
return time.Time{}, &HTTPError{ return time.Time{}, &HTTPError{
@ -303,6 +326,7 @@ func ParseSince(sinceStr string) (time.Time, *HTTPError) {
return t, nil return t, nil
} }
// ParseUnixTime turns a string of fractional seconds into time.Time
func ParseUnixTime(seconds string) (time.Time, error) { func ParseUnixTime(seconds string) (time.Time, error) {
secs, nano, err := ParseSeconds(seconds) secs, nano, err := ParseSeconds(seconds)
if nil != err { if nil != err {
@ -312,6 +336,7 @@ func ParseUnixTime(seconds string) (time.Time, error) {
return time.Unix(secs, nano), nil return time.Unix(secs, nano), nil
} }
// ParseSeconds turns a string of fractional seconds into Seconds and Nanoseconds
func ParseSeconds(s string) (int64, int64, error) { func ParseSeconds(s string) (int64, int64, error) {
seconds, err := strconv.ParseFloat(s, 64) seconds, err := strconv.ParseFloat(s, 64)
if nil != err { if nil != err {
@ -321,6 +346,7 @@ func ParseSeconds(s string) (int64, int64, error) {
return secs, nanos, nil return secs, nanos, nil
} }
// SecondsToInts turns a float64 Second into Seconds and Nanoseconds
func SecondsToInts(seconds float64) (int64, int64) { func SecondsToInts(seconds float64) (int64, int64) {
secs := math.Floor(seconds) secs := math.Floor(seconds)
nanos := math.Round((seconds - secs) * 1000000000) nanos := math.Round((seconds - secs) * 1000000000)

View File

@ -110,8 +110,8 @@ func TestCallback(t *testing.T) {
} }
job := &jobs.Job{} job := &jobs.Job{}
dec := json.NewDecoder(resp.Body) b, _ := ioutil.ReadAll(resp.Body)
if err := dec.Decode(job); nil != err { if err := json.Unmarshal(b, job); nil != err {
t.Errorf( t.Errorf(
"response decode error: %d %s\n%#v\n%#v", "response decode error: %d %s\n%#v\n%#v",
resp.StatusCode, reqURL, resp.Header, err, resp.StatusCode, reqURL, resp.Header, err,
@ -120,7 +120,7 @@ func TestCallback(t *testing.T) {
} }
if len(job.Logs) < 3 { 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 return
} }

177
internal/api/pytest.go Normal file
View 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"
}
}
]
}
*/

View File

@ -7,19 +7,30 @@ echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Started at ${GIT_DEPLOY_TIMESTAMP:-}"
sleep ${GIT_DEPLOY_TEST_WAIT:-0.1} sleep ${GIT_DEPLOY_TEST_WAIT:-0.1}
echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Finished" echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Finished"
echo "Reporting to ${GIT_DEPLOY_CALLBACK_URL} ..." echo "Reporting to '${GIT_DEPLOY_CALLBACK_URL:-}' ..."
curl -fsSL "${GIT_DEPLOY_CALLBACK_URL}" \ #curl -fsSL "${GIT_DEPLOY_CALLBACK_URL}" \
curl -fL "${GIT_DEPLOY_CALLBACK_URL}?format=pytest" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d ' -d '
{ "report": { "exitcode": 0,
{ "name": "sleep test", "root": "/home/app/srv/status.example.com/e2e-selenium",
"status": "PASS", "tests": [
"message": "a top level result group", { "nodeid": "pytest::idthing",
"results": [ "outcome": "passed"
{ "name": "sub test", "status": "PASS", "message": "a sub group", "detail": "logs or smth" }
]
} }
]
} }
' '
# -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" echo "[${GIT_REPO_ID:-}#${GIT_REF_NAME:-}] Generated Report"

View File

@ -43,19 +43,22 @@ type Job struct {
ExitCode *int `json:"exit_code,omitempty"` // empty when running ExitCode *int `json:"exit_code,omitempty"` // empty when running
// full json // full json
Logs []Log `json:"logs,omitempty"` // exist when requested 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 // internal only
cmd *exec.Cmd `json:"-"` cmd *exec.Cmd `json:"-"`
mux sync.Mutex `json:"-"` mux sync.Mutex `json:"-"`
} }
// Report should have many items // TODO move cmd and mux here
type Report struct { // type LockingJob struct { }
Name string `json:"name"`
Status string `json:"status,omitempty"` // Result may have many items and sub-items
Message string `json:"message,omitempty"` type Result struct {
Detail string `json:"detail,omitempty"` Name string `json:"name"`
Results []Report `json:"results,omitempty"` Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Results []Result `json:"results,omitempty"`
Detail interface{} `json:"_detail,omitempty"`
} }
var initialized = false var initialized = false
@ -193,7 +196,7 @@ func All(then time.Time) []*Job {
} }
// SetReport will update jobs' logs // 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)) b, err := base64.RawURLEncoding.DecodeString(string(urlRefID))
if nil != err { if nil != err {
return err return err
@ -206,7 +209,7 @@ func SetReport(urlRefID webhooks.URLSafeRefID, report *Report) error {
} }
job := value.(*Job) job := value.(*Job)
job.Report = report job.Report = result
Actives.Store(refID, job) Actives.Store(refID, job)
return nil return nil