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
|
POST /api/admin/jobs
|
||||||
{ "job_id": "xxxx", "kill": true }
|
{ "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"
|
"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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue