From 8351a3b157537f3b46ed8b60a2b06d52d341c537 Mon Sep 17 00:00:00 2001 From: xhd2015 Date: Mon, 20 May 2024 19:00:53 +0800 Subject: [PATCH] add xgo tool test-explorer --- cmd/go-tool-test-explorer/main.go | 16 + cmd/xgo/runtime_gen/core/version.go | 4 +- cmd/xgo/test-explorer/index.html | 25 ++ cmd/xgo/test-explorer/test_explorer.go | 410 ++++++++++++++++++ cmd/xgo/tool.go | 4 + cmd/xgo/version.go | 4 +- runtime/core/version.go | 4 +- .../trap_stdlib_any/trap_stdlib_any_test.go | 1 + support/fileutil/stat.go | 28 ++ support/fileutil/walk.go | 25 ++ support/goinfo/find.go | 37 ++ support/goinfo/find_test.go | 29 ++ support/netutil/http.go | 81 ++++ support/netutil/netutil.go | 9 + .../func_ptr_test.go | 18 +- 15 files changed, 680 insertions(+), 15 deletions(-) create mode 100644 cmd/go-tool-test-explorer/main.go create mode 100644 cmd/xgo/test-explorer/index.html create mode 100644 cmd/xgo/test-explorer/test_explorer.go create mode 100644 support/fileutil/stat.go create mode 100644 support/fileutil/walk.go create mode 100644 support/goinfo/find.go create mode 100644 support/goinfo/find_test.go create mode 100644 support/netutil/http.go diff --git a/cmd/go-tool-test-explorer/main.go b/cmd/go-tool-test-explorer/main.go new file mode 100644 index 00000000..68a7bb99 --- /dev/null +++ b/cmd/go-tool-test-explorer/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" + + test_explorer "github.com/xhd2015/xgo/cmd/xgo/test-explorer" +) + +func main() { + err := test_explorer.Main(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "%v", err) + os.Exit(1) + } +} diff --git a/cmd/xgo/runtime_gen/core/version.go b/cmd/xgo/runtime_gen/core/version.go index 0b861864..375e9370 100755 --- a/cmd/xgo/runtime_gen/core/version.go +++ b/cmd/xgo/runtime_gen/core/version.go @@ -7,8 +7,8 @@ import ( ) const VERSION = "1.0.36" -const REVISION = "e44373cb3c83b85599797e1f0cb302f81a95d598+1" -const NUMBER = 225 +const REVISION = "b1fa6d6f3a19df8888bf2c0eb103ddff88257582+1" +const NUMBER = 226 // these fields will be filled by compiler const XGO_VERSION = "" diff --git a/cmd/xgo/test-explorer/index.html b/cmd/xgo/test-explorer/index.html new file mode 100644 index 00000000..ddb2e2b7 --- /dev/null +++ b/cmd/xgo/test-explorer/index.html @@ -0,0 +1,25 @@ + + + + + + Document + + + + +
+ + + + + + \ No newline at end of file diff --git a/cmd/xgo/test-explorer/test_explorer.go b/cmd/xgo/test-explorer/test_explorer.go new file mode 100644 index 00000000..06bc8bee --- /dev/null +++ b/cmd/xgo/test-explorer/test_explorer.go @@ -0,0 +1,410 @@ +package test_explorer + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "io/ioutil" + "net/http" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/xhd2015/xgo/support/cmd" + "github.com/xhd2015/xgo/support/fileutil" + "github.com/xhd2015/xgo/support/netutil" +) + +func Main(args []string) error { + return handle() +} + +// NOTE: case can have sub childrens + +type TestingItemKind string + +const ( + TestingItemKind_Dir = "dir" + TestingItemKind_Case = "case" +) + +type RunStatus string + +const ( + RunStatus_NotRun RunStatus = "not_run" + RunStatus_Success RunStatus = "success" + RunStatus_Fail RunStatus = "fail" + RunStatus_Error RunStatus = "error" + RunStatus_Running RunStatus = "running" + RunStatus_Skip RunStatus = "skip" +) + +type TestingItem struct { + Name string `json:"name"` + File string `json:"file"` + Line int `json:"line"` + Kind TestingItemKind `json:"kind"` + Error string `json:"error"` + Children []*TestingItem `json:"children"` +} + +type BaseRequest struct { + Name string `json:"name"` + File string `json:"file"` +} + +type DetailRequest struct { + *BaseRequest + Line int `json:"line"` +} + +type RunRequest struct { + *BaseRequest + Path []string `json:"path"` + Verbose bool `json:"verbose"` +} + +type RunResult struct { + Status RunStatus `json:"status"` + Msg string `json:"msg"` +} + +//go:embed index.html +var indexHTML string + +const apiPlaceholder = "http://localhost:8080" + +func handle() error { + server := &http.ServeMux{} + var url string + server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(strings.ReplaceAll(indexHTML, apiPlaceholder, url))) + }) + server.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + q := r.URL.Query() + dir := q.Get("dir") + root, err := scanTests(dir) + if err != nil { + return nil, err + } + return []*TestingItem{root}, nil + }) + }) + + server.HandleFunc("/detail", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *DetailRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req == nil { + req = &DetailRequest{} + } + if req.BaseRequest == nil { + req.BaseRequest = &BaseRequest{} + } + q := r.URL.Query() + file := q.Get("file") + if file != "" { + req.BaseRequest.File = file + } + name := q.Get("name") + if name != "" { + req.BaseRequest.Name = name + } + line := q.Get("line") + if line != "" { + lineNum, err := strconv.Atoi(line) + if err != nil { + return nil, netutil.ParamErrorf("line: %v", err) + } + req.Line = lineNum + } + return getDetail(req) + }) + }) + + server.HandleFunc("/run", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + var req *RunRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + + return run(req) + }) + }) + server.HandleFunc("/openVscode", func(w http.ResponseWriter, r *http.Request) { + netutil.SetCORSHeaders(w) + netutil.HandleJSON(w, r, func(ctx context.Context, r *http.Request) (interface{}, error) { + q := r.URL.Query() + file := q.Get("file") + line := q.Get("line") + + err := cmd.Run("code", "--goto", fmt.Sprintf("%s:%s", file, line)) + return nil, err + }) + }) + + return netutil.ServePortHTTP(server, 7070, true, 500*time.Millisecond, func(port int) { + url = fmt.Sprintf("http://localhost:%d", port) + fmt.Printf("Server listen at %s\n", url) + openURL(url) + }) +} + +func openURL(url string) { + openCmd := "open" + if runtime.GOOS == "windows" { + openCmd = "explorer" + } + cmd.Run(openCmd, url) +} + +func parseBody(r io.Reader, req interface{}) error { + if r == nil { + return nil + } + data, err := ioutil.ReadAll(r) + if err != nil { + return err + } + if len(data) == 0 { + return nil + } + return json.Unmarshal(data, req) +} + +func scanTests(dir string) (*TestingItem, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + root := &TestingItem{ + Name: filepath.Base(absDir), + File: absDir, + Kind: TestingItemKind_Dir, + } + itemMapping := make(map[string]*TestingItem) + itemMapping[absDir] = root + + getParent := func(path string) (*TestingItem, error) { + parent := itemMapping[filepath.Dir(path)] + if parent == nil { + return nil, fmt.Errorf("item mapping not found: %s", filepath.Dir(path)) + } + return parent, nil + } + err = fileutil.WalkRelative(absDir, func(path, relPath string, d fs.DirEntry) error { + if relPath == "" { + return nil + } + if d.IsDir() { + // vendor inside root + if relPath == "vendor" { + return filepath.SkipDir + } + + hasGoMod, err := fileutil.FileExists(filepath.Join(path, "go.mod")) + if err != nil { + return err + } + if hasGoMod { + // sub project + return filepath.SkipDir + } + parent, err := getParent(path) + if err != nil { + return err + } + item := &TestingItem{ + Name: filepath.Base(relPath), + File: path, + Kind: TestingItemKind_Dir, + } + itemMapping[path] = item + parent.Children = append(parent.Children, item) + return nil + } + if !strings.HasSuffix(path, "_test.go") { + return nil + } + + parent, err := getParent(path) + if err != nil { + return err + } + item := &TestingItem{ + Name: filepath.Base(relPath), + File: path, + Kind: TestingItemKind_Dir, + } + itemMapping[path] = item + parent.Children = append(parent.Children, item) + + tests, parseErr := parseTests(path) + if parseErr != nil { + item.Error = parseErr.Error() + } else { + // TODO: what if test case name same with sub dir? + item.Children = append(item.Children, tests...) + } + return nil + }) + + if err != nil { + return nil, err + } + + // filter items without + // any tests + filterItem(root) + return root, nil +} + +type DetailResponse struct { + Content string `json:"content"` +} + +func getDetail(req *DetailRequest) (*DetailResponse, error) { + if req == nil || req.BaseRequest == nil || req.File == "" { + return nil, netutil.ParamErrorf("requires file") + } + if req.Name == "" { + return nil, netutil.ParamErrorf("requires name") + } + + fset, decls, err := parseTestFuncs(req.File) + if err != nil { + return nil, err + } + var found *ast.FuncDecl + for _, decl := range decls { + if decl.Name != nil && decl.Name.Name == req.Name { + found = decl + break + } + } + if found == nil { + return nil, netutil.ParamErrorf("not found: %s", req.Name) + } + content, err := ioutil.ReadFile(req.File) + if err != nil { + return nil, err + } + i := fset.Position(found.Pos()).Offset + j := fset.Position(found.End()).Offset + return &DetailResponse{ + Content: string(content)[i:j], + }, nil +} +func run(req *RunRequest) (*RunResult, error) { + if req == nil || req.BaseRequest == nil || req.File == "" { + return nil, fmt.Errorf("requires file") + } + if req.Name == "" { + return nil, fmt.Errorf("requires name") + } + // fmt.Printf("run:%v\n", req) + var buf bytes.Buffer + args := []string{"test", "-run", fmt.Sprintf("^%s$", req.Name)} + if req.Verbose { + args = append(args, "-v") + } + runErr := cmd.Dir(filepath.Dir(req.File)).Stderr(&buf).Stdout(&buf).Run("xgo", args...) + if runErr != nil { + return &RunResult{ + Status: RunStatus_Fail, + Msg: buf.String(), + }, nil + } + + return &RunResult{ + Status: RunStatus_Success, + Msg: buf.String(), + }, nil +} + +func filterItem(item *TestingItem) *TestingItem { + if item == nil { + return nil + } + + children := item.Children + n := len(children) + i := 0 + for j := 0; j < n; j++ { + child := filterItem(children[j]) + if child != nil { + children[i] = child + i++ + } + } + item.Children = children[:i] + if i == 0 && item.Kind != TestingItemKind_Case { + return nil + } + return item +} + +func parseTests(file string) ([]*TestingItem, error) { + fset, decls, err := parseTestFuncs(file) + if err != nil { + return nil, err + } + items := make([]*TestingItem, 0, len(decls)) + for _, fnDecl := range decls { + items = append(items, &TestingItem{ + Name: fnDecl.Name.Name, + File: file, + Line: fset.Position(fnDecl.Pos()).Line, + Kind: TestingItemKind_Case, + }) + } + return items, nil +} + +func parseTestFuncs(file string) (*token.FileSet, []*ast.FuncDecl, error) { + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, file, nil, parser.ParseComments) + if err != nil { + return nil, nil, err + } + var results []*ast.FuncDecl + for _, decl := range astFile.Decls { + fnDecl, ok := decl.(*ast.FuncDecl) + if !ok { + continue + } + if fnDecl.Name == nil { + continue + } + if !strings.HasPrefix(fnDecl.Name.Name, "Test") { + continue + } + if fnDecl.Body == nil { + continue + } + if fnDecl.Type.Params == nil || len(fnDecl.Type.Params.List) != 1 { + continue + } + results = append(results, fnDecl) + } + return fset, results, nil +} diff --git a/cmd/xgo/tool.go b/cmd/xgo/tool.go index 06671a44..6cb618eb 100644 --- a/cmd/xgo/tool.go +++ b/cmd/xgo/tool.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/xhd2015/xgo/cmd/xgo/coverage" + test_explorer "github.com/xhd2015/xgo/cmd/xgo/test-explorer" "github.com/xhd2015/xgo/cmd/xgo/trace" "github.com/xhd2015/xgo/support/cmd" ) @@ -22,6 +23,9 @@ func handleTool(tool string, args []string) error { coverage.Main(args) return nil } + if tool == "test-explorer" { + return test_explorer.Main(args) + } tools := []string{ tool, } diff --git a/cmd/xgo/version.go b/cmd/xgo/version.go index 4487f53a..408402c6 100644 --- a/cmd/xgo/version.go +++ b/cmd/xgo/version.go @@ -3,8 +3,8 @@ package main import "fmt" const VERSION = "1.0.36" -const REVISION = "e44373cb3c83b85599797e1f0cb302f81a95d598+1" -const NUMBER = 225 +const REVISION = "b1fa6d6f3a19df8888bf2c0eb103ddff88257582+1" +const NUMBER = 226 func getRevision() string { revSuffix := "" diff --git a/runtime/core/version.go b/runtime/core/version.go index 0b861864..375e9370 100644 --- a/runtime/core/version.go +++ b/runtime/core/version.go @@ -7,8 +7,8 @@ import ( ) const VERSION = "1.0.36" -const REVISION = "e44373cb3c83b85599797e1f0cb302f81a95d598+1" -const NUMBER = 225 +const REVISION = "b1fa6d6f3a19df8888bf2c0eb103ddff88257582+1" +const NUMBER = 226 // these fields will be filled by compiler const XGO_VERSION = "" diff --git a/runtime/test/trap_stdlib_any/trap_stdlib_any_test.go b/runtime/test/trap_stdlib_any/trap_stdlib_any_test.go index d932186b..4c5da587 100644 --- a/runtime/test/trap_stdlib_any/trap_stdlib_any_test.go +++ b/runtime/test/trap_stdlib_any/trap_stdlib_any_test.go @@ -73,6 +73,7 @@ func TestTrapStdlibFuncs(t *testing.T) { }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { testTrapStdlib(t, tt.fn, tt.call) }) diff --git a/support/fileutil/stat.go b/support/fileutil/stat.go new file mode 100644 index 00000000..0c3770b5 --- /dev/null +++ b/support/fileutil/stat.go @@ -0,0 +1,28 @@ +package fileutil + +import ( + "errors" + "os" +) + +func FileExists(file string) (bool, error) { + stat, err := os.Stat(file) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return !stat.IsDir(), nil +} + +func DirExists(dir string) (bool, error) { + stat, err := os.Stat(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + return stat.IsDir(), nil +} diff --git a/support/fileutil/walk.go b/support/fileutil/walk.go new file mode 100644 index 00000000..af27defc --- /dev/null +++ b/support/fileutil/walk.go @@ -0,0 +1,25 @@ +package fileutil + +import ( + "io/fs" + "path/filepath" +) + +// WalkRelative: calculate relative path when walking +func WalkRelative(root string, h func(path string, relPath string, d fs.DirEntry) error) error { + cleanRoot := filepath.Clean(root) + n := len(cleanRoot) + prefixLen := n + len(string(filepath.Separator)) + return filepath.WalkDir(cleanRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + // root + if path == cleanRoot { + return h(path, "", d) + } + subPath := path[prefixLen:] + + return h(path, subPath, d) + }) +} diff --git a/support/goinfo/find.go b/support/goinfo/find.go new file mode 100644 index 00000000..d41a2993 --- /dev/null +++ b/support/goinfo/find.go @@ -0,0 +1,37 @@ +package goinfo + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +func FindGoModDir(dir string) (string, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return "", err + } + var init bool = true + for { + if !init { + parent := filepath.Dir(absDir) + if parent == absDir || parent == "" { + return "", fmt.Errorf("%s outside go module", dir) + } + absDir = parent + } + stat, err := os.Stat(filepath.Join(absDir, "go.mod")) + init = false + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return "", err + } + if stat.IsDir() { + continue + } + return absDir, nil + } +} diff --git a/support/goinfo/find_test.go b/support/goinfo/find_test.go new file mode 100644 index 00000000..51ff26ef --- /dev/null +++ b/support/goinfo/find_test.go @@ -0,0 +1,29 @@ +package goinfo + +import ( + "path/filepath" + "testing" +) + +func TestFilePathDir(t *testing.T) { + tests := []struct { + name string + dir string + want string + }{ + // NOTE: must convert dot path to abs path + {"dot", ".", "."}, + {"dot_slash", "./", "."}, + + {"root", "/", "/"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got := filepath.Dir(tt.dir) + if got != tt.want { + t.Errorf("filepath.Dir(%q) = %q, want %q", tt.dir, got, tt.want) + } + }) + } +} diff --git a/support/netutil/http.go b/support/netutil/http.go new file mode 100644 index 00000000..a7ebe63a --- /dev/null +++ b/support/netutil/http.go @@ -0,0 +1,81 @@ +package netutil + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "runtime/debug" +) + +type HttpStatusErr interface { + error + HttpStatusCode() int +} + +type badParamErr struct { + msg string +} + +func (c *badParamErr) HttpStatusCode() int { + return 400 +} + +func (c *badParamErr) Error() string { + return c.msg +} + +func ParamErrorf(format string, args ...interface{}) error { + return &badParamErr{msg: fmt.Sprintf(format, args...)} +} + +func HandleJSON(w http.ResponseWriter, r *http.Request, h func(ctx context.Context, r *http.Request) (interface{}, error)) { + if r.Method == http.MethodOptions { + return + } + var respData interface{} + var err error + defer func() { + if e := recover(); e != nil { + // print panic stack trace + stack := debug.Stack() + log.Printf("panic: %s", stack) + if pe, ok := e.(error); ok { + e = pe + } else { + err = fmt.Errorf("panic: %v", e) + } + } + var jsonData []byte + if err == nil { + jsonData, err = json.Marshal(respData) + } + + if err != nil { + log.Printf("err: %v", err) + code := 500 + if httpErr, ok := err.(HttpStatusErr); ok { + code = httpErr.HttpStatusCode() + } + w.WriteHeader(code) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }() + + respData, err = h(context.Background(), r) + if err != nil { + return + } +} + +// allow request from arbitrary host +func SetCORSHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "*") +} diff --git a/support/netutil/netutil.go b/support/netutil/netutil.go index 37ec105b..90245eb7 100644 --- a/support/netutil/netutil.go +++ b/support/netutil/netutil.go @@ -2,7 +2,9 @@ package netutil import ( "errors" + "fmt" "net" + "net/http" "strconv" "syscall" "time" @@ -17,6 +19,13 @@ func IsTCPAddrServing(url string, timeout time.Duration) (bool, error) { return true, nil } +func ServePortHTTP(server *http.ServeMux, port int, autoIncrPort bool, watchTimeout time.Duration, watch func(port int)) error { + return ServePort(port, autoIncrPort, watchTimeout, watch, func(port int) error { + return http.ListenAndServe(fmt.Sprintf(":%d", port), server) + }) +} + +// suggested watch timeout: 500ms func ServePort(port int, autoIncrPort bool, watchTimeout time.Duration, watch func(port int), doWithPort func(port int) error) error { for { serving, err := IsTCPAddrServing(net.JoinHostPort("localhost", strconv.Itoa(port)), 20*time.Millisecond) diff --git a/test/xgo_test/func_is_a_two_level_pointer/func_ptr_test.go b/test/xgo_test/func_is_a_two_level_pointer/func_ptr_test.go index 0c6ba2e8..e4a431ae 100644 --- a/test/xgo_test/func_is_a_two_level_pointer/func_ptr_test.go +++ b/test/xgo_test/func_is_a_two_level_pointer/func_ptr_test.go @@ -27,23 +27,23 @@ func mockGreet(mock func(s string) string) { fn := Greet x := (*funcptr)(unsafe.Pointer(&fn)) if false { - y := (*funcptr)(unsafe.Pointer(&mock)) - *x.pc = *y.pc + y := (*funcptr)(unsafe.Pointer(&mock)) + *x.pc = *y.pc } instructions := assembleJump(mock) - dstInstructions := *((*[]byte)unsafe.Pointer(x.pc)) - copy(dstInstructions ,instructions) + dstInstructions := *((*[]byte)(unsafe.Pointer(x.pc))) + copy(dstInstructions, instructions) } -func assembleJump(f func(s string)string) []byte { +func assembleJump(f func(s string) string) []byte { funcVal := *(*uintptr)(unsafe.Pointer(&f)) return []byte{ 0x48, 0xC7, 0xC2, - byte(funcval >> 0), - byte(funcval >> 8), - byte(funcval >> 16), - byte(funcval >> 24), // MOV rdx, funcVal + byte(funcVal >> 0), + byte(funcVal >> 8), + byte(funcVal >> 16), + byte(funcVal >> 24), // MOV rdx, funcVal 0xFF, 0x22, // JMP [rdx] } }