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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
|
@ -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…
Reference in New Issue