From 927e228891ccf8c6490ecc8bee79fa66ec9669a6 Mon Sep 17 00:00:00 2001 From: xhd2015 Date: Tue, 28 May 2024 14:02:48 +0800 Subject: [PATCH] test-explorer: add debug button --- cmd/xgo/runtime_gen/core/version.go | 4 +- cmd/xgo/test-explorer/config.go | 7 + cmd/xgo/test-explorer/debug.go | 218 +++++++++++ cmd/xgo/test-explorer/index.html | 2 +- cmd/xgo/test-explorer/run.go | 477 +++++++++++++++++++++++++ cmd/xgo/test-explorer/session.go | 471 +++--------------------- cmd/xgo/test-explorer/test_explorer.go | 3 +- cmd/xgo/version.go | 4 +- runtime/core/version.go | 4 +- support/cmd/cmd.go | 15 +- 10 files changed, 766 insertions(+), 439 deletions(-) create mode 100644 cmd/xgo/test-explorer/debug.go create mode 100644 cmd/xgo/test-explorer/run.go diff --git a/cmd/xgo/runtime_gen/core/version.go b/cmd/xgo/runtime_gen/core/version.go index 8093db68..4f249e53 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.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 // 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 index 08a72109..8a5cb3d1 100644 --- a/cmd/xgo/test-explorer/config.go +++ b/cmd/xgo/test-explorer/config.go @@ -36,6 +36,13 @@ func (c *TestConfig) CmdEnv() []string { return env } +func (c *TestConfig) GetGoCmd() string { + if c.GoCmd != "" { + return c.GoCmd + } + return "go" +} + type GoConfig struct { Min string `json:"min"` Max string `json:"max"` diff --git a/cmd/xgo/test-explorer/debug.go b/cmd/xgo/test-explorer/debug.go new file mode 100644 index 00000000..b3a99571 --- /dev/null +++ b/cmd/xgo/test-explorer/debug.go @@ -0,0 +1,218 @@ +package test_explorer + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/xhd2015/xgo/support/cmd" + "github.com/xhd2015/xgo/support/fileutil" + "github.com/xhd2015/xgo/support/netutil" +) + +type DebugRequest struct { + Item *TestingItem `json:"item"` +} +type DebugResponse struct { + ID string `json:"id"` +} + +type DebugPollRequest struct { + ID string `json:"id"` +} + +type DebugPollResponse struct { + Events []*TestingItemEvent `json:"events"` +} +type DebugDestroyRequest struct { + ID string `json:"id"` +} + +func setupDebugHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { + var debugSession SessionManager = &sessionManager{} + + server.HandleFunc("/debug", 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 *DebugRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req == nil || req.Item == nil || req.Item.File == "" { + return nil, netutil.ParamErrorf("requires file") + } + + file := req.Item.File + isFile, err := fileutil.IsFile(file) + if err != nil { + return nil, err + } + if !isFile { + return nil, fmt.Errorf("cannot debug mutliple tests") + } + absDir, err := filepath.Abs(projectDir) + if err != nil { + return nil, err + } + + relPath, err := filepath.Rel(absDir, file) + if err != nil { + return nil, err + } + + config, err := getTestConfig() + if err != nil { + return nil, err + } + + id, session, err := debugSession.Start() + if err != nil { + return nil, err + } + + pr, pw := io.Pipe() + + // go func() { xxx } + // - build with gcflags="all=-N -l" + // - start dlv + // - output prompt + go func() { + defer session.SendEvents(&TestingItemEvent{ + Event: Event_TestEnd, + }) + debug := func(projectDir string, file string, stdout io.Writer, stderr io.Writer) error { + goCmd := config.GetGoCmd() + tmpDir, err := os.MkdirTemp("", "go-test-debug") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + binName := "debug.bin" + baseName := filepath.Base(file) + if baseName != "" { + binName = baseName + "-" + binName + } + + // TODO: find a way to automatically set breakpoint + // dlvInitFile := filepath.Join(tmpDir, "dlv-init.txt") + // err = ioutil.WriteFile(dlvInitFile, []byte(fmt.Sprintf("break %s:%d\n", file, req.Item.Line)), 0755) + // if err != nil { + // return err + // } + relPathDir := filepath.Dir(relPath) + tmpBin := filepath.Join(tmpDir, binName) + err = cmd.Dir(projectDir).Debug().Stderr(stderr).Stdout(stdout).Run(goCmd, "test", "-c", "-o", tmpBin, "-gcflags=all=-N -l", "./"+relPathDir) + if err != nil { + return err + } + err = netutil.ServePort(2345, true, 500*time.Millisecond, func(port int) { + // user need to set breakpoint explicitly + fmt.Fprintf(stderr, "dlv listen on localhost:%d\n", port) + fmt.Fprintf(stderr, "> to debug with vscode, add the following config to .vscode/launch.json:") + fmt.Fprintf(stderr, "\n%s\n", formatVscodeConfig(port)) + fmt.Fprintf(stderr, "> to debug with GoLand, click Add Configuration > Go Remote > localhost:%d\n", port) + fmt.Fprintf(stderr, "> to debug using terminal, run: dlv connect localhost:%d\n", port) + }, func(port int) error { + // dlv exec --api-version=2 --listen=localhost:2345 --accept-multiclient --headless ./debug.bin + return cmd.Dir(filepath.Dir(file)).Debug().Stderr(stderr).Stdout(stdout).Run("dlv", "exec", + "--api-version=2", + fmt.Sprintf("--listen=localhost:%d", port), + // NOTE: --init is ignored if --headless + // "--init", dlvInitFile, + "--headless", + // "--allow-non-terminal-interactive=true", + tmpBin, "-test.v", "-test.run", fmt.Sprintf("$%s^", req.Item.Name)) + }) + if err != nil { + return err + } + return nil + } + err := debug(projectDir, file, io.MultiWriter(os.Stdout, pw), io.MultiWriter(os.Stderr, pw)) + if err != nil { + session.SendEvents(&TestingItemEvent{ + Event: Event_Output, + Msg: "err: " + err.Error(), + }) + } + }() + + go func() { + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + data := scanner.Bytes() + session.SendEvents(&TestingItemEvent{ + Event: Event_Output, + Msg: string(data), + }) + } + }() + return &DebugResponse{ID: id}, nil + }) + }) + + server.HandleFunc("/debug/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 *DebugPollRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + session, err := debugSession.Get(req.ID) + if err != nil { + return nil, err + } + + events, err := session.PollEvents() + if err != nil { + return nil, err + } + return &DebugPollResponse{ + Events: convTestingEvents(events), + }, nil + }) + }) + server.HandleFunc("/debug/destroy", 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 *DebugDestroyRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + err = debugSession.Destroy(req.ID) + if err != nil { + return nil, err + } + return nil, nil + }) + }) +} + +func formatVscodeConfig(port int) string { + return fmt.Sprintf(`{ + "configurations": [ + { + "name": "Debug dlv localhost:%d", + "type": "go", + "request": "attach", + "mode": "remote", + "port": %d, + "host": "127.0.0.1" + } + } +}`, port, port) +} diff --git a/cmd/xgo/test-explorer/index.html b/cmd/xgo/test-explorer/index.html index 84c5541b..ecc3daf1 100644 --- a/cmd/xgo/test-explorer/index.html +++ b/cmd/xgo/test-explorer/index.html @@ -20,6 +20,6 @@ - + \ No newline at end of file diff --git a/cmd/xgo/test-explorer/run.go b/cmd/xgo/test-explorer/run.go new file mode 100644 index 00000000..40967b1d --- /dev/null +++ b/cmd/xgo/test-explorer/run.go @@ -0,0 +1,477 @@ +package test_explorer + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "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 DestroySessionRequest struct { + ID string `json:"id"` +} + +type runSession struct { + dir string + goCmd string + exclude []string + env []string + testFlags []string + + item *TestingItem + + session Session +} + +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 *runSession) 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"}, c.testFlags...) + testFlags = append(testFlags, testArgs...) + + err := cmd.Debug().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 convTestingEvents(events []interface{}) []*TestingItemEvent { + testingEvents := make([]*TestingItemEvent, 0, len(events)) + for _, e := range events { + testingEvents = append(testingEvents, e.(*TestingItemEvent)) + } + return testingEvents +} + +func (c *runSession) sendEvent(event *TestingItemEvent) { + c.session.SendEvents(event) +} + +// TODO: make FE call /session/destroy +func setupRunHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { + var sessMan SessionManager = &sessionManager{} + + 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") + } + + config, err := getTestConfig() + if err != nil { + return nil, err + } + + id, ses, err := sessMan.Start() + if err != nil { + return nil, err + } + + runSess := &runSession{ + dir: projectDir, + goCmd: config.GoCmd, + exclude: config.Exclude, + env: config.CmdEnv(), + testFlags: config.Flags, + + session: ses, + item: req.TestingItem, + } + err = runSess.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") + } + session, err := sessMan.Get(req.ID) + if err != nil { + return nil, err + } + + events, err := session.PollEvents() + if err != nil { + return nil, err + } + // fmt.Printf("poll: %v\n", events) + return &PollSessionResult{ + Events: convTestingEvents(events), + }, nil + }) + }) + + server.HandleFunc("/session/destroy", 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 *DestroySessionRequest + err := parseBody(r.Body, &req) + if err != nil { + return nil, err + } + if req.ID == "" { + return nil, netutil.ParamErrorf("requires id") + } + err = sessMan.Destroy(req.ID) + if err != nil { + return nil, err + } + return nil, nil + }) + }) +} diff --git a/cmd/xgo/test-explorer/session.go b/cmd/xgo/test-explorer/session.go index 87e5447d..cf8b3828 100644 --- a/cmd/xgo/test-explorer/session.go +++ b/cmd/xgo/test-explorer/session.go @@ -1,465 +1,86 @@ 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 SessionManager interface { + // return id and error + Start() (string, Session, error) + Get(id string) (Session, error) + Destroy(id string) error } -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 - testFlags []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 +type Session interface { + SendEvents(events ...interface{}) error + PollEvents() ([]interface{}, error) } -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):], "/") +type sessionImpl struct { + ch chan interface{} } -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 +func (c *sessionImpl) SendEvents(events ...interface{}) error { + for _, e := range events { + c.ch <- e } - - 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"}, c.testFlags...) - testFlags = append(testFlags, testArgs...) - - err := cmd.Debug().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 *sessionImpl) PollEvents() ([]interface{}, error) { + events := c.poll(5*time.Second, 100*time.Millisecond) + return events, nil } -func (c *session) Poll() []*TestingItemEvent { - var events []*TestingItemEvent +func (c *sessionImpl) poll(timeout time.Duration, pollInterval time.Duration) []interface{} { + var events []interface{} - timeout := time.After(5 * time.Second) + timeoutCh := time.After(timeout) for { select { - case event := <-c.eventCh: + case event := <-c.ch: events = append(events, event) - case <-timeout: + case <-timeoutCh: return events default: if len(events) > 0 { return events } - time.Sleep(100 * time.Millisecond) + time.Sleep(pollInterval) } } } -func (c *session) sendEvent(event *TestingItemEvent) { - c.eventCh <- event +type sessionManager struct { + nextID int64 + mapping sync.Map } -// TODO: add /session/destroy -func setupSessionHandler(server *http.ServeMux, projectDir string, getTestConfig func() (*TestConfig, error)) { - 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") - } - - config, err := getTestConfig() - if err != nil { - return nil, err - } - - idInt := atomic.AddInt64(&nextID, 1) - // to avoid stale requests from older pages - id := fmt.Sprintf("session_%s_%d", time.Now().Format("2006-01-02_15:04:05"), idInt) - - sess := &session{ - dir: projectDir, - goCmd: config.GoCmd, - exclude: config.Exclude, - env: config.CmdEnv(), - testFlags: config.Flags, - - 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) +func (c *sessionManager) Start() (string, Session, error) { + // to avoid stale requests from older pages + idInt := atomic.AddInt64(&c.nextID, 1) + id := fmt.Sprintf("session_%s_%d", time.Now().Format("2006-01-02_15:04:05"), idInt) + session := &sessionImpl{ + ch: make(chan interface{}, 100), + } + c.mapping.Store(id, session) + return id, session, nil +} +func (c *sessionManager) Get(id string) (Session, error) { + val, ok := c.mapping.Load(id) + if !ok { + return nil, netutil.ParamErrorf("session %s does not exist or has been removed", id) + } + session := val.(*sessionImpl) + return session, nil +} - events := sess.Poll() - // fmt.Printf("poll: %v\n", events) - return &PollSessionResult{ - Events: events, - }, nil - }) - }) +func (c *sessionManager) Destroy(id string) error { + c.mapping.Delete(id) + return nil } diff --git a/cmd/xgo/test-explorer/test_explorer.go b/cmd/xgo/test-explorer/test_explorer.go index 60740cb0..66a1a984 100644 --- a/cmd/xgo/test-explorer/test_explorer.go +++ b/cmd/xgo/test-explorer/test_explorer.go @@ -264,7 +264,8 @@ func handle(opts *Options) error { }) }) - setupSessionHandler(server, opts.ProjectDir, getTestConfig) + setupRunHandler(server, opts.ProjectDir, getTestConfig) + setupDebugHandler(server, opts.ProjectDir, getTestConfig) setupOpenHandler(server) return netutil.ServePortHTTP(server, 7070, true, 500*time.Millisecond, func(port int) { diff --git a/cmd/xgo/version.go b/cmd/xgo/version.go index 7abbb2aa..ba561652 100644 --- a/cmd/xgo/version.go +++ b/cmd/xgo/version.go @@ -3,8 +3,8 @@ package main import "fmt" const VERSION = "1.0.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 func getRevision() string { revSuffix := "" diff --git a/runtime/core/version.go b/runtime/core/version.go index 8093db68..4f249e53 100644 --- a/runtime/core/version.go +++ b/runtime/core/version.go @@ -7,8 +7,8 @@ import ( ) const VERSION = "1.0.37" -const REVISION = "310d0d44809c8f2ad26761138fb8eb3cc4db75c9+1" -const NUMBER = 238 +const REVISION = "62c6c037c1f57c371e227dd1bb8b8e141367f1c6+1" +const NUMBER = 239 // these fields will be filled by compiler const XGO_VERSION = "" diff --git a/support/cmd/cmd.go b/support/cmd/cmd.go index cc5a85d6..e14b7df5 100644 --- a/support/cmd/cmd.go +++ b/support/cmd/cmd.go @@ -63,6 +63,7 @@ func (c *CmdBuilder) Stderr(stderr io.Writer) *CmdBuilder { c.stderr = stderr return c } + func (c *CmdBuilder) Debug() *CmdBuilder { c.debug = true return c @@ -81,6 +82,12 @@ func cmdExec(cmd string, args []string, dir string, pipeStdout bool) (string, er return cmdExecEnv(cmd, args, nil, dir, pipeStdout, nil) } func cmdExecEnv(cmd string, args []string, env []string, dir string, useStdout bool, c *CmdBuilder) (string, error) { + var stderr io.Writer + if c != nil && c.stderr != nil { + stderr = c.stderr + } else { + stderr = os.Stderr + } if c != nil && c.debug { var lines []string if len(env) > 0 { @@ -97,16 +104,12 @@ func cmdExecEnv(cmd string, args []string, env []string, dir string, useStdout b } lines = append(lines, cmdStr) for _, line := range lines { - fmt.Fprintln(os.Stderr, line) + fmt.Fprintln(stderr, line) } } execCmd := exec.Command(cmd, args...) - if c != nil && c.stderr != nil { - execCmd.Stderr = c.stderr - } else { - execCmd.Stderr = os.Stderr - } + execCmd.Stderr = stderr if len(env) > 0 { execCmd.Env = os.Environ() execCmd.Env = append(execCmd.Env, env...)