From bb39eaaed31306a7d29cee4cee75092c0d6a2191 Mon Sep 17 00:00:00 2001 From: Shulhan Date: Wed, 11 Dec 2024 02:32:06 +0700 Subject: lib/play: implement function to test Go code The Test and HTTPHandleTest functions accept Request with File and Body fields. --- lib/play/command.go | 34 +++++++++++++ lib/play/http.go | 59 ++++++++++++++++++++++ lib/play/http_example_test.go | 65 ++++++++++++++++++++++++ lib/play/http_test.go | 82 ++++++++++++++++++++++++++++++ lib/play/play.go | 84 ++++++++++++++++++++++++++++--- lib/play/play_example_test.go | 32 ++++++++++++ lib/play/play_test.go | 59 ++++++++++++++++++++++ lib/play/request.go | 5 ++ lib/play/testdata/.gitignore | 1 + lib/play/testdata/httpHandleTest_test.txt | 53 +++++++++++++++++++ lib/play/testdata/test.go | 12 +++++ lib/play/testdata/test_test.txt | 71 ++++++++++++++++++++++++++ 12 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 lib/play/http.go create mode 100644 lib/play/http_example_test.go create mode 100644 lib/play/http_test.go create mode 100644 lib/play/testdata/.gitignore create mode 100644 lib/play/testdata/httpHandleTest_test.txt create mode 100644 lib/play/testdata/test.go create mode 100644 lib/play/testdata/test_test.txt diff --git a/lib/play/command.go b/lib/play/command.go index 4413b9ff..61b1bfb8 100644 --- a/lib/play/command.go +++ b/lib/play/command.go @@ -55,6 +55,40 @@ func newCommand(req *Request, workingDir string) (cmd *command, err error) { return cmd, nil } +func newTestCommand(treq *Request) (cmd *command, err error) { + cmd = &command{ + buf: &bytes.Buffer{}, + pid: make(chan int, 1), + } + var ctxParent = context.Background() + cmd.ctx, cmd.ctxCancel = context.WithTimeout(ctxParent, Timeout) + + var userHomeDir string + + userHomeDir, err = os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf(`newCommand: %w`, err) + } + + var listArg = []string{`test`, `-count=1`} + if !treq.WithoutRace { + listArg = append(listArg, `-race`) + } + listArg = append(listArg, `.`) + + cmd.execGoRun = exec.CommandContext(cmd.ctx, `go`, listArg...) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, `CGO_ENABLED=1`) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, `HOME=`+userHomeDir) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, + `PATH=/usr/bin:/usr/local/bin`) + cmd.execGoRun.Dir = treq.UnsafeRun + cmd.execGoRun.Stdout = cmd.buf + cmd.execGoRun.Stderr = cmd.buf + cmd.execGoRun.WaitDelay = 100 * time.Millisecond + + return cmd, nil +} + // run the command using [exec.Command.Start] and [exec.Command.Wait]. // The Start method is used to get the process ID. // When the Start or Wait failed, it will write the error or ProcessState diff --git a/lib/play/http.go b/lib/play/http.go new file mode 100644 index 00000000..34cc618d --- /dev/null +++ b/lib/play/http.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "encoding/json" + "log" + "net/http" + + liberrors "git.sr.ht/~shulhan/pakakeh.go/lib/errors" + libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" +) + +// HTTPHandleTest define the HTTP handler for testing Go code. +// Each client is identified by unique cookie, so if two Run requests come +// from the same client, the previous Test will be cancelled. +func HTTPHandleTest(httpresw http.ResponseWriter, httpreq *http.Request) { + var ( + logp = `HTTPHandleTest` + + treq *Request + resp *libhttp.EndpointResponse + rawb []byte + err error + ) + + treq, resp = readRequest(httpreq) + if resp != nil { + goto out + } + + rawb, err = Test(treq) + if err != nil { + resp = &libhttp.EndpointResponse{ + E: liberrors.E{ + Message: err.Error(), + Name: `ERR_INTERNAL`, + Code: http.StatusInternalServerError, + }, + } + goto out + } + + http.SetCookie(httpresw, treq.cookieSid) + resp = &libhttp.EndpointResponse{} + resp.Code = http.StatusOK + resp.Data = string(rawb) +out: + rawb, err = json.Marshal(resp) + if err != nil { + log.Printf(`%s: %s`, logp, err) + resp.Code = http.StatusInternalServerError + } + httpresw.Header().Set(libhttp.HeaderContentType, libhttp.ContentTypeJSON) + httpresw.WriteHeader(resp.Code) + httpresw.Write(rawb) +} diff --git a/lib/play/http_example_test.go b/lib/play/http_example_test.go new file mode 100644 index 00000000..6752e175 --- /dev/null +++ b/lib/play/http_example_test.go @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "regexp" +) + +func ExampleHTTPHandleTest() { + const code = ` +package test +import "testing" +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf("got %d, want 6", total) + } +}` + var req = Request{ + Body: code, + File: `testdata/test_test.go`, + } + var ( + rawbody []byte + err error + ) + rawbody, err = json.Marshal(&req) + if err != nil { + log.Fatal(err) + } + + var mux = http.NewServeMux() + + mux.HandleFunc(`POST /api/play/test`, HTTPHandleTest) + + var resprec = httptest.NewRecorder() + var httpreq = httptest.NewRequest(`POST`, `/api/play/test`, + bytes.NewReader(rawbody)) + httpreq.Header.Set(`Content-Type`, `application/json`) + + mux.ServeHTTP(resprec, httpreq) + var resp = resprec.Result() + + rawbody, err = io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + var rexDuration = regexp.MustCompile(`(?m)\\t(\d+\.\d+)s`) + rawbody = rexDuration.ReplaceAll(rawbody, []byte(`\tXs`)) + + fmt.Printf(`%s`, rawbody) + + // Output: + // {"data":"ok \tgit.sr.ht/~shulhan/pakakeh.go/lib/play/testdata\tXs\n","code":200} +} diff --git a/lib/play/http_test.go b/lib/play/http_test.go new file mode 100644 index 00000000..7f79bcab --- /dev/null +++ b/lib/play/http_test.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "net/http/httputil" + "regexp" + "testing" + + libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +func TestHTTPHandleTest(t *testing.T) { + type testCase struct { + tag string + contentType string + req Request + } + + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/httpHandleTest_test.txt`) + if err != nil { + t.Fatal(err) + } + + var listCase = []testCase{{ + tag: `noContentType`, + }, { + tag: `ok`, + contentType: libhttp.ContentTypeJSON, + req: Request{ + File: `testdata/test_test.go`, + }, + }, { + tag: `invalidFile`, + contentType: libhttp.ContentTypeJSON, + req: Request{ + File: `testdata/notexist/test_test.go`, + }, + }} + + var ( + rexDuration = regexp.MustCompile(`(?m)\\t(\d+\.\d+)s`) + tcase testCase + rawb []byte + ) + for _, tcase = range listCase { + tcase.req.Body = string(tdata.Input[tcase.tag]) + + rawb, err = json.Marshal(&tcase.req) + if err != nil { + t.Fatal(err) + } + + var httpReq = httptest.NewRequest(`POST`, `/`, bytes.NewReader(rawb)) + httpReq.Header.Set(libhttp.HeaderContentType, tcase.contentType) + + var httpWriter = httptest.NewRecorder() + + HTTPHandleTest(httpWriter, httpReq) + + var httpResp = httpWriter.Result() + rawb, err = httputil.DumpResponse(httpResp, true) + if err != nil { + t.Fatal(err) + } + rawb = bytes.ReplaceAll(rawb, []byte("\r"), []byte("")) + rawb = rexDuration.ReplaceAll(rawb, []byte(" Xs")) + + var exp = string(tdata.Output[tcase.tag]) + test.Assert(t, tcase.tag, exp, string(rawb)) + } +} diff --git a/lib/play/play.go b/lib/play/play.go index 3f01035d..aa6b9ab4 100644 --- a/lib/play/play.go +++ b/lib/play/play.go @@ -2,13 +2,17 @@ // // SPDX-License-Identifier: BSD-3-Clause -// Package play provides callable APIs and HTTP handlers to format and run -// Go code, similar to Go playground but using HTTP instead of WebSocket. +// Package play provides callable APIs and HTTP handlers to format, run, and +// test Go code, similar to Go playground but using HTTP instead of +// WebSocket. // -// For HTTP API, this package expose two handlers: [HTTPHandleFormat] and -// [HTTPHandleRun]. -// Both HTTP APIs accept JSON content type, with the following request -// format, +// For HTTP API, this package expose handlers: [HTTPHandleFormat], +// [HTTPHandleRun], and [HTTPHandleTest]. +// +// # Formatting and running Go code +// +// HTTP APIs for formatting and running Go code accept JSON content type, +// with the following request format, // // { // "goversion": , // For run only. @@ -39,6 +43,8 @@ // running the Go code, the "message" contains an error pre-Run, like bad // request or file system related error. // +// # Unsafe run +// // As exceptional, the [Run] and [HTTPHandleRun] accept the following // request for running program inside custom "go.mod", // @@ -52,10 +58,34 @@ // and then run "go run ." directly. // Go code that executed inside "unsafe_run" should be not modifiable and // safe from mallicious execution. +// +// # Testing +// +// For testing, since the test must run inside the directory that contains +// the Go file to be tested, the [HTTPHandleTest] API accept the following +// request format, +// +// { +// "goversion": , +// "file": , +// "body": , +// "without_race": +// } +// +// The "file" field define the path to the "_test.go" file, default to +// "test_test.go" if its empty. +// The "body" field contains the Go code that will be saved to +// "file". +// The test will run, by default, with "go test -count=1 -race $dirname" +// where "$dirname" is the path directory to the "file" relative to where +// the program is running. +// If "without_race" is true, the test command will not run with "-race" +// option. package play import ( "encoding/json" + "errors" "fmt" "io" "log" @@ -64,11 +94,16 @@ import ( "path/filepath" "time" + "golang.org/x/tools/imports" + liberrors "git.sr.ht/~shulhan/pakakeh.go/lib/errors" libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" - "golang.org/x/tools/imports" ) +// ErrEmptyFile error when running [Test] with empty File field in the +// [Request]. +var ErrEmptyFile = errors.New(`empty File`) + // GoVersion define the Go tool version for go.mod to be used to run the // code. var GoVersion = `1.23.2` @@ -329,3 +364,38 @@ func unsafeRun(req *Request) (out []byte, err error) { return out, nil } + +// Test the Go code in the [Request.Body]. +func Test(req *Request) (out []byte, err error) { + var logp = `Test` + + req.init() + + var cmd *command = runningCmd.get(req.cookieSid.Value) + if cmd != nil { + cmd.ctxCancel() + runningCmd.delete(req.cookieSid.Value) + } + + if len(req.File) == 0 { + return nil, ErrEmptyFile + } + if len(req.UnsafeRun) == 0 { + req.UnsafeRun = filepath.Dir(req.File) + } + + err = os.WriteFile(req.File, []byte(req.Body), 0600) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + cmd, err = newTestCommand(req) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + runningCmd.store(req.cookieSid.Value, cmd) + + out = cmd.run() + + return out, nil +} diff --git a/lib/play/play_example_test.go b/lib/play/play_example_test.go index 91f8817b..c89d46e8 100644 --- a/lib/play/play_example_test.go +++ b/lib/play/play_example_test.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "net/http/httptest" + "regexp" ) func ExampleFormat() { @@ -149,3 +150,34 @@ func main() { //Output: //Hello, world } + +func ExampleTest() { + const codeTest = ` +package test +import "testing" +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf("got %d, want 6", total) + } +}` + var req = Request{ + Body: codeTest, + File: `testdata/test_test.go`, + } + var ( + rexDuration = regexp.MustCompile(`(?m)\s+(\d+\.\d+)s$`) + out []byte + err error + ) + out, err = Test(&req) + if err != nil { + fmt.Printf(`error: %s`, err) + } + // Replace the test duration. + out = rexDuration.ReplaceAll(out, []byte(" Xs")) + fmt.Printf(`%s`, out) + + //Output: + //ok git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata Xs +} diff --git a/lib/play/play_test.go b/lib/play/play_test.go index f9acf460..48075347 100644 --- a/lib/play/play_test.go +++ b/lib/play/play_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "net/http/httputil" "os" + "regexp" "strings" "sync" "syscall" @@ -338,3 +339,61 @@ func TestRunUnsafeRun(t *testing.T) { var exp = "Hello...\n" test.Assert(t, `unsafeRun`, exp, string(out)) } + +func TestTest(t *testing.T) { + type testCase struct { + tag string + exp string + expError string + treq Request + } + + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/test_test.txt`) + if err != nil { + t.Fatal(err) + } + + var listCase = []testCase{{ + tag: `ok`, + treq: Request{ + File: `testdata/test_test.go`, + }, + }, { + tag: `fail`, + treq: Request{ + File: `testdata/test_test.go`, + }, + }, { + tag: `buildFailed`, + treq: Request{ + File: `testdata/test_test.go`, + }, + }, { + tag: `emptyFile`, + expError: ErrEmptyFile.Error(), + }} + + var rexDuration = regexp.MustCompile(`(?m)\s+(\d+\.\d+)s$`) + + var ( + tcase testCase + exp string + got []byte + ) + for _, tcase = range listCase { + tcase.treq.Body = string(tdata.Input[tcase.tag]) + tcase.treq.init() + + got, err = Test(&tcase.treq) + if err != nil { + test.Assert(t, tcase.tag, tcase.expError, err.Error()) + } + got = rexDuration.ReplaceAll(got, []byte(" Xs")) + exp = string(tdata.Output[tcase.tag]) + test.Assert(t, tcase.tag, exp, string(got)) + } +} diff --git a/lib/play/request.go b/lib/play/request.go index 3bd8cfb5..e2598516 100644 --- a/lib/play/request.go +++ b/lib/play/request.go @@ -13,6 +13,7 @@ import ( ) const cookieNameSid = `sid` +const defTestFile = `test_test.go` // Request for calling [Format] and [Run]. type Request struct { @@ -26,6 +27,10 @@ type Request struct { // The Go version that will be used in go.mod. GoVersion string `json:"goversion"` + // File define the path to test "_test.go" file. + // This field is for Test. + File string `json:"file"` + // Body contains the Go code to be Format-ed or Run. Body string `json:"body"` diff --git a/lib/play/testdata/.gitignore b/lib/play/testdata/.gitignore new file mode 100644 index 00000000..917e3a48 --- /dev/null +++ b/lib/play/testdata/.gitignore @@ -0,0 +1 @@ +/test_test.go diff --git a/lib/play/testdata/httpHandleTest_test.txt b/lib/play/testdata/httpHandleTest_test.txt new file mode 100644 index 00000000..5fffc365 --- /dev/null +++ b/lib/play/testdata/httpHandleTest_test.txt @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + + +>>> noContentType +{} + +<<< noContentType +HTTP/1.1 415 Unsupported Media Type +Connection: close +Content-Type: application/json + +{"message":"invalid content type","name":"ERR_CONTENT_TYPE","code":415} + +>>> ok +package test + +import "testing" + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< ok +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Set-Cookie: sid=c4832036755b3539; Path=/; Max-Age=604800; SameSite=Strict + +{"data":"ok \tgit.sr.ht/~shulhan/pakakeh.go/lib/play/testdata Xs\n","code":200} + +>>> invalidFile +package test + +import "testing" + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< invalidFile +HTTP/1.1 500 Internal Server Error +Connection: close +Content-Type: application/json + +{"message":"Test: open testdata/notexist/test_test.go: no such file or directory","name":"ERR_INTERNAL","code":500} diff --git a/lib/play/testdata/test.go b/lib/play/testdata/test.go new file mode 100644 index 00000000..b6d7a61a --- /dev/null +++ b/lib/play/testdata/test.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + +package test + +func sum(listNumber ...int) (total int) { + for _, num := range listNumber { + total += num + } + return total +} diff --git a/lib/play/testdata/test_test.txt b/lib/play/testdata/test_test.txt new file mode 100644 index 00000000..1a63f300 --- /dev/null +++ b/lib/play/testdata/test_test.txt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// +// SPDX-License-Identifier: BSD-3-Clause + +>>> ok +package test + +import "testing" + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< ok +ok git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata Xs + +>>> fail +package test + +import "testing" + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3, 4) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< fail +--- FAIL: TestSum (0.00s) + test_test.go:8: got 10, want 6 +FAIL +FAIL git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata Xs +FAIL + +exit status 1 + +>>> buildFailed +package test + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< buildFailed +# git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata [git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata.test] +./test_test.go:3:17: undefined: testing +FAIL git.sr.ht/~shulhan/pakakeh.go/lib/play/testdata [build failed] +FAIL + +exit status 1 + +>>> emptyFile +package test + +import "testing" + +func TestSum(t *testing.T) { + var total = sum(1, 2, 3) + if total != 6 { + t.Fatalf(`got %d, want 6`, total) + } +} + +<<< emptyFile -- cgit v1.3