From 1c4280008e66fc419f62235cefe6528437691706 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 --- README.md | 3 + README_zh_cn.md | 3 + cmd/go-tool-test-explorer/main.go | 16 + cmd/xgo/runtime_gen/core/version.go | 4 +- cmd/xgo/test-explorer/config.go | 87 +++ cmd/xgo/test-explorer/index.html | 25 + cmd/xgo/test-explorer/session.go | 458 ++++++++++++++ cmd/xgo/test-explorer/test_explorer.go | 597 ++++++++++++++++++ cmd/xgo/tool.go | 6 + cmd/xgo/version.go | 4 +- runtime/core/version.go | 4 +- .../trap_stdlib_any/trap_stdlib_any_test.go | 1 + support/fileutil/stat.go | 36 ++ support/fileutil/walk.go | 25 + support/goinfo/find.go | 57 ++ support/goinfo/find_test.go | 34 + support/goinfo/goinfo.go | 21 +- support/netutil/http.go | 81 +++ support/netutil/netutil.go | 9 + .../func_ptr_test.go | 18 +- 20 files changed, 1468 insertions(+), 21 deletions(-) create mode 100644 cmd/go-tool-test-explorer/main.go create mode 100644 cmd/xgo/test-explorer/config.go create mode 100644 cmd/xgo/test-explorer/index.html create mode 100644 cmd/xgo/test-explorer/session.go 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/README.md b/README.md index 1e66969e..19077007 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ Verify the installation: xgo version # output: # 1.0.x + +xgo help +# output: help messages ``` If `xgo` is not found, you may need to check if `$GOPATH/bin` is added to your `PATH` variable. diff --git a/README_zh_cn.md b/README_zh_cn.md index 2d2933cc..faa89fbe 100644 --- a/README_zh_cn.md +++ b/README_zh_cn.md @@ -33,6 +33,9 @@ go install github.com/xhd2015/xgo/cmd/xgo@latest xgo version # 输出: # 1.0.x + +xgo help +# 输出: xgo使用帮助 ``` 如果未找到`xgo`, 你可能需要查看`$GOPATH/bin`是否已经添加到你的`PATH`变量中。 diff --git a/cmd/go-tool-test-explorer/main.go b/cmd/go-tool-test-explorer/main.go new file mode 100644 index 00000000..c1b8d56d --- /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:], nil) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", 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/config.go b/cmd/xgo/test-explorer/config.go new file mode 100644 index 00000000..b20f7f43 --- /dev/null +++ b/cmd/xgo/test-explorer/config.go @@ -0,0 +1,87 @@ +package test_explorer + +import ( + "encoding/json" + "fmt" +) + +type TestConfig struct { + Go *GoConfig + GoCmd string + Exclude []string + Env map[string]interface{} +} + +type GoConfig struct { + Min string `json:"min"` + Max string `json:"max"` +} + +func parseTestConfig(config string) (*TestConfig, error) { + if config == "" { + return &TestConfig{}, nil + } + var m map[string]interface{} + err := json.Unmarshal([]byte(config), &m) + if err != nil { + return nil, err + } + + conf := &TestConfig{} + + e, ok := m["env"] + if ok { + e, ok := e.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("env type err, expect map[string]interface{}, actual: %T", e) + } + conf.Env = e + } + + e, ok = m["go"] + if ok { + goConf := &GoConfig{} + if s, ok := e.(string); ok { + goConf.Min = s + } else { + edata, err := json.Marshal(e) + if err != nil { + return nil, err + } + err = json.Unmarshal(edata, &goConf) + if err != nil { + return nil, err + } + } + conf.Go = goConf + } + e, ok = m["go_cmd"] + if ok { + if s, ok := e.(string); ok { + conf.GoCmd = s + } else { + return nil, fmt.Errorf("go_cmd requires string, actual: %T", e) + } + } + e, ok = m["exclude"] + if ok { + switch e := e.(type) { + case string: + if e != "" { + conf.Exclude = []string{e} + } + case []interface{}: + for _, x := range e { + s, ok := x.(string) + if !ok { + return nil, fmt.Errorf("exclude requires string, actual: %T", x) + } + conf.Exclude = append(conf.Exclude, s) + } + default: + return nil, fmt.Errorf("exclude requires string or list, actual: %T", e) + } + } + + return conf, nil +} diff --git a/cmd/xgo/test-explorer/index.html b/cmd/xgo/test-explorer/index.html new file mode 100644 index 00000000..29f4a012 --- /dev/null +++ b/cmd/xgo/test-explorer/index.html @@ -0,0 +1,25 @@ + + + + + + Xgo Test Explorer + + + + +
+ + + + + + \ No newline at end of file diff --git a/cmd/xgo/test-explorer/session.go b/cmd/xgo/test-explorer/session.go new file mode 100644 index 00000000..a9728857 --- /dev/null +++ b/cmd/xgo/test-explorer/session.go @@ -0,0 +1,458 @@ +package test_explorer + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/xhd2015/xgo/support/cmd" + "github.com/xhd2015/xgo/support/fileutil" + "github.com/xhd2015/xgo/support/goinfo" + "github.com/xhd2015/xgo/support/netutil" +) + +type StartSessionRequest struct { + *TestingItem +} +type StartSessionResult struct { + ID string `json:"id"` +} +type Event string + +const ( + Event_ItemStatus Event = "item_status" + Event_Output Event = "output" + Event_ErrorMsg Event = "error_msg" + Event_TestStart Event = "test_start" + Event_TestEnd Event = "test_end" +) + +type TestingItemEvent struct { + Event Event `json:"event"` + Item *TestingItem `json:"item"` + Status RunStatus `json:"status"` + Msg string `json:"msg"` +} + +type PollSessionRequest struct { + ID string `json:"id"` +} + +type PollSessionResult struct { + Events []*TestingItemEvent `json:"events"` +} + +type session struct { + dir string + goCmd string + exclude []string + env []string + + item *TestingItem + + eventCh chan *TestingItemEvent +} + +func getRelDirs(root *TestingItem, file string) []string { + var find func(t *TestingItem) *TestingItem + find = func(t *TestingItem) *TestingItem { + if t.File == file { + return t + } + for _, child := range t.Children { + e := find(child) + if e != nil { + return e + } + } + return nil + } + target := find(root) + if target == nil { + return nil + } + + var getRelPaths func(t *TestingItem) []string + getRelPaths = func(t *TestingItem) []string { + var dirs []string + if t.Kind == TestingItemKind_Dir && t.HasTestGoFiles { + dirs = append(dirs, t.RelPath) + } + for _, e := range t.Children { + dirs = append(dirs, getRelPaths(e)...) + } + return dirs + } + return getRelPaths(target) +} + +// see https://pkg.go.dev/cmd/test2json#hdr-Output_Format +type TestEventAction string + +const ( + TestEventAction_Start TestEventAction = "start" + TestEventAction_Run TestEventAction = "run" + TestEventAction_Pass TestEventAction = "pass" + TestEventAction_Pause TestEventAction = "pause" + TestEventAction_Cont TestEventAction = "cont" + TestEventAction_Bench TestEventAction = "bench" + TestEventAction_Output TestEventAction = "output" + TestEventAction_Fail TestEventAction = "fail" + TestEventAction_Skip TestEventAction = "skip" +) + +// from go/cmd/test2json +type TestEvent struct { + Time time.Time // encodes as an RFC3339-format string + Action TestEventAction + Package string + Test string + Elapsed float64 // seconds + Output string +} + +func getPkgSubDirPath(modPath string, pkgPath string) string { + // NOTE: pkgPath can be command-line-arguments + if !strings.HasPrefix(pkgPath, modPath) { + return "" + } + return strings.TrimPrefix(pkgPath[len(modPath):], "/") +} + +func resolveTests(fullSubDir string) ([]*TestingItem, error) { + files, err := os.ReadDir(fullSubDir) + if err != nil { + return nil, err + } + var results []*TestingItem + for _, file := range files { + fileName := file.Name() + if !strings.HasSuffix(fileName, "_test.go") { + continue + } + if file.IsDir() { + continue + } + fullFile := filepath.Join(fullSubDir, fileName) + tests, err := parseTests(fullFile) + if err != nil { + return nil, err + } + results = append(results, tests...) + } + return results, nil +} + +func (c *session) Start() error { + absDir, err := filepath.Abs(c.dir) + if err != nil { + return err + } + // find all tests + modPath, err := goinfo.GetModPath(absDir) + if err != nil { + return err + } + + finish := func() { + c.sendEvent(&TestingItemEvent{ + Event: Event_TestEnd, + }) + } + + var testArgs []string + file := c.item.File + + isFile, err := fileutil.IsFile(file) + if err != nil { + return err + } + if isFile { + relPath, err := filepath.Rel(absDir, file) + if err != nil { + return err + } + var subCaseNames []string + if c.item.Kind != TestingItemKind_Case { + subCases, err := parseTests(file) + if err != nil { + return err + } + if len(subCases) == 0 { + finish() + return nil + } + subCaseNames = make([]string, 0, len(subCases)) + for _, subCase := range subCases { + subCaseNames = append(subCaseNames, subCase.Name) + } + } else { + subCaseNames = append(subCaseNames, c.item.Name) + } + // fmt.Printf("sub cases: %v\n", subCaseNames) + testArgs = append(testArgs, "-run", fmt.Sprintf("^%s$", strings.Join(subCaseNames, "|"))) + testArgs = append(testArgs, "./"+filepath.Dir(relPath)) + } else { + // all sub dirs + root, err := scanTests(absDir, false, c.exclude) + if err != nil { + return err + } + + // find all relDirs + relDirs := getRelDirs(root, file) + if len(relDirs) == 0 { + return nil + } + // must exclude non packages + // no Go files in /Users/xhd2015/Projects/xhd2015/xgo-test-explorer/support + // fmt.Printf("dirs: %v\n", relDirs) + for _, relDir := range relDirs { + testArgs = append(testArgs, "./"+relDir) + } + } + + var pkgTests sync.Map + + resolvePkgTestsCached := func(absDir string, modPath string, pkgPath string) ([]*TestingItem, error) { + subDir := getPkgSubDirPath(modPath, pkgPath) + if subDir == "" { + return nil, nil + } + v, ok := pkgTests.Load(subDir) + if ok { + return v.([]*TestingItem), nil + } + results, err := resolveTests(filepath.Join(absDir, subDir)) + if err != nil { + return nil, err + } + pkgTests.Store(subDir, results) + return results, nil + } + + resolveTestFile := func(absDir, pkgPath string, name string) (string, error) { + testingItems, err := resolvePkgTestsCached(absDir, modPath, pkgPath) + if err != nil { + return "", err + } + for _, testingItem := range testingItems { + if testingItem.Name == name { + return testingItem.File, nil + } + } + return "", nil + } + + c.sendEvent(&TestingItemEvent{ + Event: Event_TestStart, + }) + + r, w := io.Pipe() + go func() { + defer finish() + goCmd := "go" + if c.goCmd != "" { + goCmd = c.goCmd + } + testFlags := append([]string{"test", "-json"}, testArgs...) + fmt.Printf("%s %v\n", goCmd, testFlags) + + err := cmd.Env(c.env).Dir(c.dir). + Stdout(io.MultiWriter(os.Stdout, w)). + Run(goCmd, testFlags...) + if err != nil { + fmt.Printf("test err: %v\n", err) + c.sendEvent(&TestingItemEvent{Event: Event_ErrorMsg, Msg: err.Error()}) + } + fmt.Printf("test end\n") + }() + + // -json will not output json if build failed + // $ go test -json ./script/build-release + // TODO: parse std error + // stderr: # github.com/xhd2015/xgo/script/build-release [github.com/xhd2015/xgo/script/build-release.test] + // stderr: script/build-release/fixup_test.go:10:17: undefined: getGitDir + // stdout: FAIL github.com/xhd2015/xgo/script/build-release [build failed] + reg := regexp.MustCompile(`^FAIL\s+([^\s]+)\s+.*$`) + go func() { + scanner := bufio.NewScanner(r) + + var prefix []string + for scanner.Scan() { + var testEvent TestEvent + data := scanner.Bytes() + // fmt.Printf("line: %s\n", string(data)) + if !bytes.HasPrefix(data, []byte{'{'}) { + s := string(data) + m := reg.FindStringSubmatch(s) + if m == nil { + prefix = append(prefix, s) + continue + } + pkg := m[1] + prefix = nil + + output := strings.Join(prefix, "\n") + "\n" + s + testEvent = TestEvent{ + Package: pkg, + Action: TestEventAction_Fail, + Output: output, + } + } else { + err := json.Unmarshal(data, &testEvent) + if err != nil { + // emit global message + fmt.Printf("err:%s %v\n", data, err) + c.sendEvent(&TestingItemEvent{Event: Event_ErrorMsg, Msg: err.Error()}) + continue + } + } + itemEvent := buildEvent(&testEvent, absDir, modPath, resolveTestFile, getPkgSubDirPath) + if itemEvent != nil { + c.sendEvent(itemEvent) + } + } + }() + + return nil +} + +func buildEvent(testEvent *TestEvent, absDir string, modPath string, resolveTestFile func(absDir string, pkgPath string, name string) (string, error), getPkgSubDirPath func(modPath string, pkgPath string) string) *TestingItemEvent { + var kind TestingItemKind + var fullFile string + var status RunStatus + + if testEvent.Package != "" { + if testEvent.Test != "" { + kind = TestingItemKind_Case + fullFile, _ = resolveTestFile(absDir, testEvent.Package, testEvent.Test) + } else { + kind = TestingItemKind_Dir + subDir := getPkgSubDirPath(modPath, testEvent.Package) + if subDir != "" { + fullFile = filepath.Join(absDir, subDir) + } + } + } + + switch testEvent.Action { + case TestEventAction_Run: + status = RunStatus_Running + case TestEventAction_Pass: + status = RunStatus_Success + case TestEventAction_Fail: + status = RunStatus_Fail + case TestEventAction_Skip: + status = RunStatus_Skip + } + return &TestingItemEvent{ + Event: Event_ItemStatus, + Item: &TestingItem{ + Kind: kind, + File: fullFile, + Name: testEvent.Test, + }, + Status: status, + Msg: testEvent.Output, + } +} + +func (c *session) Poll() []*TestingItemEvent { + var events []*TestingItemEvent + + timeout := time.After(5 * time.Second) + for { + select { + case event := <-c.eventCh: + events = append(events, event) + case <-timeout: + return events + default: + if len(events) > 0 { + return events + } + time.Sleep(100 * time.Millisecond) + } + } +} + +func (c *session) sendEvent(event *TestingItemEvent) { + c.eventCh <- event +} + +// TODO: add /session/destroy +func setupSessionHandler(server *http.ServeMux, projectDir string, config *TestConfig, env []string) { + + var nextID int64 = 0 + var sessionMapping sync.Map + + server.HandleFunc("/session/start", 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 *StartSessionRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req == nil || req.TestingItem == nil || req.File == "" { + return nil, netutil.ParamErrorf("requires file") + } + + idInt := atomic.AddInt64(&nextID, 1) + id := fmt.Sprintf("session_%d", idInt) + + sess := &session{ + dir: projectDir, + goCmd: config.GoCmd, + exclude: config.Exclude, + env: env, + + eventCh: make(chan *TestingItemEvent, 100), + item: req.TestingItem, + } + sessionMapping.Store(id, sess) + err = sess.Start() + if err != nil { + return nil, err + } + return &StartSessionResult{ID: id}, nil + }) + }) + + server.HandleFunc("/session/pollStatus", 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 *PollSessionRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + val, ok := sessionMapping.Load(req.ID) + if !ok { + return nil, netutil.ParamErrorf("session %s does not exist or has been removed", req.ID) + } + sess := val.(*session) + + events := sess.Poll() + // fmt.Printf("poll: %v\n", events) + return &PollSessionResult{ + Events: events, + }, nil + }) + }) +} diff --git a/cmd/xgo/test-explorer/test_explorer.go b/cmd/xgo/test-explorer/test_explorer.go new file mode 100644 index 00000000..e379af84 --- /dev/null +++ b/cmd/xgo/test-explorer/test_explorer.go @@ -0,0 +1,597 @@ +package test_explorer + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/xhd2015/xgo/support/cmd" + "github.com/xhd2015/xgo/support/fileutil" + "github.com/xhd2015/xgo/support/goinfo" + "github.com/xhd2015/xgo/support/netutil" +) + +type Options struct { + // by default go + DefaultGoCommand string + GoCommand string + ProjectDir string + Exclude []string +} + +func Main(args []string, opts *Options) error { + if opts == nil { + opts = &Options{} + } + n := len(args) + for i := 0; i < n; i++ { + arg := args[i] + if arg == "--" { + break + } + if arg == "--go-command" { + if i+1 >= n { + return fmt.Errorf("%s requires value", arg) + } + opts.GoCommand = args[i+1] + i++ + continue + } + if arg == "--project-dir" { + if i+1 >= n { + return fmt.Errorf("%s requires value", arg) + } + opts.ProjectDir = args[i+1] + i++ + continue + } + if arg == "--exclude" { + if i+1 >= n { + return fmt.Errorf("%s requires value", arg) + } + opts.Exclude = append(opts.Exclude, args[i+1]) + i++ + continue + } + if !strings.HasPrefix(arg, "-") { + continue + } + return fmt.Errorf("unrecognized flag: %s", arg) + } + return handle(opts) +} + +// NOTE: case can have sub childrens + +type TestingItemKind string + +const ( + TestingItemKind_Dir = "dir" + TestingItemKind_File = "file" + 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"` + RelPath string `json:"relPath"` + File string `json:"file"` + Line int `json:"line"` + Kind TestingItemKind `json:"kind"` + Error string `json:"error"` + + // only if Kind==dir + // go only + HasTestGoFiles bool `json:"hasTestGoFiles"` + + // when filter is not + // go only + HasTestCases bool `json:"hasTestCases"` + + 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 compareGoVersion(a *goinfo.GoVersion, b *goinfo.GoVersion, ignorePatch bool) int { + if a.Major != b.Major { + return a.Major - b.Major + } + if a.Minor != b.Minor { + return a.Minor - b.Minor + } + if ignorePatch { + return 0 + } + return a.Patch - b.Patch +} + +func handle(opts *Options) error { + if opts == nil { + opts = &Options{} + } + + configFile := filepath.Join(opts.ProjectDir, "test.config.json") + + data, readErr := ioutil.ReadFile(configFile) + if readErr != nil { + if !errors.Is(readErr, os.ErrNotExist) { + return readErr + } + readErr = nil + } + var testConfig *TestConfig + if len(data) > 0 { + var err error + testConfig, err = parseTestConfig(string(data)) + if err != nil { + return fmt.Errorf("parse test.config.json: %w", err) + } + } + if testConfig == nil { + testConfig = &TestConfig{} + } + if opts.GoCommand != "" { + testConfig.GoCmd = opts.GoCommand + } else if testConfig.GoCmd == "" { + testConfig.GoCmd = opts.DefaultGoCommand + } + testConfig.Exclude = append(testConfig.Exclude, opts.Exclude...) + + // check go version + if testConfig.Go != nil && (testConfig.Go.Min != "" || testConfig.Go.Max != "") { + goVersionStr, err := goinfo.GetGoVersionOutput("go") + if err != nil { + return err + } + goVersion, err := goinfo.ParseGoVersion(goVersionStr) + if err != nil { + return err + } + if testConfig.Go.Min != "" { + minVer, _ := goinfo.ParseGoVersionNumber(strings.TrimPrefix(testConfig.Go.Min, "go")) + if minVer != nil { + if compareGoVersion(goVersion, minVer, true) < 0 { + return fmt.Errorf("go version %s < %s", strings.TrimPrefix(goVersionStr, "go version "), testConfig.Go.Min) + } + } + } + if testConfig.Go.Max != "" { + maxVer, _ := goinfo.ParseGoVersionNumber(strings.TrimPrefix(testConfig.Go.Max, "go")) + if maxVer != nil { + if compareGoVersion(goVersion, maxVer, true) > 0 { + return fmt.Errorf("go version %s > %s", strings.TrimPrefix(goVersionStr, "go version "), testConfig.Go.Max) + } + } + } + } + + var env []string + if len(testConfig.Env) > 0 { + env = append(env, os.Environ()...) + for k, v := range testConfig.Env { + env = append(env, fmt.Sprintf("%s=%s", k, fmt.Sprint(v))) + } + } + + 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") + if dir == "" { + dir = opts.ProjectDir + } + root, err := scanTests(dir, true, opts.Exclude) + 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, testConfig.GoCmd, env) + }) + }) + + setupSessionHandler(server, opts.ProjectDir, testConfig, env) + + 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, needParseTests bool, exclude []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 len(exclude) > 0 { + var found bool + for _, e := range exclude { + if e == relPath { + found = true + break + } + } + if found { + if d.IsDir() { + return filepath.SkipDir + } else { + 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), + RelPath: 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), + RelPath: relPath, + File: path, + Kind: TestingItemKind_File, + } + itemMapping[path] = item + parent.HasTestGoFiles = true + parent.Children = append(parent.Children, item) + + if needParseTests { + tests, parseErr := parseTests(path) + if parseErr != nil { + item.Error = parseErr.Error() + } else { + for _, test := range tests { + test.RelPath = relPath + } + // 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, needParseTests) + 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, goCmd string, env []string) (*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") + } + if goCmd == "" { + goCmd = "go" + } + + fmt.Printf("test: %s %v\n", goCmd, args) + runErr := cmd.Dir(filepath.Dir(req.File)). + Env(env). + Stderr(io.MultiWriter(os.Stderr, &buf)). + Stdout(io.MultiWriter(os.Stdout, &buf)). + Run(goCmd, 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, withCases bool) *TestingItem { + if item == nil { + return nil + } + + if withCases { + children := item.Children + n := len(children) + i := 0 + for j := 0; j < n; j++ { + child := filterItem(children[j], withCases) + if child != nil { + children[i] = child + i++ + } + } + item.Children = children[:i] + if i == 0 && item.Kind != TestingItemKind_Case { + return nil + } + } else { + if !item.HasTestCases { + 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..d008a738 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,11 @@ func handleTool(tool string, args []string) error { coverage.Main(args) return nil } + if tool == "test-explorer" { + return test_explorer.Main(args, &test_explorer.Options{ + DefaultGoCommand: "xgo", + }) + } 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..016cafb3 --- /dev/null +++ b/support/fileutil/stat.go @@ -0,0 +1,36 @@ +package fileutil + +import ( + "errors" + "os" +) + +func IsFile(file string) (bool, error) { + return FileExists(file) +} + +func IsDir(file string) (bool, error) { + return DirExists(file) +} + +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..25a62d6a --- /dev/null +++ b/support/goinfo/find.go @@ -0,0 +1,57 @@ +package goinfo + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/xhd2015/xgo/support/cmd" +) + +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 + } +} + +func GetModPath(dir string) (string, error) { + output, err := cmd.Dir(dir).Output("go", "mod", "edit", "-json") + if err != nil { + return "", err + } + var goMod struct { + Module struct { + Path string + } + } + err = json.Unmarshal([]byte(output), &goMod) + if err != nil { + return "", err + } + return goMod.Module.Path, nil +} diff --git a/support/goinfo/find_test.go b/support/goinfo/find_test.go new file mode 100644 index 00000000..bec65b72 --- /dev/null +++ b/support/goinfo/find_test.go @@ -0,0 +1,34 @@ +package goinfo + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestFilePathDir(t *testing.T) { + type Test struct { + name string + dir string + want string + } + tests := []Test{ + // NOTE: must convert dot path to abs path + {"dot", ".", "."}, + {"dot_slash", "./", "."}, + } + if runtime.GOOS != "windows" { + tests = append(tests, Test{"root", "/", "/"}) + } else { + tests = append(tests, Test{"root_windows", "/", "\\"}) + } + 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/goinfo/goinfo.go b/support/goinfo/goinfo.go index 2cb1954b..ad068bdb 100644 --- a/support/goinfo/goinfo.go +++ b/support/goinfo/goinfo.go @@ -47,6 +47,21 @@ func ParseGoVersion(s string) (*GoVersion, error) { version := s[:spaceIdx] osArch := s[spaceIdx+1:] + res, err := ParseGoVersionNumber(version) + if err != nil { + return nil, err + } + + slashIdx := strings.Index(osArch, "/") + if slashIdx < 0 { + return nil, fmt.Errorf("unrecognized version, expect os/arch: %s", osArch) + } + res.OS = osArch[:slashIdx] + res.Arch = osArch[slashIdx+1:] + return res, nil +} + +func ParseGoVersionNumber(version string) (*GoVersion, error) { res := &GoVersion{} verList := strings.Split(version, ".") for i := 0; i < 3; i++ { @@ -65,11 +80,5 @@ func ParseGoVersion(s string) (*GoVersion, error) { } } } - slashIdx := strings.Index(osArch, "/") - if slashIdx < 0 { - return nil, fmt.Errorf("unrecognized version, expect os/arch: %s", osArch) - } - res.OS = osArch[:slashIdx] - res.Arch = osArch[slashIdx+1:] return res, nil } 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..3731a54c 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("localhost:%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] } }