From 571bfe880e232b26503d79887b20a37a9d9fcacf Mon Sep 17 00:00:00 2001 From: hongsh87 Date: Mon, 13 Jul 2026 10:01:56 +0900 Subject: [PATCH] Add verify command --- README.ko.md | 19 +++++ README.md | 19 +++++ cmd/root.go | 31 ++++++++ cmd/verify.go | 194 +++++++++++++++++++++++++++++++++++++++++++++ cmd/verify_test.go | 117 +++++++++++++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 cmd/verify.go create mode 100644 cmd/verify_test.go diff --git a/README.ko.md b/README.ko.md index 849f73a..782a63c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -147,6 +147,7 @@ Unity 커넥터의 동작: | `console` | 콘솔 로그 읽기, 필터링, 지우기 | | `exec` | Unity 안에서 임의 C# 코드 실행 | | `test` | EditMode/PlayMode 테스트 실행 | +| `verify` | 컴파일, Play 모드, 콘솔 에러 확인, 정리를 한 번에 실행 | | `menu` | Unity 메뉴 아이템을 경로로 실행 | | `reserialize` | Unity 시리얼라이저를 통해 에셋 재직렬화 | | `screenshot` | Scene/Game 뷰를 PNG로 캡처 | @@ -306,6 +307,24 @@ unity-cli test --filter MyTestClass Unity Test Framework 패키지가 필요합니다. PlayMode 테스트는 도메인 리로드를 일으키며, CLI는 결과 파일을 폴링합니다. +### Verify Project + +Common validation flow를 한 번에 실행합니다: refresh/compile, Unity ready 대기, play mode 진입, console error 확인, 그리고 `verify`가 시작한 play mode 정리. + +```bash +# 전체 검증 플로우 +unity-cli verify + +# 스크립트가 이미 최신이면 compile 생략 +unity-cli verify --skip-compile + +# 수동 확인을 위해 play mode 유지 +unity-cli verify --keep-playing + +# 더 많은 console error와 user stack frame 확인 +unity-cli verify --console-lines 100 --stacktrace user +``` + ### 도구 목록 ```bash diff --git a/README.md b/README.md index 45eb094..aaad95e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Before compiling or reloading, the Connector records the state (`compiling`, `re | `console` | Read, filter, and clear console logs | | `exec` | Run arbitrary C# code inside Unity | | `test` | Run EditMode/PlayMode tests | +| `verify` | Compile, enter play mode, inspect console errors, and stop | | `menu` | Execute any Unity menu item by path | | `reserialize` | Re-serialize assets through Unity's serializer | | `screenshot` | Capture scene/game view as PNG | @@ -305,6 +306,24 @@ unity-cli test --filter MyTestClass Requires the Unity Test Framework package. PlayMode tests trigger a domain reload; the CLI polls for results automatically. +### Verify Project + +Run the common agent validation flow in one command: refresh and compile scripts, wait until Unity is ready, enter play mode, inspect console errors, then stop play mode if `verify` started it. + +```bash +# Full verification flow +unity-cli verify + +# Skip compilation when scripts are already fresh +unity-cli verify --skip-compile + +# Leave the Editor playing for manual inspection +unity-cli verify --keep-playing + +# Inspect more console errors with user stack frames +unity-cli verify --console-lines 100 --stacktrace user +``` + ### List Tools ```bash diff --git a/cmd/root.go b/cmd/root.go index 2325b55..8f0eea2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -108,6 +108,8 @@ func Execute() error { switch category { case "editor": resp, err = editorCmd(subArgs, send, resolve) + case "verify": + resp, err = verifyCmd(subArgs, send, resolve) case "test": resp, err = testCmd(subArgs, send, resolve) case "exec": @@ -566,6 +568,12 @@ Tests: test --mode PlayMode Run PlayMode tests test --filter Filter by namespace, class, or full test name +Verify: + verify Compile, enter play mode, check console errors, stop + verify --skip-compile Skip script compilation + verify --skip-play Check compile and console without entering play mode + verify --keep-playing Leave Unity in play mode after verification + Profiler: profiler hierarchy Top-level profiler samples (last frame) profiler hierarchy --depth 5 Recursive drill-down (0=unlimited) @@ -771,6 +779,29 @@ Examples: unity-cli test --mode PlayMode unity-cli test --filter MyNamespace.MyTests unity-cli test --mode EditMode --filter MyNamespace.MyTests.SpecificTest +`) + case "verify": + fmt.Print(`Usage: unity-cli verify [options] + +Run a common Unity validation flow: + 1. Refresh assets and request script compilation + 2. Wait until Unity is ready + 3. Enter play mode and wait until fully entered + 4. Read console errors + 5. Stop play mode if verify started it + +Options: + --skip-compile Skip refresh and script compilation + --skip-play Do not enter play mode + --keep-playing Leave Unity in play mode after verification + --console-lines Console entries to inspect (default: 50) + --console-type Log types to inspect (default: error) + --stacktrace none, user, or full (default: none) + +Examples: + unity-cli verify + unity-cli verify --skip-compile + unity-cli verify --console-lines 100 --stacktrace user `) case "list": fmt.Print(`Usage: unity-cli list diff --git a/cmd/verify.go b/cmd/verify.go new file mode 100644 index 0000000..b3cdf64 --- /dev/null +++ b/cmd/verify.go @@ -0,0 +1,194 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/youngwoocho02/unity-cli/internal/client" +) + +type verifySummary struct { + Project string `json:"project,omitempty"` + Compile bool `json:"compile"` + CompileOK bool `json:"compile_ok"` + Play bool `json:"play"` + EnteredPlay bool `json:"entered_play"` + Stopped bool `json:"stopped"` + RuntimeOK bool `json:"runtime_ok"` + ErrorCount int `json:"error_count"` + Errors []string `json:"errors,omitempty"` + Messages []string `json:"messages,omitempty"` +} + +func verifyCmd(args []string, send sendFn, resolve instanceResolver) (*client.CommandResponse, error) { + flags := parseSubFlags(args) + + summary := verifySummary{ + Compile: !hasFlag(flags, "skip-compile"), + CompileOK: true, + Play: !hasFlag(flags, "skip-play"), + RuntimeOK: true, + } + + lines := parsePositiveIntFlag(flags, "console-lines", 50) + consoleType := flagValue(flags, "console-type", "error") + stacktrace := flagValue(flags, "stacktrace", "none") + keepPlaying := hasFlag(flags, "keep-playing") + + initial, err := resolve() + if err != nil { + return nil, err + } + summary.Project = initial.ProjectPath + initialPlaying := isPlayState(initial.State) + shouldStop := false + + if summary.Compile { + resp, err := send("refresh_unity", map[string]interface{}{"compile": "request"}) + if err != nil { + return nil, err + } + if !resp.Success { + summary.CompileOK = false + summary.Messages = append(summary.Messages, resp.Message) + return verifyResponse(summary), nil + } + + if waitForReady(resolve) { + summary.CompileOK = false + summary.Messages = append(summary.Messages, "Compilation finished with errors.") + return verifyResponse(summary), nil + } + } + + if summary.Play { + resp, err := send("manage_editor", map[string]interface{}{ + "action": "play", + "wait_for_completion": true, + }) + if err != nil { + return nil, err + } + if !resp.Success { + summary.Messages = append(summary.Messages, resp.Message) + return verifyResponse(summary), nil + } + summary.EnteredPlay = true + summary.Messages = append(summary.Messages, resp.Message) + shouldStop = !initialPlaying && !keepPlaying + } + + consoleResp, err := send("console", map[string]interface{}{ + "type": consoleType, + "lines": lines, + "stacktrace": stacktrace, + }) + if err != nil { + return nil, err + } + if !consoleResp.Success { + summary.RuntimeOK = false + summary.Messages = append(summary.Messages, consoleResp.Message) + stopVerifyPlayMode(send, shouldStop, &summary) + return verifyResponse(summary), nil + } + + entries, err := consoleEntries(consoleResp) + if err != nil { + return nil, err + } + summary.Errors = entries + summary.ErrorCount = len(entries) + summary.RuntimeOK = summary.ErrorCount == 0 + + if summary.RuntimeOK && summary.CompileOK { + summary.Messages = append(summary.Messages, "Verification passed.") + } + + stopVerifyPlayMode(send, shouldStop, &summary) + return verifyResponse(summary), nil +} + +func stopVerifyPlayMode(send sendFn, shouldStop bool, summary *verifySummary) { + if !shouldStop { + return + } + stopResp, stopErr := send("manage_editor", map[string]interface{}{ + "action": "stop", + "wait_for_completion": true, + }) + if stopErr != nil { + summary.Messages = append(summary.Messages, "Failed to stop play mode: "+stopErr.Error()) + return + } + if stopResp == nil { + summary.Messages = append(summary.Messages, "Failed to stop play mode: empty response") + return + } + if !stopResp.Success { + summary.Messages = append(summary.Messages, "Failed to stop play mode: "+stopResp.Message) + return + } + summary.Stopped = true +} + +func verifyResponse(summary verifySummary) *client.CommandResponse { + success := summary.CompileOK && summary.RuntimeOK + message := "Verify passed." + if !success { + message = "Verify failed." + } + data, _ := json.Marshal(summary) + return &client.CommandResponse{ + Success: success, + Message: message, + Data: data, + } +} + +func consoleEntries(resp *client.CommandResponse) ([]string, error) { + if resp == nil || len(resp.Data) == 0 || string(resp.Data) == "null" { + return nil, nil + } + var entries []string + if err := json.Unmarshal(resp.Data, &entries); err != nil { + return nil, fmt.Errorf("failed to parse console entries: %w", err) + } + return entries, nil +} + +func hasFlag(flags map[string]string, name string) bool { + _, ok := flags[name] + return ok +} + +func flagValue(flags map[string]string, name, fallback string) string { + value := strings.TrimSpace(flags[name]) + if value == "" { + return fallback + } + return value +} + +func parsePositiveIntFlag(flags map[string]string, name string, fallback int) int { + value := strings.TrimSpace(flags[name]) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func isPlayState(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "playing", "paused", "entering_playmode": + return true + default: + return false + } +} diff --git a/cmd/verify_test.go b/cmd/verify_test.go new file mode 100644 index 0000000..4ac5231 --- /dev/null +++ b/cmd/verify_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/youngwoocho02/unity-cli/internal/client" +) + +func TestVerifyCmd_DefaultFlowPassesAndStops(t *testing.T) { + var calls []string + send := func(cmd string, params interface{}) (*client.CommandResponse, error) { + calls = append(calls, cmd) + switch cmd { + case "refresh_unity": + return &client.CommandResponse{Success: true, Message: "Refresh requested."}, nil + case "manage_editor": + p := params.(map[string]interface{}) + if p["action"] == "play" { + return &client.CommandResponse{Success: true, Message: "Entered play mode (confirmed)."}, nil + } + return &client.CommandResponse{Success: true, Message: "Exited play mode (confirmed)."}, nil + case "console": + data, _ := json.Marshal([]string{}) + return &client.CommandResponse{Success: true, Message: "Retrieved 0 entries.", Data: data}, nil + default: + t.Fatalf("unexpected command %q", cmd) + return nil, nil + } + } + resolve := func() (*client.Instance, error) { + return &client.Instance{State: "ready", ProjectPath: "/projects/current"}, nil + } + + resp, err := verifyCmd(nil, send, resolve) + if err != nil { + t.Fatalf("verifyCmd returned error: %v", err) + } + if resp == nil || !resp.Success { + t.Fatalf("response = %#v, want success", resp) + } + want := []string{"refresh_unity", "manage_editor", "console", "manage_editor"} + if !sliceEqual(calls, want) { + t.Fatalf("calls = %v, want %v", calls, want) + } + var summary verifySummary + if err := json.Unmarshal(resp.Data, &summary); err != nil { + t.Fatalf("failed to parse summary: %v", err) + } + if !summary.CompileOK || !summary.RuntimeOK || !summary.Stopped { + t.Fatalf("summary = %+v, want compile/runtime ok and stopped", summary) + } +} + +func TestVerifyCmd_FailsOnConsoleErrors(t *testing.T) { + send := func(cmd string, params interface{}) (*client.CommandResponse, error) { + switch cmd { + case "refresh_unity", "manage_editor": + return &client.CommandResponse{Success: true, Message: cmd}, nil + case "console": + data, _ := json.Marshal([]string{"boom"}) + return &client.CommandResponse{Success: true, Message: "Retrieved 1 entries.", Data: data}, nil + default: + t.Fatalf("unexpected command %q", cmd) + return nil, nil + } + } + resolve := func() (*client.Instance, error) { + return &client.Instance{State: "ready", ProjectPath: "/projects/current"}, nil + } + + resp, err := verifyCmd([]string{"--skip-compile"}, send, resolve) + if err != nil { + t.Fatalf("verifyCmd returned error: %v", err) + } + if resp.Success { + t.Fatal("expected verify failure") + } + var summary verifySummary + if err := json.Unmarshal(resp.Data, &summary); err != nil { + t.Fatalf("failed to parse summary: %v", err) + } + if summary.ErrorCount != 1 || summary.RuntimeOK { + t.Fatalf("summary = %+v, want one runtime error", summary) + } +} + +func TestVerifyCmd_DoesNotStopPreexistingPlayMode(t *testing.T) { + var stopCalled bool + send := func(cmd string, params interface{}) (*client.CommandResponse, error) { + if cmd == "manage_editor" { + p := params.(map[string]interface{}) + if p["action"] == "stop" { + stopCalled = true + } + } + if cmd == "console" { + data, _ := json.Marshal([]string{}) + return &client.CommandResponse{Success: true, Data: data}, nil + } + return &client.CommandResponse{Success: true, Message: cmd}, nil + } + resolve := func() (*client.Instance, error) { + return &client.Instance{State: "playing", ProjectPath: "/projects/current"}, nil + } + + resp, err := verifyCmd([]string{"--skip-compile"}, send, resolve) + if err != nil { + t.Fatalf("verifyCmd returned error: %v", err) + } + if !resp.Success { + t.Fatalf("expected success, got %#v", resp) + } + if stopCalled { + t.Fatal("verify should not stop play mode it did not start") + } +}