summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2024-12-11 02:32:06 +0700
committerShulhan <ms@kilabit.info>2024-12-28 14:37:31 +0700
commitbb39eaaed31306a7d29cee4cee75092c0d6a2191 (patch)
tree079997bdf4cfa74c98c8a94f8a2818c8fb7f267a
parent632c367b5ad48df8d223bab64daf2e23ceaccabb (diff)
downloadpakakeh.go-bb39eaaed31306a7d29cee4cee75092c0d6a2191.tar.xz
lib/play: implement function to test Go code
The Test and HTTPHandleTest functions accept Request with File and Body fields.
-rw-r--r--lib/play/command.go34
-rw-r--r--lib/play/http.go59
-rw-r--r--lib/play/http_example_test.go65
-rw-r--r--lib/play/http_test.go82
-rw-r--r--lib/play/play.go84
-rw-r--r--lib/play/play_example_test.go32
-rw-r--r--lib/play/play_test.go59
-rw-r--r--lib/play/request.go5
-rw-r--r--lib/play/testdata/.gitignore1
-rw-r--r--lib/play/testdata/httpHandleTest_test.txt53
-rw-r--r--lib/play/testdata/test.go12
-rw-r--r--lib/play/testdata/test_test.txt71
12 files changed, 550 insertions, 7 deletions
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 <ms@kilabit.info>
+//
+// 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 <ms@kilabit.info>
+//
+// 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 <ms@kilabit.info>
+//
+// 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": <string>, // 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": <string>,
+// "file": <string>,
+// "body": <string>,
+// "without_race": <boolean>
+// }
+//
+// 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 <ms@kilabit.info>
+//
+// 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 <ms@kilabit.info>
+//
+// 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 <ms@kilabit.info>
+//
+// 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