diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98694f8..b7f271f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: jobs: build-and-test: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Codecov OIDC upload; no stored token needed steps: - uses: actions/checkout@v4 @@ -34,4 +37,12 @@ jobs: # outside internal/httpx constructs a raw http.Client / http.NewRequest — # so un-receipted egress can't merge. - name: Test - run: go test ./... + run: go test -coverprofile=coverage.out -covermode=atomic ./... + + - name: Upload coverage + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + uses: codecov/codecov-action@v5 + with: + files: coverage.out + use_oidc: true + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index e4febd6..c06a9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ dist/ security-roast-report.md .claude/ +coverage.out diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f0788..d9c59bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +### Changed +- Go module path is now `github.com/Quality-Max/qmax-code`, matching the + GitHub repository. The old lowercase path stopped the Go proxy and + pkg.go.dev from resolving the module. `go install + github.com/Quality-Max/qmax-code@latest` works from the next tagged release. + +### Added +- Haiku 5.5 as an explicit Anthropic API and Claude Code model option, with + request settings and per-request cost estimates. The `haiku` shorthand and + automatic chat routing continue to use Haiku 4.5 until verified. +- Regression coverage for CLI commands, agent loops, the REPL, terminal UI, + authentication, sessions, setup, telemetry, and VNC frame handling. +- Package documentation for `pkg.go.dev` and a Go Reference badge. +- CI uploads test coverage to Codecov. + ## [1.38.0] - 2026-10-14 ### Changed diff --git a/README.md b/README.md index bec8592..db1d7d1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ [![GitHub downloads](https://img.shields.io/github/downloads/Quality-Max/qmax-code/total?label=downloads)](https://github.com/Quality-Max/qmax-code/releases) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Made with Go](https://img.shields.io/badge/made%20with-Go%201.25.13+-00ADD8.svg)](https://go.dev/) +[![Go Reference](https://pkg.go.dev/badge/github.com/Quality-Max/qmax-code.svg)](https://pkg.go.dev/github.com/Quality-Max/qmax-code) [![Announcement](https://img.shields.io/badge/announcement-Apache%202.0-7c6cf0.svg)](docs/releases/v1.38.0.md) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-yellow?logo=buymeacoffee)](https://buymeacoffee.com/qualitymax) @@ -123,6 +124,12 @@ curl -sL https://qualitymax.io/static/install-qmax-code.txt | bash To build from source instead: +```bash +go install github.com/Quality-Max/qmax-code@latest +``` + +Or from a checkout: + ```bash git clone https://github.com/Quality-Max/qmax-code.git cd qmax-code @@ -274,6 +281,13 @@ troubleshooting. | OpenCode | `/opencode` or `/orch` | Per-provider key in OS keychain | CLI subprocess for opt-in Z.AI, Groq, and OpenRouter providers. | | Ollama | `/ollama` or `/orch` | Configured Ollama endpoint | Self-hosted inference; configure the URL and model first. | +Haiku 5.5 is an explicit option in the Anthropic API and Claude Code pickers, +or with `/model claude-haiku-5-5`. The `haiku` shorthand and automatic chat +routing continue to use Haiku 4.5 until the upgrade is verified. Direct Haiku +5.5 requests disable thinking to reserve the response budget for text and +tools. Cost estimates preserve each request's prompt-size tier and reported +cache tokens; historical sessions without recorded costs remain estimates. + QualityMax authentication is independent of model-provider authentication. You can run any backend with `--local` and no QualityMax login. Without `--local`, qmax-code starts the QualityMax onboarding flow when no supported QualityMax diff --git a/claude_code_command.go b/claude_code_command.go index b909df3..62cd1fd 100644 --- a/claude_code_command.go +++ b/claude_code_command.go @@ -13,8 +13,8 @@ import ( "runtime" "strings" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/setup" ) const maxClaudeCodeAuthBytes = 64 * 1024 diff --git a/claude_code_command_handler_test.go b/claude_code_command_handler_test.go new file mode 100644 index 0000000..76c1e4a --- /dev/null +++ b/claude_code_command_handler_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// stubCCSeams swaps the three connectClaudeCode seams and restores them at +// test end. Returns the login stub so a test can record calls. +func stubCCSeams(t *testing.T, load func() *api.AuthConfig, login func() (*api.AuthConfig, error), upload func(context.Context, *api.AuthConfig, string) (*api.ClaudeCodeConnection, error)) { + t.Helper() + oldLoad := loadQualityMaxAuthForCC + oldLogin := loginQualityMaxForCC + oldUpload := uploadClaudeCodeAuth + loadQualityMaxAuthForCC = load + loginQualityMaxForCC = login + uploadClaudeCodeAuth = upload + t.Cleanup(func() { + loadQualityMaxAuthForCC = oldLoad + loginQualityMaxForCC = oldLogin + uploadClaudeCodeAuth = oldUpload + }) +} + +// writeClaudeCredentials writes a credentials file under the temp HOME so +// readClaudeCodeAuth's file fallback finds it. Combine with +// isolatePathWithFailingSecurity to keep the macOS keychain out of the picture. +func writeClaudeCredentials(t *testing.T, content string) { + t.Helper() + home := withTempHome(t) + dir := filepath.Join(home, ".config", "claude") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "credentials.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestHandleCCCommandUsage(t *testing.T) { + cases := [][]string{ + nil, + {}, + {"login"}, + {"connect", "extra"}, + } + for _, args := range cases { + err := handleCCCommand(args) + if err == nil || !strings.Contains(err.Error(), "usage: qmax-code cc connect") { + t.Errorf("handleCCCommand(%v) = %v, want usage error", args, err) + } + } +} + +func TestHandleCCCommandConnectUploadsSession(t *testing.T) { + isolatePathWithFailingSecurity(t) + authJSON := `{"tokens":{"access_token":"access"}}` + writeClaudeCredentials(t, authJSON) + + uploadRan := false + stubCCSeams(t, + func() *api.AuthConfig { return &api.AuthConfig{APIKey: "qm-user"} }, + func() (*api.AuthConfig, error) { + t.Fatal("existing QualityMax auth should be reused") + return nil, nil + }, + func(ctx context.Context, auth *api.AuthConfig, gotJSON string) (*api.ClaudeCodeConnection, error) { + uploadRan = true + if gotJSON != authJSON { + t.Fatalf("uploaded auth JSON = %q, want file content", gotJSON) + } + return &api.ClaudeCodeConnection{Connected: true, AccountLabel: "user@example.com"}, nil + }, + ) + stdout, _ := captureOutput(t) + + if err := handleCCCommand([]string{"connect"}); err != nil { + t.Fatalf("handleCCCommand(connect): %v", err) + } + if !uploadRan { + t.Fatal("upload was never invoked") + } + if out := stdout(); !strings.Contains(out, "connected to QualityMax as user@example.com") { + t.Fatalf("stdout = %q, want account-label confirmation", out) + } +} + +func TestConnectClaudeCodeRejectsNonJSONCredentials(t *testing.T) { + isolatePathWithFailingSecurity(t) + writeClaudeCredentials(t, "definitely not json") + + stubCCSeams(t, + func() *api.AuthConfig { return &api.AuthConfig{APIKey: "qm-user"} }, + nil, + func(context.Context, *api.AuthConfig, string) (*api.ClaudeCodeConnection, error) { + t.Fatal("upload must not run for invalid credentials") + return nil, nil + }, + ) + + err := connectClaudeCode(context.Background()) + if err == nil || !strings.Contains(err.Error(), "not a valid JSON object") { + t.Fatalf("connectClaudeCode error = %v, want JSON validation error", err) + } +} + +func TestConnectClaudeCodeErrorPropagatesThroughHandler(t *testing.T) { + isolatePathWithFailingSecurity(t) + writeClaudeCredentials(t, "definitely not json") + + stubCCSeams(t, func() *api.AuthConfig { return &api.AuthConfig{APIKey: "qm-user"} }, nil, nil) + + if err := handleCCCommand([]string{"connect"}); err == nil { + t.Fatal("handleCCCommand should surface the credential error") + } +} + +func TestConnectClaudeCodeLogsInWhenUnauthenticated(t *testing.T) { + isolatePathWithFailingSecurity(t) + writeClaudeCredentials(t, `{"tokens":{"access_token":"access"}}`) + + loginCalls := 0 + stubCCSeams(t, + func() *api.AuthConfig { return nil }, // no stored QualityMax auth + func() (*api.AuthConfig, error) { + loginCalls++ + return &api.AuthConfig{APIKey: "fresh-login"}, nil + }, + func(ctx context.Context, auth *api.AuthConfig, authJSON string) (*api.ClaudeCodeConnection, error) { + if auth.APIKey != "fresh-login" { + t.Fatalf("upload used APIKey %q, want the freshly logged-in one", auth.APIKey) + } + return &api.ClaudeCodeConnection{Connected: true}, nil + }, + ) + stdout, _ := captureOutput(t) + + if err := connectClaudeCode(context.Background()); err != nil { + t.Fatalf("connectClaudeCode: %v", err) + } + if loginCalls != 1 { + t.Fatalf("login calls = %d, want 1", loginCalls) + } + if out := stdout(); !strings.Contains(out, "Connect this computer to QualityMax") { + t.Fatalf("stdout = %q, want login prompt message", out) + } + if out := stdout(); !strings.Contains(out, "Claude Code connected to QualityMax.") { + t.Fatalf("stdout = %q, want unlabelled confirmation", out) + } +} diff --git a/claude_code_command_test.go b/claude_code_command_test.go index 88b776b..3161e81 100644 --- a/claude_code_command_test.go +++ b/claude_code_command_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestReadClaudeCodeAuthFile(t *testing.T) { diff --git a/cmd_receipt_test.go b/cmd_receipt_test.go new file mode 100644 index 0000000..3c1bac9 --- /dev/null +++ b/cmd_receipt_test.go @@ -0,0 +1,314 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +// useTempReceipts points the receipt store at a temp HOME and returns the +// BaseDir it installed. receipt.BaseDir is process-global, so it is restored +// when the test ends. +func useTempReceipts(t *testing.T) string { + t.Helper() + home := withTempHome(t) + oldBase := receipt.BaseDir + t.Cleanup(func() { receipt.BaseDir = oldBase }) + initReceiptPaths() + base := filepath.Join(home, ".qmax-code") + if receipt.BaseDir != base { + t.Fatalf("initReceiptPaths: BaseDir = %q, want %q", receipt.BaseDir, base) + } + return base +} + +// finalizeFixtureReceipt creates a signed receipt with one entry per host and +// writes it to the store, returning the in-memory receipt. +func finalizeFixtureReceipt(t *testing.T, kind string, hosts ...string) *receipt.Receipt { + t.Helper() + r := receipt.NewCurrent(kind) + for _, host := range hosts { + r.Record(receipt.Entry{ + Method: "POST", + Host: host, + Path: "/v1/chat", + Category: "llm", + ReqBytes: 128, + Allowed: true, + }) + } + if _, err := r.Finalize(); err != nil { + t.Fatalf("fixture Finalize: %v", err) + } + return r +} + +func TestArgAt(t *testing.T) { + if got := argAt([]string{"list", "show"}, 0); got != "list" { + t.Errorf("argAt(..., 0) = %q, want list", got) + } + if got := argAt([]string{"list", "show"}, 1); got != "show" { + t.Errorf("argAt(..., 1) = %q, want show", got) + } + if got := argAt([]string{"list"}, 1); got != "" { + t.Errorf("argAt(..., 1) out of range = %q, want empty", got) + } + if got := argAt(nil, 0); got != "" { + t.Errorf("argAt(nil, 0) = %q, want empty", got) + } +} + +func TestReceiptListEmpty(t *testing.T) { + useTempReceipts(t) + + out, _ := withCapturedOutput(t, receiptList) + + if !strings.Contains(out, "No exposure receipts found.") { + t.Fatalf("receiptList output = %q, want empty-store message", out) + } +} + +func TestReceiptListShowsRuns(t *testing.T) { + useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:interactive", "api.anthropic.com") + + out, _ := withCapturedOutput(t, receiptList) + + if !strings.Contains(out, "RUN ID") || !strings.Contains(out, "KIND") { + t.Fatalf("receiptList missing header:\n%s", out) + } + if !strings.Contains(out, r.RunID) { + t.Fatalf("receiptList missing run %s:\n%s", r.RunID, out) + } + if !strings.Contains(out, "session:interactive") { + t.Fatalf("receiptList missing kind:\n%s", out) + } + if !strings.Contains(out, "api.anthropic.com") { + t.Fatalf("receiptList missing destination:\n%s", out) + } +} + +func TestReceiptListSkipsUnreadableFiles(t *testing.T) { + base := useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:mcp", "api.cerebras.ai") + dir := filepath.Join(base, "receipts") + if err := os.WriteFile(filepath.Join(dir, "corrupt.json"), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + + out, _ := withCapturedOutput(t, receiptList) + + if !strings.Contains(out, r.RunID) { + t.Fatalf("receiptList should still list the valid receipt:\n%s", out) + } + if strings.Contains(out, "corrupt") { + t.Fatalf("receiptList should silently skip unreadable files:\n%s", out) + } +} + +func TestReceiptListReportsStoreErrors(t *testing.T) { + useTempReceipts(t) + receipt.BaseDir = "" // forces receipt.List to fail + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, receiptList) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "Error:") { + t.Fatalf("stderr = %q, want error message", stderr) + } +} + +func TestReceiptShowLatest(t *testing.T) { + useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:interactive", "api.anthropic.com") + + out, _ := withCapturedOutput(t, func() { receiptShow("") }) + + if !strings.Contains(out, `"run_id": "`+r.RunID+`"`) { + t.Fatalf("receiptShow(\"\") output missing run_id:\n%s", out) + } + if !strings.Contains(out, `"run_kind": "session:interactive"`) { + t.Fatalf("receiptShow(\"\") output missing run_kind:\n%s", out) + } +} + +func TestReceiptShowLatestKeywordAndID(t *testing.T) { + useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:cc", "api.anthropic.com") + + for _, id := range []string{"latest", r.RunID} { + out, _ := withCapturedOutput(t, func() { receiptShow(id) }) + if !strings.Contains(out, r.RunID) { + t.Fatalf("receiptShow(%q) output missing run id:\n%s", id, out) + } + } +} + +func TestReceiptShowUnknownIDExits(t *testing.T) { + useTempReceipts(t) + finalizeFixtureReceipt(t, "session:interactive", "api.anthropic.com") + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { receiptShow("deadbeef") }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "Error:") { + t.Fatalf("stderr = %q, want error message", stderr) + } +} + +func TestReceiptShowNoReceiptsExits(t *testing.T) { + useTempReceipts(t) + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { receiptShow("latest") }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "No exposure receipts found.") { + t.Fatalf("stderr = %q, want no-receipts message", stderr) + } +} + +func TestReceiptVerifyValid(t *testing.T) { + useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:codex", "api.openai.com", "api.anthropic.com") + + out, _ := withCapturedOutput(t, func() { receiptVerify(r.RunID) }) + + if !strings.Contains(out, "✓ VALID") { + t.Fatalf("receiptVerify output = %q, want VALID marker", out) + } + if !strings.Contains(out, r.RunID) { + t.Fatalf("receiptVerify output missing run id:\n%s", out) + } + if !strings.Contains(out, "2 request(s)") { + t.Fatalf("receiptVerify output missing request count:\n%s", out) + } + if !strings.Contains(out, "provenance") { + t.Fatalf("receiptVerify output missing provenance caveat:\n%s", out) + } +} + +func TestReceiptVerifyTamperedReceiptExits(t *testing.T) { + base := useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:interactive", "api.anthropic.com") + + // Rewrite the stored receipt with a different run_id (renaming the file to + // match) but keep the original signature, so verification must fail. + path := filepath.Join(base, "receipts", r.RunID+".json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + fakeID := "0123456789abcdef0123456789abcdef" + tampered := []byte(strings.ReplaceAll(string(data), r.RunID, fakeID)) + if err := os.WriteFile(filepath.Join(base, "receipts", fakeID+".json"), tampered, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { receiptVerify(fakeID) }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "✗ INVALID") { + t.Fatalf("stderr = %q, want INVALID marker", stderr) + } +} + +func TestMustResolveReceiptBadStoreExits(t *testing.T) { + useTempReceipts(t) + receipt.BaseDir = "" // forces receipt.ReceiptsDir to fail + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { mustResolveReceipt("whatever") }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "Error:") { + t.Fatalf("stderr = %q, want error message", stderr) + } +} + +func TestHandleReceiptCommandDispatch(t *testing.T) { + useTempReceipts(t) + r := finalizeFixtureReceipt(t, "session:interactive", "api.anthropic.com") + + t.Run("list is the default", func(t *testing.T) { + out, _ := withCapturedOutput(t, func() { handleReceiptCommand(nil) }) + if !strings.Contains(out, r.RunID) { + t.Fatalf("default subcommand should list receipts:\n%s", out) + } + }) + + t.Run("show explicit", func(t *testing.T) { + out, _ := withCapturedOutput(t, func() { handleReceiptCommand([]string{"show", r.RunID}) }) + if !strings.Contains(out, `"run_id": "`+r.RunID+`"`) { + t.Fatalf("show output missing run_id:\n%s", out) + } + }) + + t.Run("verify explicit", func(t *testing.T) { + out, _ := withCapturedOutput(t, func() { handleReceiptCommand([]string{"verify", r.RunID}) }) + if !strings.Contains(out, "✓ VALID") { + t.Fatalf("verify output = %q, want VALID marker", out) + } + }) +} + +func TestHandleReceiptCommandUnknownSubcommand(t *testing.T) { + useTempReceipts(t) + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { handleReceiptCommand([]string{"bogus"}) }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "Usage: qmax-code receipt ") { + t.Fatalf("stderr = %q, want usage message", stderr) + } +} + +func TestHandleReceiptCommandVerifyNoReceiptsExits(t *testing.T) { + useTempReceipts(t) + + var codes []int + _, stderr := withCapturedOutput(t, func() { + codes = runWithStubbedExit(t, func() { handleReceiptCommand([]string{"verify"}) }) + }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if !strings.Contains(stderr, "No exposure receipts found.") { + t.Fatalf("stderr = %q, want no-receipts message", stderr) + } +} diff --git a/codex_command.go b/codex_command.go index 32f5ed0..2ec04cd 100644 --- a/codex_command.go +++ b/codex_command.go @@ -11,9 +11,9 @@ import ( "os/exec" "path/filepath" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/setup" ) const maxCodexAuthBytes = 64 * 1024 diff --git a/codex_command_handler_test.go b/codex_command_handler_test.go new file mode 100644 index 0000000..ccd8afd --- /dev/null +++ b/codex_command_handler_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func TestHandleCodexCommandUsage(t *testing.T) { + cases := [][]string{ + nil, + {}, + {"login"}, + {"connect", "extra"}, + } + for _, args := range cases { + err := handleCodexCommand(args) + if err == nil || !strings.Contains(err.Error(), "usage: qmax-code codex connect") { + t.Errorf("handleCodexCommand(%v) = %v, want usage error", args, err) + } + } +} + +func TestHandleCodexCommandConnectRequiresCodexBinary(t *testing.T) { + oldFind := findCodexForConnect + findCodexForConnect = func() string { return "" } + t.Cleanup(func() { findCodexForConnect = oldFind }) + + err := handleCodexCommand([]string{"connect"}) + if err == nil || !strings.Contains(err.Error(), "Codex CLI not found") { + t.Fatalf("handleCodexCommand error = %v, want binary-not-found", err) + } +} + +func TestHandleCodexCommandConnectUploadsSession(t *testing.T) { + oldFind := findCodexForConnect + oldRun := runCodexLogin + oldLoad := loadQualityMaxAuth + oldLogin := loginQualityMax + oldUpload := uploadCodexAuth + t.Cleanup(func() { + findCodexForConnect = oldFind + runCodexLogin = oldRun + loadQualityMaxAuth = oldLoad + loginQualityMax = oldLogin + uploadCodexAuth = oldUpload + }) + + home := withTempHome(t) + dir := filepath.Join(home, ".codex") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + authJSON := `{"tokens":{"access_token":"access","refresh_token":"refresh"}}` + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(authJSON), 0o600); err != nil { + t.Fatal(err) + } + + loginRan := false + uploadRan := false + findCodexForConnect = func() string { return "/usr/local/bin/codex" } + runCodexLogin = func(string) error { loginRan = true; return nil } + loadQualityMaxAuth = func() *api.AuthConfig { return &api.AuthConfig{APIKey: "qm-user"} } + loginQualityMax = func() (*api.AuthConfig, error) { + t.Fatal("existing QualityMax auth should be reused") + return nil, nil + } + uploadCodexAuth = func(ctx context.Context, auth *api.AuthConfig, gotJSON string) (*api.CodexConnection, error) { + uploadRan = true + if gotJSON != authJSON { + t.Fatalf("uploaded auth JSON = %q, want file content", gotJSON) + } + return &api.CodexConnection{Connected: true, AccountLabel: "user@example.com"}, nil + } + stdout, _ := captureOutput(t) + + if err := handleCodexCommand([]string{"connect"}); err != nil { + t.Fatalf("handleCodexCommand(connect): %v", err) + } + if !loginRan || !uploadRan { + t.Fatalf("loginRan=%v uploadRan=%v, want both", loginRan, uploadRan) + } + if out := stdout(); !strings.Contains(out, "connected to QualityMax as user@example.com") { + t.Fatalf("stdout = %q, want account-label confirmation", out) + } +} + +func writeCodexAuth(t *testing.T, content string) { + t.Helper() + home := withTempHome(t) + dir := filepath.Join(home, ".codex") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestReadCodexAuthFileMissingFile(t *testing.T) { + withTempHome(t) + + if _, err := readCodexAuthFile(); err == nil { + t.Fatal("expected error when ~/.codex/auth.json does not exist") + } +} + +func TestReadCodexAuthFileInvalidJSON(t *testing.T) { + writeCodexAuth(t, "{not json") + + _, err := readCodexAuthFile() + if err == nil || !strings.Contains(err.Error(), "is not valid JSON") { + t.Fatalf("readCodexAuthFile error = %v, want JSON parse error", err) + } +} + +func TestReadCodexAuthFileMissingRefreshToken(t *testing.T) { + writeCodexAuth(t, `{"tokens":{"access_token":"access"}}`) + + _, err := readCodexAuthFile() + if err == nil || !strings.Contains(err.Error(), "refresh_token") { + t.Fatalf("readCodexAuthFile error = %v, want missing refresh_token error", err) + } +} + +func TestReadCodexAuthFileMissingAccessToken(t *testing.T) { + writeCodexAuth(t, `{"tokens":{"refresh_token":"refresh"}}`) + + _, err := readCodexAuthFile() + if err == nil || !strings.Contains(err.Error(), "access_token") { + t.Fatalf("readCodexAuthFile error = %v, want missing access_token error", err) + } +} + +func TestReadCodexAuthFileRejectsOversizedFile(t *testing.T) { + writeCodexAuth(t, strings.Repeat("x", maxCodexAuthBytes+1)) + + _, err := readCodexAuthFile() + if err == nil || !strings.Contains(err.Error(), "larger than") { + t.Fatalf("readCodexAuthFile error = %v, want size-limit error", err) + } +} diff --git a/codex_command_test.go b/codex_command_test.go index a02ba23..074f15f 100644 --- a/codex_command_test.go +++ b/codex_command_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestReadCodexAuthFileRequiresOAuthTokens(t *testing.T) { diff --git a/config_command.go b/config_command.go index b44d484..513c695 100644 --- a/config_command.go +++ b/config_command.go @@ -6,10 +6,10 @@ import ( "strconv" "strings" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // handleConfigCommand implements the `qmax-code config ...` subcommand diff --git a/config_command_handler_test.go b/config_command_handler_test.go new file mode 100644 index 0000000..e30424a --- /dev/null +++ b/config_command_handler_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// unsetOverridingEnv clears the env vars LoadQMaxCodeConfig layers on top of +// config.json, so assertions about printed values stay deterministic no matter +// what the developer's shell exports. +func unsetOverridingEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "OLLAMA_BASE_URL", "OLLAMA_MODEL", + "CEREBRAS_API_KEY", "CEREBRAS_API_BASE", "CEREBRAS_MODEL", "CEREBRAS_REASONING_EFFORT", + } { + orig, had := os.LookupEnv(key) + if !had { + continue + } + os.Unsetenv(key) + t.Cleanup(func() { os.Setenv(key, orig) }) + } +} + +func TestHandleConfigCommandShowIsTheDefault(t *testing.T) { + withTempHome(t) + isolatePathWithFailingSecurity(t) + unsetOverridingEnv(t) + stdout, _ := captureOutput(t) + + for _, args := range [][]string{nil, {"show"}} { + handleConfigCommand(args) + } + + // Two invocations → the header shows up twice; both must render defaults. + out := stdout() + if n := strings.Count(out, "Current config"); n != 2 { + t.Fatalf("header appeared %d times, want 2:\n%s", n, out) + } + if !strings.Contains(out, "default_model") || !strings.Contains(out, `"auto"`) { + t.Fatalf("default_model missing from show output:\n%s", out) + } + if !strings.Contains(out, "plan_window_hours = 5 (default)") { + t.Fatalf("plan_window_hours default missing from show output:\n%s", out) + } + if !strings.Contains(out, `"historic"`) { + t.Fatalf("default theme missing from show output:\n%s", out) + } +} + +func TestHandleConfigCommandSet(t *testing.T) { + withTempHome(t) + stdout, _ := captureOutput(t) + + handleConfigCommand([]string{"set", "default_framework", "playwright"}) + + if out := stdout(); !strings.Contains(out, "Set default_framework = playwright") { + t.Fatalf("stdout = %q, want set confirmation", out) + } + loaded := api.LoadQMaxCodeConfig() + if loaded.DefaultFramework != "playwright" { + t.Fatalf("persisted DefaultFramework = %q, want playwright", loaded.DefaultFramework) + } +} + +func TestHandleConfigCommandSetMissingValueExits(t *testing.T) { + withTempHome(t) + _, stderr := captureOutput(t) + + codes := runWithStubbedExit(t, func() { handleConfigCommand([]string{"set", "default_framework"}) }) + + if len(codes) != 1 || codes[0] != 2 { + t.Fatalf("exit codes = %v, want [2]", codes) + } + if out := stderr(); !strings.Contains(out, "Usage: qmax-code config set KEY VALUE") { + t.Fatalf("stderr = %q, want usage message", out) + } +} + +func TestHandleConfigCommandSetInvalidValueExits(t *testing.T) { + withTempHome(t) + _, stderr := captureOutput(t) + + codes := runWithStubbedExit(t, func() { handleConfigCommand([]string{"set", "default_framework", "bash"}) }) + + if len(codes) != 1 || codes[0] != 1 { + t.Fatalf("exit codes = %v, want [1]", codes) + } + if out := stderr(); !strings.Contains(out, "Error:") { + t.Fatalf("stderr = %q, want error message", out) + } +} + +func TestHandleConfigCommandUnset(t *testing.T) { + withTempHome(t) + if err := setConfigField("default_framework", "go_test"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + handleConfigCommand([]string{"unset", "default_framework"}) + + if out := stdout(); !strings.Contains(out, "Cleared default_framework") { + t.Fatalf("stdout = %q, want cleared confirmation", out) + } + if loaded := api.LoadQMaxCodeConfig(); loaded.DefaultFramework != "" { + t.Fatalf("DefaultFramework after unset = %q, want empty", loaded.DefaultFramework) + } +} + +func TestHandleConfigCommandUnsetMissingKeyExits(t *testing.T) { + withTempHome(t) + _, stderr := captureOutput(t) + + codes := runWithStubbedExit(t, func() { handleConfigCommand([]string{"unset"}) }) + + if len(codes) != 1 || codes[0] != 2 { + t.Fatalf("exit codes = %v, want [2]", codes) + } + if out := stderr(); !strings.Contains(out, "Usage: qmax-code config unset KEY") { + t.Fatalf("stderr = %q, want usage message", out) + } +} + +func TestHandleConfigCommandReset(t *testing.T) { + home := withTempHome(t) + if err := setConfigField("auto_save", "false"); err != nil { + t.Fatal(err) + } + if err := setConfigField("theme", "ocean"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + handleConfigCommand([]string{"reset"}) + + if out := stdout(); !strings.Contains(out, "api.Config reset to defaults") { + t.Fatalf("stdout = %q, want reset confirmation", out) + } + data, err := os.ReadFile(filepath.Join(home, ".qmax-code", "config.json")) + if err != nil { + t.Fatalf("config file not rewritten by reset: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("reset config malformed: %v", err) + } + loaded := api.LoadQMaxCodeConfig() + if !loaded.AutoSave || loaded.Theme != "" || loaded.DefaultModel != "auto" { + t.Fatalf("post-reset config = %+v, want defaults", loaded) + } +} + +func TestHandleConfigCommandUnknownSubcommandExits(t *testing.T) { + withTempHome(t) + _, stderr := captureOutput(t) + + codes := runWithStubbedExit(t, func() { handleConfigCommand([]string{"bogus"}) }) + + if len(codes) != 1 || codes[0] != 2 { + t.Fatalf("exit codes = %v, want [2]", codes) + } + out := stderr() + if !strings.Contains(out, "Unknown config command: bogus") { + t.Fatalf("stderr = %q, want unknown-command message", out) + } + if !strings.Contains(out, "Try: qmax-code config [show|set|unset|reset]") { + t.Fatalf("stderr = %q, want hint", out) + } +} + +func TestPrintConfigDefaults(t *testing.T) { + withTempHome(t) + isolatePathWithFailingSecurity(t) + unsetOverridingEnv(t) + stdout, _ := captureOutput(t) + + printConfig() + + out := stdout() + for _, want := range []string{ + "plan_window_hours = 5 (default)", + "narrate_tool_calls", + `"brief"`, + "gpt-oss-120b (default)", + "https://api.cerebras.ai/v1 (default)", + "none (default — Gemma 4 thinking off)", + "anthropic_key = (not set)", + "cerebras_key = (not set)", + "ollama_url = (not set)", + "cloud_sync = not set (will prompt on next eligible session)", + "live_feed = false", + } { + if !strings.Contains(out, want) { + t.Errorf("printConfig output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "providers") { + t.Errorf("printConfig should omit the providers line when none are enabled:\n%s", out) + } +} + +func TestPrintConfigOllamaMasked(t *testing.T) { + withTempHome(t) + unsetOverridingEnv(t) + if err := setConfigField("ollama_url", "http://alice:s3cr3t@127.0.0.1:11434"); err != nil { + t.Fatal(err) + } + if err := setConfigField("ollama_model", "llama3"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + out := stdout() + if !strings.Contains(out, "127.0.0.1:11434") { + t.Errorf("ollama_url missing host:\n%s", out) + } + if !strings.Contains(out, "alice:%2A%2A%2A%2A@") { + t.Errorf("ollama_url password not masked:\n%s", out) + } + if strings.Contains(out, "s3cr3t") { + t.Errorf("ollama_url password leaked:\n%s", out) + } + if !strings.Contains(out, `"llama3"`) { + t.Errorf("ollama_model missing:\n%s", out) + } +} + +func TestPrintConfigCustomPlanWindow(t *testing.T) { + withTempHome(t) + if err := setConfigField("plan_window_hours", "8"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + out := stdout() + if !strings.Contains(out, "plan_window_hours = 8") { + t.Errorf("custom plan_window_hours missing:\n%s", out) + } + if strings.Contains(out, "plan_window_hours = 8 (default)") { + t.Errorf("non-default window should not be marked default:\n%s", out) + } +} + +func TestPrintConfigCloudSyncAndLiveFeed(t *testing.T) { + withTempHome(t) + stdout, _ := captureOutput(t) + + printConfig() // fresh: not set + if err := setConfigField("cloud_sync", "true"); err != nil { + t.Fatal(err) + } + if err := setConfigField("live_feed", "true"); err != nil { + t.Fatal(err) + } + printConfig() // enabled + if err := setConfigField("cloud_sync", "false"); err != nil { + t.Fatal(err) + } + printConfig() // disabled + + out := stdout() + for _, want := range []string{ + "cloud_sync = not set (will prompt on next eligible session)", + "cloud_sync = enabled", + "cloud_sync = disabled", + "live_feed = true", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestPrintConfigCerebrasBackendWithKey(t *testing.T) { + withTempHome(t) + unsetOverridingEnv(t) + setenv(t, "CEREBRAS_API_KEY", "csk-test-key") + if err := setConfigField("backend", "cerebras"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + out := stdout() + if !strings.Contains(out, `= "cerebras"`) { + t.Errorf("backend selection missing:\n%s", out) + } + if !strings.Contains(out, "(cerebras key set; model: gpt-oss-120b") { + t.Errorf("cerebras key branch missing:\n%s", out) + } + if !strings.Contains(out, "cerebras_key = (set; stored in OS keychain)") { + t.Errorf("cerebras_key set line missing:\n%s", out) + } +} + +func TestPrintConfigCerebrasBackendWithoutKeyWarns(t *testing.T) { + withTempHome(t) + isolatePathWithFailingSecurity(t) + unsetOverridingEnv(t) + if err := setConfigField("backend", "cerebras"); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + if out := stdout(); !strings.Contains(out, "(WARNING: CEREBRAS_API_KEY not set)") { + t.Fatalf("cerebras no-key warning missing:\n%s", out) + } +} + +func TestPrintConfigBackendBinariesFound(t *testing.T) { + withTempHome(t) + + cases := []struct { + backend string + binary string + want string + }{ + {"cc", "claude", "(claude found: "}, + {"codex", "codex", "(codex found: "}, + {"agy", "agy", "(agy found: "}, + {"opencode", "opencode", "(opencode found: "}, + } + for _, tc := range cases { + t.Run(tc.backend, func(t *testing.T) { + prependFakeBinaries(t, tc.binary) + if err := setConfigField("backend", tc.backend); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + if out := stdout(); !strings.Contains(out, tc.want) { + t.Fatalf("output missing %q:\n%s", tc.want, out) + } + }) + } +} + +func TestPrintConfigProviders(t *testing.T) { + home := withTempHome(t) + // EnabledProviders has no setConfigField key; write config.json directly. + dir := filepath.Join(home, ".qmax-code") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + cfg := api.DefaultConfig() + cfg.EnabledProviders = []string{"zai-coding-plan", "groq"} + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0o600); err != nil { + t.Fatal(err) + } + stdout, _ := captureOutput(t) + + printConfig() + + if out := stdout(); !strings.Contains(out, "providers = zai-coding-plan, groq") { + t.Fatalf("providers line missing:\n%s", out) + } +} diff --git a/config_command_test.go b/config_command_test.go index 5408ee3..673e6c5 100644 --- a/config_command_test.go +++ b/config_command_test.go @@ -6,16 +6,17 @@ import ( "path/filepath" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) func withTempHome(t *testing.T) string { t.Helper() - orig := os.Getenv("HOME") tmp := t.TempDir() - os.Setenv("HOME", tmp) - t.Cleanup(func() { os.Setenv("HOME", orig) }) + t.Setenv("HOME", tmp) + unsetOverridingEnv(t) + // HOME does not isolate the OS keychain, so shadow its helpers as well. + prependFakeBinaries(t, "security", "secret-tool") return tmp } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6d56eec --- /dev/null +++ b/doc.go @@ -0,0 +1,18 @@ +// Command qmax-code is a QA-focused AI terminal agent. +// +// It runs standalone against a local repository or connects to QualityMax, +// and can switch inference between the Anthropic API, Cerebras, Ollama, and +// hosted coding agents (Claude Code, Codex, Antigravity, OpenCode) through +// /orch. Its focus is QA work: generating and running tests, crawling apps, +// reviewing diffs, and recording receipts. +// +// Install a release binary: +// +// curl -sL https://qualitymax.io/static/install-qmax-code.txt | bash +// +// Or build from source with Go 1.25.13 or newer: +// +// go install github.com/Quality-Max/qmax-code@latest +// +// Run qmax-code --help for flags and the README for slash commands. +package main diff --git a/gate_command.go b/gate_command.go index 494f35b..625f004 100644 --- a/gate_command.go +++ b/gate_command.go @@ -5,7 +5,7 @@ import ( "fmt" "io" - "github.com/qualitymax/qmax-code/internal/gate" + "github.com/Quality-Max/qmax-code/internal/gate" ) func runGateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { diff --git a/gate_command_run_test.go b/gate_command_run_test.go new file mode 100644 index 0000000..c5e25f2 --- /dev/null +++ b/gate_command_run_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// initMinimalGitRepo creates a tiny committed Go module inside a temp dir: +// one passing test, one commit. The gate running against `--base HEAD` sees an +// empty diff and a passing `go test ./...` / `go vet ./...`, so its verdict is +// PASS without touching the real repository. +func initMinimalGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + write := func(name, content string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + write("go.mod", "module gatetest\n\ngo 1.25\n") + write("gate_test.go", "package gatetest\n\nimport \"testing\"\n\nfunc TestNoop(t *testing.T) {}\n") + + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + // Ignore the developer's global/system git config so identity, + // signing, and hooks settings cannot affect the fixture. + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_SYSTEM="+os.DevNull, "GIT_CONFIG_COUNT=0") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + git("init", "-q") + git("add", "-A") + git("-c", "user.name=Gate Test", "-c", "user.email=gate@test.example", + "-c", "commit.gpgsign=false", "commit", "-q", "-m", "init") + return dir +} + +func TestRunGateCommandPassesOnCleanMinimalRepo(t *testing.T) { + t.Chdir(initMinimalGitRepo(t)) + + var stdout, stderr bytes.Buffer + code := runGateCommand(context.Background(), []string{"--base", "HEAD"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (PASS)\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "Base: HEAD") { + t.Errorf("stdout missing base line:\n%s", out) + } + if !strings.Contains(out, "Verdict: PASS") { + t.Errorf("stdout missing PASS verdict:\n%s", out) + } + if !strings.Contains(out, "go test ./...") || !strings.Contains(out, "go vet ./...") { + t.Errorf("stdout missing go checks:\n%s", out) + } + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunGateCommandIncompleteOutsideARepo(t *testing.T) { + t.Chdir(t.TempDir()) // empty dir: no git repo, no go.mod + + var stdout, stderr bytes.Buffer + code := runGateCommand(context.Background(), nil, &stdout, &stderr) + + if code != 2 { + t.Fatalf("exit code = %d, want 2 (INCOMPLETE)", code) + } + out := stdout.String() + if !strings.Contains(out, "Verdict: INCOMPLETE") { + t.Errorf("stdout missing INCOMPLETE verdict:\n%s", out) + } + if !strings.Contains(out, "cannot resolve base") { + t.Errorf("stdout missing explanation:\n%s", out) + } + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want empty", stderr.String()) + } +} diff --git a/go.mod b/go.mod index 40c7913..7c67459 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/qualitymax/qmax-code +module github.com/Quality-Max/qmax-code go 1.25.13 diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5ff44d1..8477c81 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -12,11 +12,11 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // AgentConfig holds configuration for the LLM agent. @@ -582,6 +582,7 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten Messages: sanitized, Tools: a.tools, Stream: true, + Thinking: api.DefaultThinking(model), } data, err := json.Marshal(reqBody) @@ -656,6 +657,13 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) var eventType string + var requestUsage api.APIUsage + var usageStarted bool + defer func() { + if usageStarted { + a.Usage.RecordRequest(model, requestUsage) + } + }() for scanner.Scan() { line := scanner.Text() @@ -684,9 +692,9 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten case "message_start": var ev sseMessageStart if err := json.Unmarshal([]byte(rawData), &ev); err == nil { - a.Usage.InputTokens += ev.Message.Usage.InputTokens - a.LastContextTokens = ev.Message.Usage.InputTokens - a.Usage.Requests++ + requestUsage = ev.Message.Usage + usageStarted = true + a.LastContextTokens = requestUsage.TotalInputTokens() if a.Cfg.Verbose { fmt.Fprintf(term.Stderr(), "[SSE] message_start: input_tokens=%d\n", ev.Message.Usage.InputTokens) } @@ -769,7 +777,8 @@ func (a *Agent) callStreamingAPI(term *tui.Terminal, model string) ([]api.Conten var ev sseMessageDelta if err := json.Unmarshal([]byte(rawData), &ev); err == nil { stopReason = ev.Delta.StopReason - a.Usage.OutputTokens += ev.Usage.OutputTokens + // message_delta usage is cumulative for this response. + requestUsage.OutputTokens = ev.Usage.OutputTokens if a.Cfg.Verbose { fmt.Fprintf(term.Stderr(), "[SSE] message_delta: stop=%s, output_tokens=%d\n", stopReason, ev.Usage.OutputTokens) } @@ -802,6 +811,7 @@ func (a *Agent) callAPI() (*api.APIResponse, error) { Messages: a.History, Tools: a.tools, Stream: false, + Thinking: api.DefaultThinking(a.Cfg.Model), } data, err := json.Marshal(reqBody) @@ -855,10 +865,8 @@ func (a *Agent) callAPI() (*api.APIResponse, error) { apiResp.Content = content // Track usage - a.Usage.InputTokens += apiResp.Usage.InputTokens - a.LastContextTokens = apiResp.Usage.InputTokens - a.Usage.OutputTokens += apiResp.Usage.OutputTokens - a.Usage.Requests++ + a.Usage.RecordRequest(a.Cfg.Model, apiResp.Usage) + a.LastContextTokens = apiResp.Usage.TotalInputTokens() if a.Cfg.Verbose { fmt.Printf("[API] Response: stop=%s, input=%d, output=%d tokens\n", diff --git a/internal/agent/agy_agent.go b/internal/agent/agy_agent.go index 7f139ad..0489e19 100644 --- a/internal/agent/agy_agent.go +++ b/internal/agent/agy_agent.go @@ -14,9 +14,9 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // AgyAgent orchestrates an Antigravity CLI (`agy`) subprocess for LLM @@ -33,23 +33,23 @@ import ( // 5. conversation_id is kept for --conversation on the next turn type AgyAgent struct { TurnTranscript - agyBin string - modelID string // "" = agy default; otherwise --model + agyBin string + modelID string // "" = agy default; otherwise --model effort string // "low" | "medium" | "high" outputVerbose bool narrateToolCalls string // "off" | "brief" | "full" — see narrationDirective permissionMode string // "standard" | "unattended" - conversationID string - sctx *api.SessionContext - lastTurnIn int - lastTurnOut int - lastTurnOK bool - lastLimitHit bool - lastLimitReset time.Time - lastStderr string - mu sync.Mutex - runMu sync.Mutex - runCancel context.CancelFunc + conversationID string + sctx *api.SessionContext + lastTurnIn int + lastTurnOut int + lastTurnOK bool + lastLimitHit bool + lastLimitReset time.Time + lastStderr string + mu sync.Mutex + runMu sync.Mutex + runCancel context.CancelFunc } const agyQASystemPrompt = `You are QMax, an elite QA engineer running inside Google Antigravity. diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index 17c31ca..0909f4a 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // CLIAgent is the interface implemented by both CCAgent (claude CLI) and @@ -70,24 +70,24 @@ type PlanLimitReporter interface { // 5. CC session ID is saved for --resume on the next turn type CCAgent struct { TurnTranscript - claudeBin string - modelID string // "" = let CC decide; otherwise passed as --model - effort string // "low" | "medium" | "high" — injected into system prompt + claudeBin string + modelID string // "" = let CC decide; otherwise passed as --model + effort string // "low" | "medium" | "high" — injected into system prompt outputVerbose bool // false = compact answer style; true = previous detailed style narrateToolCalls string // "off" | "brief" | "full" — see narrationDirective permissionMode string // "standard" | "unattended" — see Run() for behavior - ccSessionID string // CC's own session ID, for --resume - mcpConfigPath string // temp MCP config written once per qmax session - mcpConfigInfo os.FileInfo - sctx *api.SessionContext - lastToolName string // track last tool name for result display - fileSnaps map[string]fileSnapshot // CC tool_use id → pre-edit snapshot - lastTurnIn int // token usage of the most recent turn (from CC's result event) - lastTurnOut int - lastTurnOK bool // true once a result event carried usage this turn - mu sync.Mutex - runMu sync.Mutex - runCancel context.CancelFunc // non-nil while Run() is active + ccSessionID string // CC's own session ID, for --resume + mcpConfigPath string // temp MCP config written once per qmax session + mcpConfigInfo os.FileInfo + sctx *api.SessionContext + lastToolName string // track last tool name for result display + fileSnaps map[string]fileSnapshot // CC tool_use id → pre-edit snapshot + lastTurnIn int // token usage of the most recent turn (from CC's result event) + lastTurnOut int + lastTurnOK bool // true once a result event carried usage this turn + mu sync.Mutex + runMu sync.Mutex + runCancel context.CancelFunc // non-nil while Run() is active } // ccStandardAllowlist is the curated list passed to `claude --allowedTools` in diff --git a/internal/agent/cc_agent_session_test.go b/internal/agent/cc_agent_session_test.go index f5c2281..500e4d2 100644 --- a/internal/agent/cc_agent_session_test.go +++ b/internal/agent/cc_agent_session_test.go @@ -17,9 +17,9 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/tui" ) // mockCLIAgent is a CLIAgent stub that returns scripted responses without diff --git a/internal/agent/cerebras.go b/internal/agent/cerebras.go index f89ac65..d45f7fa 100644 --- a/internal/agent/cerebras.go +++ b/internal/agent/cerebras.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" ) // Cerebras integration — drive the full native agent loop through Cerebras's diff --git a/internal/agent/cerebras_agent.go b/internal/agent/cerebras_agent.go index 342b432..15577d3 100644 --- a/internal/agent/cerebras_agent.go +++ b/internal/agent/cerebras_agent.go @@ -5,9 +5,9 @@ import ( "encoding/json" "fmt" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/tui" ) // RunCerebrasAgent runs a complete conversation turn through Cerebras using @@ -67,10 +67,10 @@ func (a *Agent) RunCerebrasAgent(term *tui.Terminal) (string, bool) { } choice := resp.Choices[0] - a.Usage.InputTokens += resp.Usage.PromptTokens + a.Usage.RecordRequest(a.Cerebras.Model, api.APIUsage{ + InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, + }) a.LastContextTokens = resp.Usage.PromptTokens - a.Usage.OutputTokens += resp.Usage.CompletionTokens - a.Usage.Requests++ // Reconstruct the assistant turn as Anthropic-shaped blocks so the rest // of qmax (history compression, session persistence, cloud upload) sees diff --git a/internal/agent/cerebras_agent_test.go b/internal/agent/cerebras_agent_test.go index b6aa2af..76f4385 100644 --- a/internal/agent/cerebras_agent_test.go +++ b/internal/agent/cerebras_agent_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) // captureStdout redirects os.Stdout for the duration of fn and returns @@ -24,12 +24,18 @@ func captureStdout(t *testing.T, fn func()) string { } os.Stdout = w t.Cleanup(func() { os.Stdout = orig }) + // Drain concurrently so output larger than the pipe buffer cannot block fn. + done := make(chan []byte) + go func() { + out, _ := io.ReadAll(r) + done <- out + }() fn() w.Close() - out, _ := io.ReadAll(r) - return string(out) + os.Stdout = orig + return string(<-done) } // A real Cerebras request failure (bad model, auth error, malformed request, diff --git a/internal/agent/cerebras_loop_test.go b/internal/agent/cerebras_loop_test.go new file mode 100644 index 0000000..b61f258 --- /dev/null +++ b/internal/agent/cerebras_loop_test.go @@ -0,0 +1,173 @@ +package agent + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +func TestRunCerebrasAgentToolRoundAndSpeed(t *testing.T) { + withTempHome(t) + fc := newFakeCloud(t) + replies := []string{ + `{"choices":[{"message":{"content":"Planning first.","tool_calls":[{"id":"c1","type":"function","function":{"name":"update_plan","arguments":"{\"steps\":[{\"title\":\"look\",\"status\":\"in_progress\"}]}"}},{"id":"c2","type":"function","function":{"name":"update_plan","arguments":"not json"}}]}}],"usage":{"prompt_tokens":20,"completion_tokens":5},"time_info":{"tokens_per_second":1800,"time_to_first_token_sec":0.12}}`, + `{"choices":[{"message":{"content":"All done."}}],"usage":{"prompt_tokens":40,"completion_tokens":8},"time_info":{"tps":1500}}`, + } + var mu sync.Mutex + fc.handle("/chat/completions", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + defer mu.Unlock() + body := `{"choices":[]}` + if len(replies) > 0 { + body, replies = replies[0], replies[1:] + } + _, _ = io.WriteString(w, body) + }) + ag := NewAgent(AgentConfig{Context: &api.SessionContext{LocalOnly: true}}) + ag.Logger = sysutil.NewLogger("cerebras-test") + t.Cleanup(ag.Logger.Close) + ag.Cerebras = &CerebrasClient{BaseURL: fc.srv.URL, APIKey: "csk", Model: "", HTTP: ag.client} + ag.AppendHistory(api.Message{Role: "user", Content: "plan it"}) + + var out string + var ok bool + printed := captureStdout(t, func() { out, ok = ag.RunCerebrasAgent(&tui.Terminal{}) }) + if !ok || out != "All done." { + t.Fatalf("out=%q ok=%v", out, ok) + } + if ag.Usage.InputTokens != 60 || ag.Usage.Requests != 2 || ag.LastContextTokens != 40 { + t.Fatalf("usage = %+v last=%d", ag.Usage, ag.LastContextTokens) + } + if !strings.Contains(printed, "1800 tok/s · TTFT 0.12s") || !strings.Contains(printed, "1500 tok/s") { + t.Fatalf("speed lines missing:\n%s", printed) + } + + // No choices is a failure. + captureStdout(t, func() { out, ok = ag.RunCerebrasAgent(&tui.Terminal{}) }) + if ok { + t.Fatal("empty choices reported success") + } + + // A request error surfaces its reason. + fc.handle("/chat/completions", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad model", http.StatusBadRequest) + }) + printed = captureStdout(t, func() { _, ok = ag.RunCerebrasAgent(&tui.Terminal{}) }) + if ok || !strings.Contains(printed, "bad model") { + t.Fatalf("error path ok=%v printed=%s", ok, printed) + } + + fc.handle("/chat/completions", func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `not json`) + }) + if _, err := ag.Cerebras.Chat(context.Background(), nil, nil); err == nil || !strings.Contains(err.Error(), "parse response") { + t.Fatalf("bad json err = %v", err) + } + + // Cancelling mid-request reports an interrupted turn without an error line. + fc.handle("/chat/completions", func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + go func() { + time.Sleep(100 * time.Millisecond) + ag.CancelCurrent() + }() + printed = captureStdout(t, func() { _, ok = ag.RunCerebrasAgent(&tui.Terminal{}) }) + if ok || strings.Contains(printed, "Cerebras request failed") { + t.Fatalf("interrupt ok=%v printed=%s", ok, printed) + } + + var nilAgent Agent + if _, ok := nilAgent.RunCerebrasAgent(&tui.Terminal{}); ok { + t.Fatal("nil cerebras client ran") + } + printCerebrasSpeed(&tui.Terminal{}, nil, "") + captureStdout(t, func() { + printCerebrasSpeed(&tui.Terminal{}, &oaiChatResponse{TimeInfo: map[string]interface{}{"ttft_ms": 250}}, "") + }) +} + +func TestDoWithRetryHonoursRetryAfter(t *testing.T) { + fc := newFakeCloud(t) + var mu sync.Mutex + calls := 0 + fc.handle("/", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + calls++ + c := calls + mu.Unlock() + if c == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = io.WriteString(w, "ok") + }) + ag := NewAgent(AgentConfig{}) + newReq := func() (*http.Request, error) { + return http.NewRequestWithContext(context.Background(), "GET", fc.srv.URL, nil) + } + var resp *http.Response + var err error + captureStdout(t, func() { resp, err = doWithRetry(context.Background(), ag.client, newReq, nil) }) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("resp=%v err=%v", resp, err) + } + resp.Body.Close() + + // Cancellation during the backoff returns the context error. + calls = 0 + fc.handle("/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + captureStdout(t, func() { _, err = doWithRetry(ctx, ag.client, newReq, &tui.Terminal{}) }) + if err == nil { + t.Fatal("expected cancellation error") + } + + if _, err := doWithRetry(context.Background(), ag.client, func() (*http.Request, error) { return nil, io.EOF }, nil); err != io.EOF { + t.Fatalf("builder error = %v", err) + } +} + +func TestParseResetTimeHeaders(t *testing.T) { + now := time.Now() + if !parseResetTime(nil).IsZero() { + t.Fatal("nil headers") + } + if got := parseResetTime(map[string]string{"Retry-After": "60"}); got.Before(now.Add(59*time.Second)) { + t.Fatalf("retry-after seconds = %v", got) + } + date := now.Add(time.Hour).UTC().Truncate(time.Second) + if got := parseResetTime(map[string]string{"retry-after": date.Format(http.TimeFormat)}); !got.Equal(date) { + t.Fatalf("retry-after date = %v", got) + } + if got := parseResetTime(map[string]string{"x-ratelimit-reset": "1900000000"}); got.Unix() != 1900000000 { + t.Fatalf("epoch = %v", got) + } + if got := parseResetTime(map[string]string{"ratelimit-reset": "30"}); got.Before(now.Add(29 * time.Second)) { + t.Fatalf("delta = %v", got) + } + if got := parseResetTime(map[string]string{"anthropic-ratelimit-unified-reset": date.Format(time.RFC3339)}); !got.Equal(date) { + t.Fatalf("rfc3339 = %v", got) + } + if !parseResetTime(map[string]string{"x-ratelimit-reset": "soon"}).IsZero() { + t.Fatal("unparseable reset should be zero") + } +} diff --git a/internal/agent/cerebras_test.go b/internal/agent/cerebras_test.go index 46262aa..ddccf61 100644 --- a/internal/agent/cerebras_test.go +++ b/internal/agent/cerebras_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestValidateCerebrasKey(t *testing.T) { diff --git a/internal/agent/cli_streams_test.go b/internal/agent/cli_streams_test.go new file mode 100644 index 0000000..efe8293 --- /dev/null +++ b/internal/agent/cli_streams_test.go @@ -0,0 +1,187 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +const agyConversation = "055a398f-db14-4c5f-abbb-1bf03f8120a7" + +func agyStream() string { + return strings.Join([]string{ + `{"event":"init","conversation_id":"` + agyConversation + `"}`, + `not json`, + `{"event":"step_update"}`, + `{"event":"step_update","step_update":{"conversation_id":"` + agyConversation + `","step_index":1,"state":"ACTIVE","step_type":"agent_response","text_delta":"Checking "}}`, + `{"event":"step_update","step_update":{"step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"mcp__qmax__run_test","tool_info":{"name":"mcp__qmax__run_test","parameters":{"script_id":3}}}}`, + `{"event":"step_update","step_update":{"step_index":2,"state":"DONE","step_type":"tool","tool_info":{"name":"run_test","output":"passed","error":{"message":"flaky"}},"usage":{"input_tokens":40,"output_tokens":9}}}`, + `{"event":"result"}`, + `{"event":"result","result":{"conversation_id":"` + agyConversation + `","status":"ERROR","response":"Tests passed.","error":"partial","usage":{"input_tokens":80,"output_tokens":20}}}`, + }, "\n") + "\n" +} + +func TestAgyRunStreamsAndRecordsUsage(t *testing.T) { + requirePOSIX(t) + home := withTempHome(t) + fixture := filepath.Join(home, "agy.ndjson") + if err := os.WriteFile(fixture, []byte(agyStream()), 0o600); err != nil { + t.Fatal(err) + } + bin := writeStubIn(t, t.TempDir(), "agy", `cat "`+fixture+`"`) + sctx := &api.SessionContext{ProjectID: 4, LiveFeed: true, LocalOnly: true} + + for _, verbose := range []bool{true, false} { + a := NewAgyAgent(bin, "gemini-3", "", "unattended", verbose, "on", sctx) + var out string + var err error + captureStdout(t, func() { out, err = a.Run("run the smoke test", &tui.Terminal{}) }) + if err != nil || out != "Tests passed." { + t.Fatalf("verbose=%v: out=%q err=%v", verbose, out, err) + } + if in, o, ok := a.LastTurnStats(); !ok || in != 80 || o != 20 { + t.Fatalf("stats = %d/%d/%v", in, o, ok) + } + if a.conversationID != agyConversation { + t.Fatalf("conversation = %q", a.conversationID) + } + // The second turn resumes the stored conversation. + captureStdout(t, func() { _, err = a.Run("again", nil) }) + if err != nil { + t.Fatal(err) + } + a.SetOutputVerbose(!verbose) + a.ResetConversation() + a.Cancel() + a.Cleanup() + } + if _, err := os.Stat(agyMCPConfigPath(home)); err != nil { + t.Fatalf("MCP config not written: %v", err) + } + if !AgyFileTokenPresent() { + t.Log("no file token (expected in tests)") + } +} + +func TestAgyRunExitErrors(t *testing.T) { + requirePOSIX(t) + withTempHome(t) + dir := t.TempDir() + cases := []struct { + name, body, want string + limit bool + }{ + {"auth", `echo "Error: authentication required" >&2; exit 1`, "not signed in with Google", false}, + {"limit", `echo "usage limit reached" >&2; exit 1`, "agy exited with error", true}, + {"long stderr", `printf '%0500d' 0 >&2; exit 2`, "agy exited with error", false}, + {"silent", `exit 3`, "agy exited with error: exit status 3", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bin := writeStubIn(t, dir, "agy-"+strings.ReplaceAll(tc.name, " ", "-"), tc.body) + a := NewAgyAgent(bin, "", "", "", false, "", nil) + _, err := a.Run("hi", nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + if _, hit := a.LastPlanLimit(); hit != tc.limit { + t.Fatalf("limit hit = %v, want %v", hit, tc.limit) + } + }) + } + + a := NewAgyAgent("/nonexistent/agy", "", "", "", false, "", nil) + if _, err := a.Run("hi", nil); err == nil || !strings.Contains(err.Error(), "start agy") { + t.Fatalf("missing binary err = %v", err) + } + if _, err := a.buildAgyArgs("x", "", "not-a-uuid"); err == nil { + t.Fatal("invalid conversation ID accepted") + } + if agyToolName(nil) != "" { + t.Fatal("nil step tool name") + } +} + +func TestCCParseStream(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + file := filepath.Join(dir, "app.go") + if err := os.WriteFile(file, []byte("package app\n"), 0o644); err != nil { + t.Fatal(err) + } + stream := strings.Join([]string{ + `{"type":"system","subtype":"init","session_id":"0f8fad5b-d9cb-469f-a165-70867728950e"}`, + `garbage`, + `{"type":"assistant"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Editing."},{"type":"tool_use","id":"tu1","name":"Edit","input":{"file_path":"` + file + `","old_string":"package app","new_string":"package app2"}}]}}`, + `{"type":"user"}`, + `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu1","content":[{"type":"text","text":"ok"}]}]}}`, + `{"type":"user","message":{"role":"user","content":"plain echo"}}`, + `{"type":"result","result":"Done.","is_error":true,"usage":{"input_tokens":11,"output_tokens":5}}`, + }, "\n") + + for _, verbose := range []bool{true, false} { + a := NewCCAgent("claude", "", "", "", verbose, "", &api.SessionContext{}) + var got string + captureStdout(t, func() { + _ = os.WriteFile(file, []byte("package app\n"), 0o644) + got = a.parseStream(strings.NewReader(stream), &tui.Terminal{}) + }) + if got != "Done." { + t.Fatalf("result = %q", got) + } + if in, out, ok := a.LastTurnStats(); !ok || in != 11 || out != 5 { + t.Fatalf("stats = %d/%d/%v", in, out, ok) + } + if a.ccSessionID == "" { + t.Fatal("session id not captured") + } + } + if got := extractToolResultText([]byte(`"plain"`)); got != "plain" { + t.Fatalf("string tool result = %q", got) + } + if got := extractToolResultText([]byte(`{"x":1}`)); got == "" { + t.Fatal("object tool result dropped") + } +} + +func TestOpenCodeParseStream(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + file := filepath.Join(dir, "main.go") + if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + stream := strings.Join([]string{ + `{"type":"step_start","sessionID":"ses_0123456789abcdefABCDEF"}`, + `{"type":"text","part":{"id":"p1","type":"text","text":"Hello"}}`, + `{"type":"text","part":{"id":"p1","type":"text","text":"Hello world"}}`, + `{"type":"text","part":{"id":"p1","type":"text","text":"Rewritten"}}`, + `{"type":"text","part":{"id":"p2","type":"text","text":""}}`, + `{"type":"text","part":{"id":"p3","type":"text","text":"Second part"}}`, + `{"type":"tool","part":{"id":"t0","type":"tool"}}`, + `{"type":"tool","part":{"id":"t1","type":"tool","tool":"edit","state":"running","input":{"filePath":"` + file + `"}}}`, + `{"type":"tool","part":{"id":"t1","type":"tool","tool":"edit","state":"completed","output":"ok"}}`, + `{"type":"tool","part":{"id":"t2","type":"tool","tool":"qmax_run_test","state":"error","error":"boom"}}`, + `{"type":"step_finish","part":{"id":"s1","tokens":{"input":30,"output":7}}}`, + `{"type":"step_finish","part":{"id":"s1","tokens":{"input":30,"output":7}}}`, + `{"type":"step_finish","timestamp":5,"usage":{"prompt":3,"completion":2}}`, + `{"type":"error","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429}}}`, + `{"type":"error","error":{"name":"Quiet"}}`, + }, "\n") + for _, verbose := range []bool{true, false} { + a := NewOpenCodeAgent("opencode", "groq/llama", "", "", verbose, api.DefaultConfig(), &api.SessionContext{}) + var got string + captureStdout(t, func() { got = a.parseStream(strings.NewReader(stream), &tui.Terminal{}) }) + if got != "Rewritten\n\nSecond part" { + t.Fatalf("result = %q", got) + } + if in, out, ok := a.LastTurnStats(); !ok || in != 33 || out != 9 { + t.Fatalf("stats = %d/%d/%v", in, out, ok) + } + } +} diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index 3dcf15c..8ce9bdc 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -10,10 +10,10 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/codexrunner" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/codexrunner" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // CodexAgent orchestrates an OpenAI Codex CLI subprocess for LLM inference. @@ -36,15 +36,15 @@ type CodexAgent struct { narrateToolCalls string // "off" | "brief" | "full" — see narrationDirective sctx *api.SessionContext continuity *codexrunner.Continuity - lastTurnIn int // token usage of the most recent turn, when codex reports it - lastTurnOut int - lastTurnOK bool - lastLimitHit bool // true if the plan limit was hit this turn - lastLimitReset time.Time // provider-reported reset (usually zero for codex) - mu sync.Mutex - turnMu sync.Mutex // keeps checkpoint selection and execution atomic - runMu sync.Mutex - runCancel context.CancelFunc // non-nil while Run() is active + lastTurnIn int // token usage of the most recent turn, when codex reports it + lastTurnOut int + lastTurnOK bool + lastLimitHit bool // true if the plan limit was hit this turn + lastLimitReset time.Time // provider-reported reset (usually zero for codex) + mu sync.Mutex + turnMu sync.Mutex // keeps checkpoint selection and execution atomic + runMu sync.Mutex + runCancel context.CancelFunc // non-nil while Run() is active } // FindCodex returns the path to the codex CLI binary, or "" if not installed. diff --git a/internal/agent/codex_agent_continuity_test.go b/internal/agent/codex_agent_continuity_test.go index c638cc3..3fc834e 100644 --- a/internal/agent/codex_agent_continuity_test.go +++ b/internal/agent/codex_agent_continuity_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/codexrunner" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/codexrunner" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestCodexAgentUsesNativeContinuityAndClearStartsFresh(t *testing.T) { diff --git a/internal/agent/conversation.go b/internal/agent/conversation.go index 19fde35..9b33c77 100644 --- a/internal/agent/conversation.go +++ b/internal/agent/conversation.go @@ -7,10 +7,10 @@ import ( "strings" "sync" - "github.com/qualitymax/qmax-code/codexrunner" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/codexrunner" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/tui" ) // AppendHistory adds messages to both the working context and lossless archive. diff --git a/internal/agent/conversation_test.go b/internal/agent/conversation_test.go index 084ff88..9c365da 100644 --- a/internal/agent/conversation_test.go +++ b/internal/agent/conversation_test.go @@ -9,9 +9,9 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/tui" ) type conversationSpy struct { diff --git a/internal/agent/fable_test.go b/internal/agent/fable_test.go index 4b2c9fc..89739a7 100644 --- a/internal/agent/fable_test.go +++ b/internal/agent/fable_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) type fableTestTransport func(*http.Request) (*http.Response, error) diff --git a/internal/agent/filediff.go b/internal/agent/filediff.go index cd8462e..ac3b799 100644 --- a/internal/agent/filediff.go +++ b/internal/agent/filediff.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) // fileSnapshot captures a file's pre-edit content so the TUI can render a live diff --git a/internal/agent/filediff_test.go b/internal/agent/filediff_test.go index e53bdba..3c3c1fb 100644 --- a/internal/agent/filediff_test.go +++ b/internal/agent/filediff_test.go @@ -5,23 +5,23 @@ import ( "path/filepath" "testing" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) func TestIsFileEditTool(t *testing.T) { cases := map[string]bool{ - "edit_file": true, - "write_file": true, - "Edit": true, // Claude Code - "Write": true, - "NotebookEdit": true, - "qmax__edit_file": true, // mcp-prefixed - "opencode.edit": true, - "applypatch": true, - "read_file": false, - "bash": false, - "update_plan": false, - "webfetch": false, + "edit_file": true, + "write_file": true, + "Edit": true, // Claude Code + "Write": true, + "NotebookEdit": true, + "qmax__edit_file": true, // mcp-prefixed + "opencode.edit": true, + "applypatch": true, + "read_file": false, + "bash": false, + "update_plan": false, + "webfetch": false, } for name, want := range cases { if got := isFileEditTool(name); got != want { @@ -36,8 +36,8 @@ func TestToolPathFieldVariants(t *testing.T) { want string }{ {map[string]interface{}{"path": "a.go"}, "a.go"}, - {map[string]interface{}{"file_path": "b.go"}, "b.go"}, // CC - {map[string]interface{}{"filePath": "c.go"}, "c.go"}, // OpenCode + {map[string]interface{}{"file_path": "b.go"}, "b.go"}, // CC + {map[string]interface{}{"filePath": "c.go"}, "c.go"}, // OpenCode {map[string]interface{}{"notebook_path": "n.ipynb"}, "n.ipynb"}, {map[string]interface{}{"files": map[string]interface{}{"path": "d.go"}}, "d.go"}, {map[string]interface{}{"command": "ls"}, ""}, diff --git a/internal/agent/haiku55_test.go b/internal/agent/haiku55_test.go new file mode 100644 index 0000000..5c3642b --- /dev/null +++ b/internal/agent/haiku55_test.go @@ -0,0 +1,95 @@ +package agent + +import ( + "encoding/json" + "math" + "net/http" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +func assertHaiku55Request(t *testing.T, req map[string]interface{}) { + t.Helper() + thinking, _ := req["thinking"].(map[string]interface{}) + if req["model"] != api.ModelHaiku55 || thinking["type"] != "disabled" { + t.Fatalf("model=%v thinking=%v", req["model"], req["thinking"]) + } + for _, key := range []string{"temperature", "top_p", "top_k"} { + if _, ok := req[key]; ok { + t.Fatalf("unsupported sampling parameter %s", key) + } + } +} + +func TestHaiku55StreamingUsage(t *testing.T) { + body := sseEvent("message_start", map[string]interface{}{"message": map[string]interface{}{"usage": api.APIUsage{ + InputTokens: 1, CacheReadInputTokens: 100_000, OutputTokens: 1, + }}}) + + sseEvent("content_block_start", map[string]interface{}{"index": 0, "content_block": map[string]string{"type": "thinking"}}) + + sseEvent("content_block_delta", map[string]interface{}{"index": 0, "delta": map[string]string{"type": "thinking_delta", "thinking": "internal"}}) + + sseEvent("content_block_stop", map[string]int{"index": 0}) + + sseEvent("content_block_start", map[string]interface{}{"index": 1, "content_block": map[string]string{"type": "text"}}) + + sseEvent("content_block_delta", map[string]interface{}{"index": 1, "delta": map[string]string{"type": "text_delta", "text": "visible"}}) + + sseEvent("content_block_stop", map[string]int{"index": 1}) + + sseEvent("message_delta", map[string]interface{}{"usage": api.APIUsage{OutputTokens: 3}}) + + sseEvent("message_delta", map[string]interface{}{"delta": map[string]string{"stop_reason": "end_turn"}, "usage": api.APIUsage{OutputTokens: 10}}) + ag, sa, _ := newStreamingAgent(t, &api.SessionContext{LocalOnly: true}, body) + ag.Cfg.Model = api.ModelHaiku55 + var out string + var err error + captureStdout(t, func() { out, err = ag.RunStreaming("hello", &tui.Terminal{}) }) + if err != nil || out != "visible" { + t.Fatalf("response=%q err=%v", out, err) + } + assertHaiku55Request(t, sa.requests[0]) + if ag.Usage.InputTokens != 1 || ag.Usage.CacheReadInputTokens != 100_000 || ag.Usage.OutputTokens != 10 || ag.Usage.Requests != 1 || ag.LastContextTokens != 100_001 { + t.Fatalf("usage=%+v context=%d", ag.Usage, ag.LastContextTokens) + } + if got := ag.Usage.EstimatedCost(api.ModelHaiku55); math.Abs(got-0.0050255) > 1e-12 { + t.Fatalf("cost=%v", got) + } +} + +func TestHaiku55NonStreamingUsage(t *testing.T) { + ag, _, fc := newStreamingAgent(t, &api.SessionContext{LocalOnly: true}) + ag.Cfg.Model = api.ModelHaiku55 + var sent map[string]interface{} + fc.handle("/v1/messages", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&sent); err != nil { + t.Error(err) + } + _, _ = w.Write([]byte(`{"content":[{"type":"thinking","thinking":"internal"},{"type":"text","text":"visible"}],"usage":{"input_tokens":1,"cache_creation_input_tokens":100000,"output_tokens":10}}`)) + }) + resp, err := ag.callAPI() + if err != nil { + t.Fatal(err) + } + assertHaiku55Request(t, sent) + if len(resp.Content) != 1 || resp.Content[0].Type != "text" || resp.Content[0].Text != "visible" { + t.Fatalf("content=%+v", resp.Content) + } + if ag.Usage.CacheCreationInputTokens != 100_000 || ag.Usage.Requests != 1 || ag.LastContextTokens != 100_001 { + t.Fatalf("usage=%+v context=%d", ag.Usage, ag.LastContextTokens) + } + if got := ag.Usage.EstimatedCost(api.ModelHaiku55); math.Abs(got-0.0625255) > 1e-12 { + t.Fatalf("cost=%v", got) + } +} + +func TestStreamingRecordsPartialUsage(t *testing.T) { + // A stream can end after message_start, before final output usage arrives. + // Keep the prompt charge and any initial output reported by the provider. + body := sseEvent("message_start", map[string]interface{}{"message": map[string]interface{}{"usage": api.APIUsage{ + InputTokens: 60_000, OutputTokens: 2, + }}}) + ag, _, _ := newStreamingAgent(t, &api.SessionContext{LocalOnly: true}, body) + _, _, err := ag.callStreamingAPI(&tui.Terminal{}, api.ModelHaiku55) + if err != nil { + t.Fatal(err) + } + if ag.Usage.Requests != 1 || ag.Usage.OutputTokens != 2 || math.Abs(ag.Usage.EstimatedCost(api.ModelHaiku55)-0.006001) > 1e-12 { + t.Fatalf("partial usage=%+v", ag.Usage) + } +} diff --git a/internal/agent/live_feed_extra_test.go b/internal/agent/live_feed_extra_test.go index fa4fb0f..e43a910 100644 --- a/internal/agent/live_feed_extra_test.go +++ b/internal/agent/live_feed_extra_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" ) // ─── annotateWithClientNote ─────────────────────────────────────────────────── diff --git a/internal/agent/live_feed_test.go b/internal/agent/live_feed_test.go index 6851bdb..3bbc698 100644 --- a/internal/agent/live_feed_test.go +++ b/internal/agent/live_feed_test.go @@ -3,7 +3,7 @@ package agent import ( "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // captureLiveURL pulls live_browser_url out of a status JSON payload diff --git a/internal/agent/local_only_test.go b/internal/agent/local_only_test.go index f9d92b3..c2a55f6 100644 --- a/internal/agent/local_only_test.go +++ b/internal/agent/local_only_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestStandaloneToolCatalogsContainOnlyLocalTools(t *testing.T) { diff --git a/internal/agent/mcp_reconnect_test.go b/internal/agent/mcp_reconnect_test.go index 819cc15..0a0e03b 100644 --- a/internal/agent/mcp_reconnect_test.go +++ b/internal/agent/mcp_reconnect_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // withTempHome points $HOME at a fresh temp dir for the test and restores it diff --git a/internal/agent/ollama.go b/internal/agent/ollama.go index 5bf3554..d38f084 100644 --- a/internal/agent/ollama.go +++ b/internal/agent/ollama.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/tui" ) // Ollama integration — use a self-hosted local model for chat and, diff --git a/internal/agent/ollama_agent.go b/internal/agent/ollama_agent.go index 8c48b20..c8c8d93 100644 --- a/internal/agent/ollama_agent.go +++ b/internal/agent/ollama_agent.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) // OllamaAgentMode provides a full Ollama-powered agent that handles both diff --git a/internal/agent/ollama_test.go b/internal/agent/ollama_test.go index 2cb179c..bb3d373 100644 --- a/internal/agent/ollama_test.go +++ b/internal/agent/ollama_test.go @@ -3,10 +3,9 @@ package agent import ( "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) - func TestValidateOllamaURL(t *testing.T) { tests := []struct { name string diff --git a/internal/agent/opencode_agent.go b/internal/agent/opencode_agent.go index 54e2fe6..adae125 100644 --- a/internal/agent/opencode_agent.go +++ b/internal/agent/opencode_agent.go @@ -16,8 +16,8 @@ import ( "sync" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) // OpenCodeAgent orchestrates an opencode CLI subprocess for LLM inference. diff --git a/internal/agent/opencode_config.go b/internal/agent/opencode_config.go index 764690b..330899c 100644 --- a/internal/agent/opencode_config.go +++ b/internal/agent/opencode_config.go @@ -13,8 +13,8 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" ) // userOpenCodeConfigDir returns the directory opencode reads its own global diff --git a/internal/agent/opencode_config_test.go b/internal/agent/opencode_config_test.go index fe7efe6..c068597 100644 --- a/internal/agent/opencode_config_test.go +++ b/internal/agent/opencode_config_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestWriteOpenCodeConfigProducesProviderBlockAndMCP(t *testing.T) { diff --git a/internal/agent/opencode_run_test.go b/internal/agent/opencode_run_test.go index bbea7ed..e23f559 100644 --- a/internal/agent/opencode_run_test.go +++ b/internal/agent/opencode_run_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) // TestRedactStderrTail pins the defensive redaction applied to the stderr tail diff --git a/internal/agent/opencode_stream_test.go b/internal/agent/opencode_stream_test.go index 39464c0..c45028d 100644 --- a/internal/agent/opencode_stream_test.go +++ b/internal/agent/opencode_stream_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) // ocSuccessStream is a real stream captured from diff --git a/internal/agent/orphaned_tool_use_test.go b/internal/agent/orphaned_tool_use_test.go index a4c4b5b..8895f63 100644 --- a/internal/agent/orphaned_tool_use_test.go +++ b/internal/agent/orphaned_tool_use_test.go @@ -3,7 +3,7 @@ package agent import ( "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // Regression for the "messages.13.content: Input should be a valid list" diff --git a/internal/agent/plan.go b/internal/agent/plan.go index 547d889..d97dec0 100644 --- a/internal/agent/plan.go +++ b/internal/agent/plan.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) // PlanStep is a single entry in the agent's task plan, surfaced via update_plan. diff --git a/internal/agent/plan_test.go b/internal/agent/plan_test.go index adff775..9744b2c 100644 --- a/internal/agent/plan_test.go +++ b/internal/agent/plan_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestParsePlanSteps_Valid(t *testing.T) { diff --git a/internal/agent/retry.go b/internal/agent/retry.go index 85787a1..a6a0b7a 100644 --- a/internal/agent/retry.go +++ b/internal/agent/retry.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) const maxAPIRetries = 3 diff --git a/internal/agent/streaming_test.go b/internal/agent/streaming_test.go new file mode 100644 index 0000000..b188295 --- /dev/null +++ b/internal/agent/streaming_test.go @@ -0,0 +1,335 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +// sseEvent renders one Anthropic server-sent event. +func sseEvent(event string, data interface{}) string { + b, _ := json.Marshal(data) + return fmt.Sprintf("event: %s\ndata: %s\n\n", event, b) +} + +func sseTextTurn(text string) string { + return sseEvent("message_start", map[string]interface{}{"type": "message_start", "message": map[string]interface{}{"usage": map[string]int{"input_tokens": 50}}}) + + sseEvent("content_block_start", map[string]interface{}{"index": 0, "content_block": map[string]string{"type": "text"}}) + + sseEvent("content_block_delta", map[string]interface{}{"index": 0, "delta": map[string]string{"type": "text_delta", "text": text}}) + + sseEvent("content_block_stop", map[string]interface{}{"index": 0}) + + sseEvent("message_delta", map[string]interface{}{"delta": map[string]string{"stop_reason": "end_turn"}, "usage": map[string]int{"output_tokens": 7}}) + + sseEvent("message_stop", map[string]interface{}{}) + + "data: not-json-ignored\n\n" + ": comment line\n\n" +} + +func sseToolTurn(name, inputJSON string) string { + return sseEvent("message_start", map[string]interface{}{"message": map[string]interface{}{"usage": map[string]int{"input_tokens": 30}}}) + + sseEvent("content_block_start", map[string]interface{}{"index": 0, "content_block": map[string]string{"type": "text"}}) + + sseEvent("content_block_delta", map[string]interface{}{"index": 0, "delta": map[string]string{"type": "text_delta", "text": "Let me check."}}) + + sseEvent("content_block_start", map[string]interface{}{"index": 1, "content_block": map[string]string{"type": "tool_use", "id": "tu_1", "name": name}}) + + sseEvent("content_block_delta", map[string]interface{}{"index": 1, "delta": map[string]string{"type": "input_json_delta", "partial_json": inputJSON}}) + + sseEvent("content_block_stop", map[string]interface{}{"index": 1}) + + sseEvent("content_block_start", map[string]interface{}{"index": 2, "content_block": map[string]string{"type": "tool_use", "id": "tu_2", "name": "update_plan"}}) + + sseEvent("content_block_stop", map[string]interface{}{"index": 2}) + + sseEvent("message_delta", map[string]interface{}{"delta": map[string]string{"stop_reason": "tool_use"}, "usage": map[string]int{"output_tokens": 12}}) +} + +// scriptedAnthropic serves the given SSE bodies in order and records requests. +type scriptedAnthropic struct { + mu sync.Mutex + bodies []string + requests []map[string]interface{} +} + +func (s *scriptedAnthropic) handler(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var req map[string]interface{} + _ = json.Unmarshal(raw, &req) + s.mu.Lock() + s.requests = append(s.requests, req) + var body string + if len(s.bodies) > 0 { + body, s.bodies = s.bodies[0], s.bodies[1:] + } else { + body = sseTextTurn("fallback") + } + s.mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, body) +} + +func newStreamingAgent(t *testing.T, sctx *api.SessionContext, bodies ...string) (*Agent, *scriptedAnthropic, *fakeCloud) { + t.Helper() + withTempHome(t) + fc := newFakeCloud(t) + sa := &scriptedAnthropic{bodies: bodies} + fc.handle("/v1/messages", sa.handler) + routeAllRequestsTo(t, fc.srv) + ag := NewAgent(AgentConfig{AnthropicKey: "sk-ant-test", Model: api.ModelSonnet, ChatModel: api.ModelHaiku, Context: sctx}) + ag.Logger = sysutil.NewLogger("test") + t.Cleanup(ag.Logger.Close) + return ag, sa, fc +} + +func TestRunStreamingToolLoop(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello from the repo"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + sctx := &api.SessionContext{LocalOnly: true} + ag, sa, _ := newStreamingAgent(t, sctx, + sseToolTurn("read_file", `{"path":"README.md"}`), + sseTextTurn("The README says hello."), + ) + ag.Cfg.Verbose = true + ag.Cfg.AutoRoute = true + + var out string + var err error + captureStdout(t, func() { out, err = ag.RunStreaming("what does the readme say?", &tui.Terminal{}) }) + if err != nil { + t.Fatalf("RunStreaming: %v", err) + } + if out != "The README says hello." { + t.Fatalf("final text = %q", out) + } + if len(sa.requests) != 2 { + t.Fatalf("requests = %d, want 2", len(sa.requests)) + } + if sa.requests[0]["model"] != api.ModelHaiku || sa.requests[1]["model"] != api.ModelSonnet { + t.Fatalf("auto-route models = %v, %v", sa.requests[0]["model"], sa.requests[1]["model"]) + } + toolResults, _ := json.Marshal(sa.requests[1]["messages"]) + if !strings.Contains(string(toolResults), "hello from the repo") { + t.Fatalf("tool result not sent back: %s", toolResults) + } + if ag.Usage.InputTokens != 80 || ag.Usage.OutputTokens != 19 || ag.LastContextTokens != 50 { + t.Fatalf("usage = %+v last=%d", ag.Usage, ag.LastContextTokens) + } +} + +func TestRunStreamingAPIErrors(t *testing.T) { + ag, _, fc := newStreamingAgent(t, &api.SessionContext{}) + fc.handle("/v1/messages", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":{"type":"invalid_request_error"}}`, http.StatusBadRequest) + }) + var err error + captureStdout(t, func() { _, err = ag.RunStreaming("hi", &tui.Terminal{}) }) + if err == nil || !strings.Contains(err.Error(), "API error 400") { + t.Fatalf("err = %v", err) + } + + // Malformed history entries are sanitized before the request. + ag.History = append(ag.History, + api.Message{Role: "user", Content: "tool_result leftovers"}, + api.Message{Role: "assistant", Content: nil}, + api.Message{Role: "user", Content: 42}, + api.Message{Role: "assistant", Content: []interface{}{map[string]interface{}{"type": "text", "text": "x"}}}, + ) + var sent map[string]interface{} + fc.handle("/v1/messages", func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &sent) + _, _ = io.WriteString(w, sseTextTurn("ok")) + }) + captureStdout(t, func() { _, err = ag.RunStreaming("again", &tui.Terminal{}) }) + if err != nil { + t.Fatalf("sanitized request failed: %v", err) + } + msgs, _ := json.Marshal(sent["messages"]) + if strings.Contains(string(msgs), "null") { + t.Fatalf("nil content leaked into the request: %s", msgs) + } +} + +func TestRunStreamingCerebrasFailure(t *testing.T) { + ag, _, fc := newStreamingAgent(t, &api.SessionContext{}) + fc.handle("/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + }) + ag.Cerebras = &CerebrasClient{BaseURL: fc.srv.URL, APIKey: "csk", Model: api.CerebrasDefaultModel, HTTP: ag.client} + var err error + captureStdout(t, func() { _, err = ag.RunStreaming("hi", &tui.Terminal{}) }) + if err == nil { + t.Fatal("expected cerebras failure") + } +} + +// ollamaStream renders an OpenAI-compatible streaming completion. +func ollamaStream(chunks ...string) string { + var b strings.Builder + for _, c := range chunks { + data, _ := json.Marshal(map[string]interface{}{"choices": []map[string]interface{}{{"delta": map[string]string{"content": c}}}}) + fmt.Fprintf(&b, "data: %s\n\n", data) + } + b.WriteString("data: {bad json}\n\ndata: [DONE]\n\n") + return b.String() +} + +func newOllamaAgent(t *testing.T, sctx *api.SessionContext, mode OllamaMode, responses ...string) (*Agent, *fakeCloud) { + t.Helper() + ag, _, fc := newStreamingAgent(t, sctx, sseTextTurn("from claude")) + var mu sync.Mutex + fc.handle("/v1/chat/completions", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + defer mu.Unlock() + if len(responses) == 0 { + http.Error(w, "exhausted", http.StatusInternalServerError) + return + } + body := responses[0] + responses = responses[1:] + _, _ = io.WriteString(w, body) + }) + cfg := api.DefaultConfig() + cfg.OllamaURL = strings.Replace(fc.srv.URL, "http://", "http://user:pass@", 1) + cfg.OllamaModel = "llama3.2:3b" + ag.Ollama = NewOllamaClient(cfg) + ag.Mode = mode + return ag, fc +} + +func TestOllamaChatMode(t *testing.T) { + ag, _ := newOllamaAgent(t, &api.SessionContext{}, OllamaModeChat, ollamaStream("Hi ", "there!")) + ag.Cfg.Verbose = true + var out string + captureStdout(t, func() { out, _ = ag.RunStreaming("hello", &tui.Terminal{}) }) + if out != "Hi there!" { + t.Fatalf("ollama chat = %q", out) + } + + // A failed local response falls back to Claude. + ag, _ = newOllamaAgent(t, &api.SessionContext{}, OllamaModeChat) + ag.Cfg.Verbose = true + captureStdout(t, func() { out, _ = ag.RunStreaming("hello", &tui.Terminal{}) }) + if out != "from claude" { + t.Fatalf("fallback = %q", out) + } +} + +func TestOllamaFullModeActions(t *testing.T) { + sctx := &api.SessionContext{ProjectID: 4} + ag, fc := newOllamaAgent(t, sctx, OllamaModeFull, + ollamaStream(`Sure. {"name": "list_projects", "params": {}}`), + ollamaStream("You have 2 projects."), + ) + sctx.API = fc.client() + fc.json("/api/projects", `[{"id":1},{"id":2}]`) + ag.Cfg.Verbose = true + var out string + captureStdout(t, func() { out, _ = ag.RunStreaming("list my projects", &tui.Terminal{}) }) + if out != "You have 2 projects." { + t.Fatalf("ollama full = %q", out) + } + + // Pure chat in full mode. + ag, _ = newOllamaAgent(t, &api.SessionContext{}, OllamaModeFull, ollamaStream("Just chatting.")) + captureStdout(t, func() { out, _ = ag.RunStreaming("hi", &tui.Terminal{}) }) + if out != "Just chatting." { + t.Fatalf("full chat = %q", out) + } + + // Summary failure falls back to the raw tool result. + sctx = &api.SessionContext{} + ag, fc = newOllamaAgent(t, sctx, OllamaModeFull, ollamaStream(`{"name": "get_project_summary", "params": {"project_id": 3}}`)) + sctx.API = fc.client() + fc.json("/api/", `{"summary":"ok"}`) + captureStdout(t, func() { out, _ = ag.RunStreaming("summarize project 3", &tui.Terminal{}) }) + if !strings.Contains(out, "summary") { + t.Fatalf("raw fallback = %q", out) + } + + // Standalone with no Anthropic fallback reports the failure. + ag, _ = newOllamaAgent(t, &api.SessionContext{LocalOnly: true}, OllamaModeFull) + ag.Cfg.AnthropicKey = "" + var err error + captureStdout(t, func() { _, err = ag.RunStreaming("hi", &tui.Terminal{}) }) + if err == nil || !strings.Contains(err.Error(), "standalone") { + t.Fatalf("standalone failure = %v", err) + } +} + +func TestExecuteOllamaActionTable(t *testing.T) { + withTempHome(t) + fc := newFakeCloud(t) + sctx := &api.SessionContext{API: fc.client(), ProjectID: 2} + ag := NewAgent(AgentConfig{Context: sctx}) + ctx := t.Context() + + cases := []struct { + action string + params map[string]interface{} + want string + }{ + {"list_projects", nil, "ok"}, + {"list_test_cases", map[string]interface{}{"search": "login"}, "ok"}, + {"list_scripts", nil, "ok"}, + {"run_test", map[string]interface{}{}, "script_id is required"}, + {"run_test", map[string]interface{}{"script_id": 3}, "ok"}, + {"start_crawl", map[string]interface{}{}, "url is required"}, + {"start_crawl", map[string]interface{}{"url": "https://x"}, "ok"}, + {"review_repo", map[string]interface{}{}, "repo_id is required"}, + {"review_repo", map[string]interface{}{"repo_id": 1}, "ok"}, + {"get_script", map[string]interface{}{"script_id": 1}, "ok"}, + {"get_project_summary", nil, "ok"}, + {"check_test_status", map[string]interface{}{"execution_id": "e"}, "ok"}, + {"create_pr", map[string]interface{}{"repo_id": 1}, "ok"}, + {"teleport", nil, "Unknown action"}, + } + for _, tc := range cases { + if got := ag.executeOllamaAction(tc.action, tc.params, ctx); !strings.Contains(got, tc.want) { + t.Errorf("%s(%v) = %s, want %q", tc.action, tc.params, got, tc.want) + } + } + + ag.Cfg.Context = &api.SessionContext{} + if got := ag.executeOllamaAction("list_projects", nil, ctx); !strings.Contains(got, "Not connected") { + t.Errorf("disconnected = %s", got) + } + ag.Cfg.Context = &api.SessionContext{LocalOnly: true} + if got := ag.executeOllamaAction("list_projects", nil, ctx); !strings.Contains(got, "standalone") { + t.Errorf("standalone cloud action = %s", got) + } +} + +func TestOllamaClientEdges(t *testing.T) { + var nilClient *OllamaClient + if nilClient.Available() { + t.Fatal("nil client reported available") + } + c := &OllamaClient{cooldownSeconds: 0} + for i := 0; i < ollamaMaxFailures; i++ { + c.recordFailure() + } + if !c.Available() { + t.Fatal("zero cooldown should reopen the breaker") + } + c.cooldownSeconds = 3600 + for i := 0; i < ollamaMaxFailures; i++ { + c.recordFailure() + } + if c.Available() { + t.Fatal("tripped breaker reported available") + } + + if got := extractPlainText([]interface{}{map[string]interface{}{"type": "text", "text": "a"}, map[string]interface{}{"type": "image"}, "x"}); got != "a" { + t.Fatalf("extractPlainText(map blocks) = %q", got) + } + if got := extractPlainText([]api.ContentBlock{{Type: "text", Text: "a"}, {Type: "text", Text: "b"}}); got != "a\nb" { + t.Fatalf("extractPlainText(blocks) = %q", got) + } + if got := extractPlainText(42); got != "" { + t.Fatalf("extractPlainText(int) = %q", got) + } +} diff --git a/internal/agent/summarize_test.go b/internal/agent/summarize_test.go new file mode 100644 index 0000000..5df78c0 --- /dev/null +++ b/internal/agent/summarize_test.go @@ -0,0 +1,115 @@ +package agent + +import ( + "fmt" + "strings" + "testing" +) + +func TestSummarizeToolResult(t *testing.T) { + manyCases := make([]string, 0, 17) + for i := 0; i < 17; i++ { + manyCases = append(manyCases, fmt.Sprintf(`{"id":%d,"title":"case %d","category":"smoke","has_script":%v}`, i, i, i%2 == 0)) + } + cases := []struct { + name, tool, output string + wants []string + }{ + {"error envelope", "anything", `{"error":"boom"}`, []string{"Error: boom"}}, + {"error envelope unparseable", "anything", `{"error" broken`, []string{`{"error" broken`}}, + {"detail envelope", "list_projects", `{"detail":"Not authenticated"}`, []string{"Error: Not authenticated"}}, + + {"test cases array", "list_test_cases", "[" + strings.Join(manyCases, ",") + "]", []string{"17 test cases (9 automated, 8 manual)", "smoke(17)", "... and 2 more"}}, + {"test cases wrapped", "list_test_cases", `{"test_cases":[{"id":1,"title":"Login","is_automated":true}]}`, []string{"1 test cases (1 automated, 0 manual)", "#1 Login"}}, + {"test cases empty", "list_test_cases", `[]`, []string{"No test cases found."}}, + {"test cases bad wrapper", "list_test_cases", `{"items":[]}`, []string{`{"items":[]}`}}, + {"test cases bad shape", "list_test_cases", `{"test_cases":"nope"}`, []string{`{"test_cases":"nope"}`}}, + {"test cases not json", "list_test_cases", `nope`, []string{"nope"}}, + + {"execution full", "run_test", `{"execution_id":"e1","status":"failed","result":"fail","duration":12,"passed":1,"failed":2,"message":"done","execution_time":3.5,"error":"assert","error_message":"timeout","test_errors":"line 4","test_output":"Error: locator.click timeout\n at spec.ts:4","console_logs":[{"text":"Error: 500"},{"text":"ok"},"x"],"screenshot_paths":["a.png",1],"video_path":"v.webm"}`, + []string{"Execution: e1", "Status: failed", "Result: fail", "Duration: 12s", "Passed: 1", "Failed: 2", "Message: done", "Duration: 3.5s", "Error: assert", "Error: timeout", "Test errors: line 4", "Playwright error:", "Console: Error: 500", "Screenshots: 2 captured", "Video: v.webm"}}, + {"execution by id", "check_test_status", `{"id":"e2","duration_seconds":4}`, []string{"Execution: e2", "Duration: 4s"}}, + {"execution empty", "check_test_status", `{}`, []string{"{}"}}, + {"execution not json", "run_test", `nope`, []string{"nope"}}, + + {"batch array", "run_tests_batch", `[{"execution_id":"a","status":"queued"},{"id":"b","status":"running"}]`, []string{"2 executions started:", "a — queued", "b — running"}}, + {"batch wrapped", "run_tests_batch", `{"executions":[{"execution_id":"c","status":"queued"}]}`, []string{"1 executions started:"}}, + {"batch single", "run_tests_batch", `{"execution_id":"d","status":"queued"}`, []string{"Execution: d"}}, + {"batch bad shape", "run_tests_batch", `{"executions":"x"}`, []string{`{"executions":"x"}`}}, + {"batch not json", "run_tests_batch", `nope`, []string{"nope"}}, + + {"crawl status", "crawl_status", `{"crawl_id":"c1","status":"running","progress_percent":40}`, []string{"Crawl c1: running (40%)"}}, + {"crawl status job id", "crawl_status", `{"job_id":"j1","status":"queued"}`, []string{"Crawl j1: queued (0%)"}}, + {"crawl status incomplete", "crawl_status", `{"status":"queued"}`, []string{`{"status":"queued"}`}}, + + {"crawl results nested", "crawl_results", `{"results":{"metrics":{"pages_crawled":5,"tests_generated":2},"test_case_id":7,"generated_tests":[{"name":"login","metadata":{"fanatical_quality_score":88}},{"name":"cart"}]}}`, + []string{"Crawl complete: 5 pages, 2 tests generated.", "Test case ID: 7.", "Scripts: login, cart", "Quality: 88/100."}}, + {"crawl results counted", "crawl_results", `{"generated_tests":[{"name":"a"},{"name":"b"},"x"]}`, []string{"0 pages, 3 tests generated.", "Scripts: a, b"}}, + {"crawl results not json", "crawl_results", `nope`, []string{"nope"}}, + + {"crawl jobs total", "list_crawl_jobs", `{"total_count":9,"jobs":[{"id":1,"status":"done","url":"https://example.com/a/very/long/path/that/keeps/going/on","created_at":"today"},{"crawl_id":2,"status":"queued","url":"u"},"skip"]}`, + []string{"3 recent crawl jobs (of 9 total):", "1 — done — https://", "... (today)", "2 — queued — u"}}, + {"crawl jobs no total", "list_crawl_jobs", `{"jobs":[]}`, []string{"0 crawl jobs:"}}, + {"crawl jobs missing", "list_crawl_jobs", `{"items":[]}`, []string{`{"items":[]}`}}, + {"crawl jobs not json", "list_crawl_jobs", `nope`, []string{"nope"}}, + + {"start crawl", "start_crawl", `{"crawl_id":"c9","estimated_seconds":30}`, []string{"Crawl started: c9. Estimated time: 30s"}}, + {"start crawl job", "start_crawl", `{"job_id":"j9"}`, []string{"Crawl started: j9"}}, + {"start crawl id", "start_crawl", `{"id":"i9","estimated_time":5}`, []string{"Crawl started: i9. Estimated time: 5s"}}, + {"start crawl unknown", "start_crawl", `{"queued":true}`, []string{`{"queued":true}`}}, + {"start crawl not json", "start_crawl", `nope`, []string{"nope"}}, + + {"get script", "get_script", `{"id":3,"title":"Login","framework":"playwright","code":"line1\nline2"}`, []string{"Script #3 — Login [playwright] (2 lines)", "```javascript\nline1\nline2\n```"}}, + {"get script huge", "get_script", `{"id":3,"code":"` + strings.Repeat("x", 4100) + `"}`, []string{"truncated (4100 chars total)"}}, + {"get script no code", "get_script", `{"id":3,"name":"n"}`, []string{"Script #3 — n\n{"}}, + {"get script empty", "get_script", `{}`, []string{"{}"}}, + {"get script not json", "get_script", `nope`, []string{"nope"}}, + + {"update script", "update_script", `{"id":3,"title":"Login","code":"a\nb\nc"}`, []string{"Script #3 updated — Login (3 lines)"}}, + {"update script bare", "update_script", `{"ok":true}`, []string{"Script updated"}}, + {"update script not json", "update_script", `nope`, []string{"nope"}}, + + {"unknown pretty", "other", `{"a":` + strings.Repeat(" ", 100) + `1}`, []string{"{\n \"a\": 1\n}"}}, + {"unknown passthrough", "other", `plain text`, []string{"plain text"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SummarizeToolResult(tc.tool, tc.output) + for _, want := range tc.wants { + if !strings.Contains(got, want) { + t.Errorf("SummarizeToolResult(%q) missing %q\n%s", tc.tool, want, got) + } + } + }) + } +} + +func TestExtractPlaywrightError(t *testing.T) { + if got := extractPlaywrightError("all good\n1 passed"); got != "" { + t.Fatalf("clean output = %q", got) + } + var b strings.Builder + b.WriteString("Running 1 test\n Error: strict mode violation\n") + for i := 0; i < 30; i++ { + fmt.Fprintf(&b, " at frame%d (spec.ts:%d)\n", i, i) + } + got := extractPlaywrightError(b.String()) + if !strings.Contains(got, "strict mode violation") || strings.Count(got, "\n") > 25 { + t.Fatalf("extract = %q", got) + } +} + +func TestToolCostAndArgHelpers(t *testing.T) { + for tool, want := range map[string]string{ + "list_projects": "free", "generate_test_code": "low", "run_test": "medium", + "start_crawl": "high", "create_pr": "medium", "set_review_preferences": "free", "mystery": "free", + } { + if got := ToolCost(tool); got != want { + t.Errorf("ToolCost(%q) = %q, want %q", tool, got, want) + } + } + in := map[string]interface{}{"n": 4} + if intArg(in, "n", 1) != "4" || intArg(in, "x", 7) != "7" || intArg(in, "x", 0) != "0" { + t.Error("intArg") + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 2d877f9..e25b0e1 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -15,11 +15,11 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // jsonError formats msg as a JSON-encoded error object after redaction. @@ -1085,6 +1085,9 @@ func executeToolViaAPI(name string, rawInput interface{}, sctx *api.SessionConte // When sctx.LiveFeed is true the test runs in a QM Cloud Sandbox; status // responses are scanned for `live_browser_url` and the freshest one is // stored on sctx for the REPL to auto-launch /browserfeed against. +// testStatusPollInterval spaces CheckTestStatus polls while a cloud test runs. +var testStatusPollInterval = 2 * time.Second + func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.SessionContext, scriptID int, headless bool, browser, baseURL string, term *tui.Terminal) string { // Start the test raw := client.RunTest(ctx, scriptID, headless, browser, baseURL, sctx != nil && sctx.LiveFeed) @@ -1153,7 +1156,7 @@ func runTestWithProgress(ctx context.Context, client *api.APIClient, sctx *api.S lastPct := -1 stuckCount := 0 for i := 0; i < 180; i++ { // max 6 minutes - time.Sleep(2 * time.Second) + time.Sleep(testStatusPollInterval) statusRaw := client.CheckTestStatus(ctx, execID) captureLiveURLTo(sctx, statusRaw, term) @@ -2782,20 +2785,25 @@ func callVisionAnalysis(sctx *api.SessionContext, imageURL, prompt string) strin return jsonError(fmt.Sprintf("Vision API error %d: %s", resp.StatusCode, security.RedactSensitive(string(respData)))) } - var result map[string]interface{} + var result api.APIResponse if err := json.Unmarshal(respData, &result); err != nil { return jsonError("Failed to parse vision response") } // Extract text from response - content, _ := result["content"].([]interface{}) - if len(content) == 0 { + if len(result.Content) == 0 { return jsonError("Empty vision response") } - block, _ := content[0].(map[string]interface{}) - text, _ := block["text"].(string) - - return text + var text strings.Builder + for _, block := range result.Content { + if block.Type == "text" { + text.WriteString(block.Text) + } + } + if text.Len() == 0 { + return jsonError("Empty vision response") + } + return text.String() } // callBackendVisionAnalysis falls back to QualityMax backend for vision analysis diff --git a/internal/agent/tools_api_test.go b/internal/agent/tools_api_test.go new file mode 100644 index 0000000..6ffb48b --- /dev/null +++ b/internal/agent/tools_api_test.go @@ -0,0 +1,734 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +// fakeCloud is a stand-in QualityMax API. Handlers are matched by path +// prefix; unmatched requests get a generic JSON body. Every request is +// recorded as "METHOD /path". +type fakeCloud struct { + t *testing.T + srv *httptest.Server + mu sync.Mutex + seen []string + bodies []string + routes map[string]func(w http.ResponseWriter, r *http.Request) +} + +func newFakeCloud(t *testing.T) *fakeCloud { + t.Helper() + fc := &fakeCloud{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}} + fc.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + fc.mu.Lock() + fc.seen = append(fc.seen, r.Method+" "+r.URL.Path) + fc.bodies = append(fc.bodies, string(body)) + var match func(http.ResponseWriter, *http.Request) + best := -1 + for prefix, h := range fc.routes { + if strings.HasPrefix(r.URL.Path, prefix) && len(prefix) > best { + match, best = h, len(prefix) + } + } + fc.mu.Unlock() + if match != nil { + match(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(fc.srv.Close) + return fc +} + +func (fc *fakeCloud) handle(prefix string, h func(http.ResponseWriter, *http.Request)) { + fc.mu.Lock() + defer fc.mu.Unlock() + fc.routes[prefix] = h +} + +func (fc *fakeCloud) json(prefix, body string) { + fc.handle(prefix, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, body) + }) +} + +func (fc *fakeCloud) requests() []string { + fc.mu.Lock() + defer fc.mu.Unlock() + return append([]string(nil), fc.seen...) +} + +func (fc *fakeCloud) sawPrefix(prefix string) bool { + for _, r := range fc.requests() { + if strings.HasPrefix(r, prefix) { + return true + } + } + return false +} + +func (fc *fakeCloud) client() *api.APIClient { + return api.NewAPIClient(&api.AuthConfig{APIKey: "qm-test", CloudURL: fc.srv.URL}) +} + +// routeAllRequestsTo sends every outbound request, whatever its host, to srv. +// Clients capture http.DefaultTransport when built, so call this first. +func routeAllRequestsTo(t *testing.T, srv *httptest.Server) { + t.Helper() + target, _ := url.Parse(srv.URL) + orig := http.DefaultTransport + base := orig + if prev, ok := orig.(rewriteTransport); ok { + base = prev.base + } + http.DefaultTransport = rewriteTransport{target: target, base: base} + t.Cleanup(func() { http.DefaultTransport = orig }) +} + +type rewriteTransport struct { + target *url.URL + base http.RoundTripper +} + +func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = rt.target.Scheme + clone.URL.Host = rt.target.Host + clone.Host = rt.target.Host + return rt.base.RoundTrip(clone) +} + +func requirePOSIX(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell stubs") + } +} + +func fastPolling(t *testing.T) { + t.Helper() + orig := testStatusPollInterval + testStatusPollInterval = time.Millisecond + t.Cleanup(func() { testStatusPollInterval = orig }) +} + +func TestExecuteToolViaAPIDispatchesEveryCloudTool(t *testing.T) { + withTempHome(t) + fastPolling(t) + fc := newFakeCloud(t) + sctx := &api.SessionContext{API: fc.client(), ProjectID: 9} + ctx := context.Background() + + cases := []struct { + tool string + input map[string]interface{} + path string + }{ + {"list_projects", nil, "GET /api/projects"}, + {"list_test_cases", map[string]interface{}{"search": "login", "limit": 5}, "GET /api/"}, + {"list_scripts", nil, "GET /api/"}, + {"generate_test_code", map[string]interface{}{"test_case_id": 3}, "POST /api/"}, + {"run_native_test", map[string]interface{}{"script_id": 4}, "POST /api/"}, + {"setup_cicd", map[string]interface{}{"repo_id": 1, "framework": "playwright"}, "POST /api/"}, + {"run_tests_batch", map[string]interface{}{"script_ids": "1,2"}, "POST /api/"}, + {"check_test_status", map[string]interface{}{"execution_id": "e1"}, "GET /api/playwright-execution/status/e1"}, + {"start_crawl", map[string]interface{}{"url": "https://example.com"}, "POST /api/"}, + {"crawl_status", map[string]interface{}{"crawl_id": "c1"}, "GET /api/"}, + {"crawl_results", map[string]interface{}{"crawl_id": "c1"}, "GET /api/"}, + {"list_crawl_jobs", nil, "GET /api/"}, + {"list_repos", nil, "GET /api/"}, + {"review_repo", map[string]interface{}{"repo_id": 2}, "POST /api/"}, + {"get_review_preferences", map[string]interface{}{"repository_id": 2}, "/api/"}, + {"set_review_preferences", map[string]interface{}{"scope": "global", "preferences": map[string]interface{}{"focus": "security"}}, "/api/"}, + {"repo_coverage", map[string]interface{}{"repo_id": 2}, "GET /api/"}, + {"repo_quality", map[string]interface{}{"repo_id": 2}, "GET /api/"}, + {"import_repo", map[string]interface{}{"url": "https://github.com/o/r"}, "POST /api/"}, + {"import_document", map[string]interface{}{"text": "spec"}, "POST /api/"}, + {"create_pr", map[string]interface{}{"repo_id": 2}, "POST /api/repositories/create-pr"}, + {"get_script", map[string]interface{}{"script_id": 5}, "GET /api/automation/scripts/5"}, + {"update_script", map[string]interface{}{"script_id": 5, "content": "test('x', () => {})"}, "PUT /api/"}, + {"check_job_status", map[string]interface{}{"job_id": "j"}, "GET /api/"}, + {"k6_list_scripts", nil, "GET /api/"}, + {"k6_create_script", map[string]interface{}{"name": "load"}, "POST /api/"}, + {"k6_get_script", map[string]interface{}{"script_id": 1}, "GET /api/"}, + {"k6_run_test", map[string]interface{}{"script_id": 1, "vus": 2}, "POST /api/"}, + {"k6_check_status", map[string]interface{}{"execution_id": "k"}, "GET /api/"}, + {"k6_report", map[string]interface{}{"execution_id": "k"}, "GET /api/"}, + {"k6_generate", map[string]interface{}{"target_url": "https://x"}, "POST /api/"}, + {"k6_convert", map[string]interface{}{"source_code": "x"}, "POST /api/"}, + {"create_test_case", map[string]interface{}{"title": "t"}, "POST /api/"}, + {"update_test_case", map[string]interface{}{"test_case_id": 1, "title": "t"}, "/api/"}, + {"delete_test_case", map[string]interface{}{"test_case_id": 1}, "DELETE /api/"}, + {"create_project", map[string]interface{}{"name": "p"}, "POST /api/"}, + {"update_project", map[string]interface{}{"name": "p"}, "/api/"}, + {"delete_project", map[string]interface{}{"project_id": 1}, "DELETE /api/"}, + {"get_project_by_slug", map[string]interface{}{"slug": "demo"}, "GET /api/projects/by-slug/demo"}, + {"get_project_summary", nil, "GET /api/"}, + {"trigger_framework_run", map[string]interface{}{"framework_type": "playwright"}, "POST /api/"}, + {"export_framework", nil, "/api/"}, + {"get_install_command", nil, "GET /api/"}, + {"enhance_test_case", map[string]interface{}{"test_case_id": 1}, "POST /api/"}, + {"generate_gap_tests", map[string]interface{}{"repo_id": 1}, "POST /api/"}, + {"start_crawl_from_test_case", map[string]interface{}{"test_case_id": 1}, "POST /api/"}, + {"export_qtml", nil, "/api/"}, + {"import_qtml", map[string]interface{}{"content": "qtml"}, "POST /api/"}, + {"test_deployed_environment", map[string]interface{}{"url": "https://x"}, "POST /api/"}, + } + for _, tc := range cases { + t.Run(tc.tool, func(t *testing.T) { + before := len(fc.requests()) + out := executeTool(tc.tool, tc.input, sctx, ctx, nil) + if out == "" { + t.Fatalf("%s returned empty output", tc.tool) + } + after := fc.requests()[before:] + found := false + for _, r := range after { + if strings.Contains(r, strings.TrimSpace(tc.path)) || strings.HasPrefix(r, tc.path) { + found = true + } + } + if !found { + t.Fatalf("%s: no request matching %q; got %v", tc.tool, tc.path, after) + } + }) + } + + if got := executeToolViaAPI("no_such_tool", nil, sctx, ctx, nil); got != "" { + t.Fatalf("unknown tool should fall through, got %q", got) + } + if got := executeTool("update_script", map[string]interface{}{"script_id": 5, "code": "require('child_process').exec('rm -rf /')"}, sctx, ctx, nil); !strings.Contains(got, "Security scan failed") { + t.Fatalf("unsafe script was not blocked: %s", got) + } +} + +func TestGenerateTestCodeUsesConfiguredFramework(t *testing.T) { + home := withTempHome(t) + cfg := api.DefaultConfig() + cfg.DefaultFramework = "go_test" + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + _ = home + fc := newFakeCloud(t) + executeTool("generate_test_code", map[string]interface{}{"test_case_id": 1}, &api.SessionContext{API: fc.client()}, context.Background(), nil) + fc.mu.Lock() + defer fc.mu.Unlock() + if len(fc.bodies) == 0 || !strings.Contains(fc.bodies[len(fc.bodies)-1], "go_test") { + t.Fatalf("framework not sent: %v", fc.bodies) + } +} + +func TestRunTestWithProgressOutcomes(t *testing.T) { + withTempHome(t) + fastPolling(t) + ctx := context.Background() + + t.Run("start error is returned", func(t *testing.T) { + fc := newFakeCloud(t) + fc.handle("/api/playwright-execution/run/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"detail":"no credits"}`, http.StatusPaymentRequired) + }) + out := runTestWithProgress(ctx, fc.client(), &api.SessionContext{}, 1, true, "", "", nil) + if !strings.Contains(out, "error") { + t.Fatalf("out = %s", out) + } + }) + + t.Run("non-json and missing execution id pass through", func(t *testing.T) { + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `not json`) + if out := runTestWithProgress(ctx, fc.client(), nil, 1, true, "", "", nil); out != "not json" { + t.Fatalf("out = %q", out) + } + fc.json("/api/playwright-execution/run/", `{"queued":true}`) + if out := runTestWithProgress(ctx, fc.client(), nil, 1, true, "", "", nil); out != `{"queued":true}` { + t.Fatalf("out = %q", out) + } + }) + + t.Run("polls until passed", func(t *testing.T) { + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `{"execution_id":"e1"}`) + var n int + var mu sync.Mutex + fc.handle("/api/playwright-execution/status/", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + n++ + c := n + mu.Unlock() + switch c { + case 1: + _, _ = io.WriteString(w, `garbage`) + case 2: + _, _ = io.WriteString(w, `{"status":"queued"}`) + case 3: + _, _ = io.WriteString(w, `{"status":"running","progress":50,"message":"step 2"}`) + default: + _, _ = io.WriteString(w, `{"status":"passed","message":"ok"}`) + } + }) + out := captureStdout(t, func() { + got := runTestWithProgress(ctx, fc.client(), &api.SessionContext{}, 1, true, "chromium", "https://app", nil) + if !strings.Contains(got, `"passed"`) { + t.Errorf("final = %s", got) + } + }) + _ = out + }) + + t.Run("live feed returns immediately", func(t *testing.T) { + resetExecIDFileForTest() + t.Cleanup(resetExecIDFileForTest) + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `{"execution_id":"e2"}`) + out := runTestWithProgress(ctx, fc.client(), &api.SessionContext{LiveFeed: true}, 1, true, "", "", nil) + if !strings.Contains(out, "client_note") || !strings.Contains(out, "e2") { + t.Fatalf("out = %s", out) + } + }) + + t.Run("stuck progress stops polling", func(t *testing.T) { + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `{"execution_id":"e3"}`) + fc.json("/api/playwright-execution/status/", `{"status":"running","progress":42}`) + captureStdout(t, func() { + out := runTestWithProgress(ctx, fc.client(), &api.SessionContext{}, 1, true, "", "", nil) + if !strings.Contains(out, "stuck at 42%") { + t.Errorf("out = %s", out) + } + }) + }) + + t.Run("persistent progress uses the turn activity line", func(t *testing.T) { + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `{"execution_id":"e4"}`) + fc.json("/api/playwright-execution/status/", `{"status":"failed","message":"boom"}`) + out := runTestWithProgress(ctx, fc.client(), &api.SessionContext{}, 1, true, "", "", &tui.Terminal{}) + if !strings.Contains(out, `"failed"`) { + t.Fatalf("out = %s", out) + } + }) + + t.Run("times out after max polls", func(t *testing.T) { + fc := newFakeCloud(t) + fc.json("/api/playwright-execution/run/", `{"execution_id":"e5"}`) + var n int + var mu sync.Mutex + fc.handle("/api/playwright-execution/status/", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + n++ + c := n + mu.Unlock() + fmt.Fprintf(w, `{"status":"running","progress":%d}`, c%90+1) + }) + captureStdout(t, func() { + out := runTestWithProgress(ctx, fc.client(), &api.SessionContext{}, 1, true, "", "", nil) + if !strings.Contains(out, "Polling stopped after 6 minutes") { + t.Errorf("out = %s", out) + } + }) + }) +} + +func TestRunLocalTestPaths(t *testing.T) { + requirePOSIX(t) + withTempHome(t) + ctx := context.Background() + + if out := runLocalTest(ctx, &api.SessionContext{}, 1, ""); !strings.Contains(out, "Not connected") { + t.Fatalf("no API = %s", out) + } + + fc := newFakeCloud(t) + sctx := &api.SessionContext{API: fc.client()} + script := func(body string) { + fc.json("/api/automation/scripts/", body) + } + + fc.handle("/api/automation/scripts/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"detail":"missing"}`, http.StatusNotFound) + }) + if out := runLocalTest(ctx, sctx, 1, ""); !strings.Contains(out, "error") { + t.Fatalf("missing script = %s", out) + } + script(`nope`) + if out := runLocalTest(ctx, sctx, 1, ""); !strings.Contains(out, "Failed to parse script") { + t.Fatalf("bad json = %s", out) + } + script(`{"script":{"framework":"pytest"}}`) + if out := runLocalTest(ctx, sctx, 1, ""); !strings.Contains(out, "no code") { + t.Fatalf("no code = %s", out) + } + script(`{"code":"x","framework":"cobol"}`) + if out := runLocalTest(ctx, sctx, 1, ""); !strings.Contains(out, "not supported for local execution") { + t.Fatalf("unsupported = %s", out) + } + script(`{"code":"fn main(){}","framework":"rust_cargo"}`) + runLocalTest(ctx, sctx, 1, "") + if !fc.sawPrefix("POST /api/") { + t.Fatal("native framework did not delegate to the API") + } + + bin := t.TempDir() + writeStubIn(t, bin, "python3", `echo "collected 1 item"; echo "test_script.py::test_ok PASSED"; exit 0`) + writeStubIn(t, bin, "npx", `echo "Error: expect(locator).toBeVisible() failed" >&2; exit 1`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + fc.json("/api/automation/scripts/", `{"script":{"code":"def test_ok(): pass","framework":"pytest","name":"smoke"}}`) + fc.json("/api/automation/local-result", `{"execution_id":"local-1"}`) + fc.json("/api/playwright-execution/local", `{"execution_id":"local-1"}`) + out := runLocalTest(ctx, sctx, 7, "https://staging") + var res map[string]interface{} + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("pytest result not JSON: %s", out) + } + if res["passed"] != true || res["name"] != "smoke" || !strings.Contains(res["output"].(string), "PASSED") { + t.Fatalf("pytest result = %v", res) + } + + fc.json("/api/automation/scripts/", `{"code":"test('x', async () => {})","framework":"playwright"}`) + out = runLocalTest(ctx, sctx, 8, "https://staging") + if err := json.Unmarshal([]byte(out), &res); err != nil { + t.Fatalf("playwright result not JSON: %s", out) + } + if res["passed"] != false || !strings.Contains(res["output"].(string), "--- stderr ---") { + t.Fatalf("playwright result = %v", res) + } +} + +func writeStubIn(t *testing.T, dir, name, body string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestExecuteToolViaQMaxCLI(t *testing.T) { + requirePOSIX(t) + withTempHome(t) + bin := t.TempDir() + qmax := writeStubIn(t, bin, "qmax", `printf '{"args":"%s"}' "$*"`) + sctx := &api.SessionContext{QMaxBin: qmax, ProjectID: 3} + ctx := context.Background() + + cases := []struct { + tool string + input map[string]interface{} + want string + }{ + {"list_projects", nil, "projects --json"}, + {"list_test_cases", map[string]interface{}{"limit": 2, "search": "x"}, "--search x"}, + {"list_scripts", map[string]interface{}{"limit": 2}, "--limit 2"}, + {"generate_test_code", map[string]interface{}{"test_case_id": 4, "force": true}, "--force"}, + {"run_test", map[string]interface{}{"script_id": 1, "headless": true, "browser": "firefox", "base_url": "u"}, "--browser firefox"}, + {"run_tests_batch", map[string]interface{}{"script_ids": "1,2", "base_url": "u"}, "--script-ids 1,2"}, + {"check_test_status", map[string]interface{}{"execution_id": "e"}, "--execution-id e"}, + {"start_crawl", map[string]interface{}{"url": "u", "depth": 2, "pages": 3, "test_type": "smoke", "instructions": "go"}, "--instructions go"}, + {"crawl_status", map[string]interface{}{"crawl_id": "c"}, "crawl status"}, + {"crawl_results", map[string]interface{}{"crawl_id": "c"}, "crawl results"}, + {"list_crawl_jobs", map[string]interface{}{"limit": 4}, "crawl jobs"}, + {"list_repos", nil, "--project-id 3"}, + {"review_repo", map[string]interface{}{"repo_id": 1}, "repo review"}, + {"repo_coverage", map[string]interface{}{"repo_id": 1}, "repo coverage"}, + {"repo_quality", map[string]interface{}{"repo_id": 1}, "repo quality"}, + {"import_repo", map[string]interface{}{"url": "u", "create_project": true, "project_name": "n", "base_url": "b"}, "--project-name n"}, + {"import_document", map[string]interface{}{"text": "t", "source_name": "s"}, "--source s"}, + {"create_pr", map[string]interface{}{"repo_id": 1}, "pr create"}, + } + for _, tc := range cases { + out := executeToolViaQMax(tc.tool, tc.input, sctx, ctx) + if !strings.Contains(out, tc.want) { + t.Errorf("%s = %s, want %q", tc.tool, out, tc.want) + } + } + type typed struct { + ScriptID int `json:"script_id"` + } + if out := executeToolViaQMax("check_test_status", typed{ScriptID: 1}, sctx, ctx); !strings.Contains(out, "test status") { + t.Errorf("struct input = %s", out) + } + if out := executeToolViaQMax("nope", nil, sctx, ctx); !strings.Contains(out, "Unknown tool") { + t.Errorf("unknown = %s", out) + } + if out := executeToolViaQMax("run_command", map[string]interface{}{"command": "echo hi"}, sctx, ctx); strings.TrimSpace(out) != "hi" { + t.Errorf("run_command = %q", out) + } + if out := executeToolViaQMax("run_command", map[string]interface{}{"command": "rm -rf /"}, sctx, ctx); !strings.Contains(out, "blocked") { + t.Errorf("dangerous command = %q", out) + } + if out := executeToolViaQMax("run_command", map[string]interface{}{"command": "ls /definitely-missing-qmax-path"}, sctx, ctx); !strings.Contains(out, "definitely-missing") { + t.Errorf("failing command = %q", out) + } + if out := runShell(ctx, "/nonexistent/binary"); !strings.Contains(out, "error") { + t.Errorf("missing binary = %q", out) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + if out := runShell(canceled, "sleep", "1"); !strings.Contains(out, "cancelled") { + t.Errorf("canceled shell = %q", out) + } + if out := runQMax(sctx, canceled, "x"); !strings.Contains(out, "cancelled") { + t.Errorf("canceled qmax = %q", out) + } + + failing := writeStubIn(t, bin, "qmax-fail", `echo "auth expired" >&2; exit 1`) + if out := runQMax(&api.SessionContext{QMaxBin: failing}, ctx); !strings.Contains(out, "auth expired") { + t.Errorf("stderr error = %q", out) + } + silent := writeStubIn(t, bin, "qmax-silent", `exit 2`) + if out := runQMax(&api.SessionContext{QMaxBin: silent}, ctx); !strings.Contains(out, "exit status 2") { + t.Errorf("silent error = %q", out) + } + stderrOnly := writeStubIn(t, bin, "qmax-stderr", `echo "note" >&2`) + if out := runQMax(&api.SessionContext{QMaxBin: stderrOnly}, ctx); out != "note" { + t.Errorf("stderr-only success = %q", out) + } + if out := runQMax(&api.SessionContext{}, ctx); !strings.Contains(out, "qmax CLI not found") { + t.Errorf("missing qmax = %q", out) + } + + dir := t.TempDir() + t.Chdir(dir) + if out := executeToolViaQMax("write_file", map[string]interface{}{"path": "notes.txt", "content": "hello"}, sctx, ctx); !strings.Contains(out, `"success": true`) { + t.Fatalf("write_file = %s", out) + } + if out := executeToolViaQMax("read_file", map[string]interface{}{"path": "notes.txt"}, sctx, ctx); out != "hello" { + t.Fatalf("read_file = %q", out) + } + if out := executeToolViaQMax("write_file", map[string]interface{}{"path": "../escape.txt", "content": "x"}, sctx, ctx); !strings.Contains(out, "error") { + t.Fatalf("escaping write = %s", out) + } +} + +func TestScriptHealingViaQMaxConfig(t *testing.T) { + home := withTempHome(t) + ctx := context.Background() + fc := newFakeCloud(t) + fc.handle("/api/automation/scripts/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + _, _ = io.WriteString(w, `{"id":1,"name":"login","code":"updated"}`) + return + } + if strings.HasSuffix(r.URL.Path, "/404") { + http.Error(w, "nope", http.StatusNotFound) + return + } + _, _ = io.WriteString(w, `{"script":{"name":"login","code":"old code"}}`) + }) + sctx := &api.SessionContext{} + sctx.QMaxCfg.Token = "tok" + sctx.QMaxCfg.CloudURL = fc.srv.URL + + if out := fetchScriptCode(&api.SessionContext{}, ctx, "1"); !strings.Contains(out, "not authenticated") { + t.Fatalf("unauth fetch = %s", out) + } + if out := updateScriptCode(&api.SessionContext{}, ctx, "1", "n", "c"); !strings.Contains(out, "not authenticated") { + t.Fatalf("unauth update = %s", out) + } + if out := executeToolViaQMax("get_script", map[string]interface{}{"script_id": 404}, sctx, ctx); !strings.Contains(out, "HTTP 404") { + t.Fatalf("404 fetch = %s", out) + } + if out := executeToolViaQMax("rollback_script", map[string]interface{}{"script_id": 1}, sctx, ctx); !strings.Contains(out, "no backups found") { + t.Fatalf("rollback without backups = %s", out) + } + out := executeToolViaQMax("update_script", map[string]interface{}{"script_id": 1, "name": "login", "content": "test('new code', async () => {})"}, sctx, ctx) + if !strings.Contains(out, "updated") { + t.Fatalf("update = %s", out) + } + if out := executeToolViaQMax("update_script", map[string]interface{}{"script_id": 1, "code": "eval(atob('x'))"}, sctx, ctx); !strings.Contains(out, "Security scan failed") { + t.Fatalf("unsafe update = %s", out) + } + if out := rollbackScript(sctx, ctx, "2"); !strings.Contains(out, "no backup found for script 2") { + t.Fatalf("rollback other script = %s", out) + } + out = executeToolViaQMax("rollback_script", map[string]interface{}{"script_id": 1}, sctx, ctx) + if !strings.Contains(out, "updated") || !strings.Contains(lastBody(fc), "old code") { + t.Fatalf("rollback = %s", out) + } + + backups := filepath.Join(home, ".qmax-code", "script-backups") + _ = os.WriteFile(filepath.Join(backups, "3_x.json"), []byte("not json"), 0o600) + if out := rollbackScript(sctx, ctx, "3"); !strings.Contains(out, "failed to parse backup") { + t.Fatalf("corrupt backup = %s", out) + } + + fc.handle("/api/automation/scripts/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "denied", http.StatusForbidden) + }) + if out := updateScriptCode(sctx, ctx, "1", "n", "c"); !strings.Contains(out, "HTTP 403") { + t.Fatalf("403 update = %s", out) + } + sctx.QMaxCfg.CloudURL = "http://127.0.0.1:1" + if out := fetchScriptCode(sctx, ctx, "1"); !strings.Contains(out, "error") { + t.Fatalf("unreachable fetch = %s", out) + } + if out := updateScriptCode(sctx, ctx, "1", "n", "c"); !strings.Contains(out, "error") { + t.Fatalf("unreachable update = %s", out) + } + sctx.QMaxCfg.CloudURL = "://bad" + if out := fetchScriptCode(sctx, ctx, "1"); !strings.Contains(out, "error") { + t.Fatalf("bad URL fetch = %s", out) + } + if out := updateScriptCode(sctx, ctx, "1", "n", "c"); !strings.Contains(out, "error") { + t.Fatalf("bad URL update = %s", out) + } +} + +func lastBody(fc *fakeCloud) string { + fc.mu.Lock() + defer fc.mu.Unlock() + if len(fc.bodies) == 0 { + return "" + } + return fc.bodies[len(fc.bodies)-1] +} + +func TestScreenshotVisionTools(t *testing.T) { + withTempHome(t) + fc := newFakeCloud(t) + client := fc.client() + sctx := &api.SessionContext{API: client} + ctx := context.Background() + + if out := executeTool("analyze_screenshot", map[string]interface{}{}, sctx, ctx, nil); !strings.Contains(out, "execution_id is required") { + t.Fatalf("missing id = %s", out) + } + if out := executeTool("get_page_elements", map[string]interface{}{}, sctx, ctx, nil); !strings.Contains(out, "execution_id is required") { + t.Fatalf("missing id = %s", out) + } + fc.json("/api/playwright-execution/status/", `nope`) + if out := executeTool("analyze_screenshot", map[string]interface{}{"execution_id": "e"}, sctx, ctx, nil); !strings.Contains(out, "Failed to parse execution status") { + t.Fatalf("bad status = %s", out) + } + fc.json("/api/playwright-execution/status/", `{"screenshot_paths":[]}`) + if out := executeTool("get_page_elements", map[string]interface{}{"execution_id": "e"}, sctx, ctx, nil); !strings.Contains(out, "No screenshots") { + t.Fatalf("no screenshots = %s", out) + } + fc.json("/api/playwright-execution/status/", `{"screenshot_paths":[""]}`) + if out := executeTool("get_page_elements", map[string]interface{}{"execution_id": "e"}, sctx, ctx, nil); !strings.Contains(out, "Screenshot path is empty") { + t.Fatalf("empty path = %s", out) + } + + fc.json("/api/playwright-execution/status/", `{"screenshot_paths":["run/final.png"]}`) + t.Setenv("ANTHROPIC_API_KEY", "") + out := executeTool("analyze_screenshot", map[string]interface{}{"execution_id": "e"}, sctx, ctx, nil) + if !strings.Contains(out, "https://app.qualitymax.io/static/test-executions/run/final.png") || !strings.Contains(out, "ANTHROPIC_API_KEY") { + t.Fatalf("backend fallback = %s", out) + } + + vision := newFakeCloud(t) + routeAllRequestsTo(t, vision.srv) + client = api.NewAPIClient(&api.AuthConfig{APIKey: "qm", CloudURL: vision.srv.URL}) + sctx = &api.SessionContext{API: client} + sctx.QMaxCfg.CloudURL = "https://cloud.example" + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test") + vision.json("/api/playwright-execution/status/", `{"screenshot_paths":["https://cdn.example/s.png"]}`) + vision.json("/v1/messages", `{"content":[{"type":"text","text":"A login form with an email field."}]}`) + if out := executeTool("get_page_elements", map[string]interface{}{"execution_id": "e"}, sctx, ctx, nil); out != "A login form with an email field." { + t.Fatalf("vision = %s", out) + } + vision.json("/v1/messages", `{"content":[{"type":"thinking","thinking":"internal"},{"type":"text","text":"A login "},{"type":"text","text":"form."}]}`) + if out := callVisionAnalysis(sctx, "https://x/s.png", "p"); out != "A login form." { + t.Fatalf("vision with thinking and multiple text blocks = %s", out) + } + vision.json("/v1/messages", `{"content":[{"type":"thinking","thinking":"internal"}]}`) + if out := callVisionAnalysis(sctx, "https://x/s.png", "p"); !strings.Contains(out, "Empty vision response") { + t.Fatalf("thinking-only vision = %s", out) + } + vision.json("/v1/messages", `{"content":[]}`) + if out := callVisionAnalysis(sctx, "https://x/s.png", "p"); !strings.Contains(out, "Empty vision response") { + t.Fatalf("empty vision = %s", out) + } + vision.json("/v1/messages", `garbage`) + if out := callVisionAnalysis(sctx, "https://x/s.png", "p"); !strings.Contains(out, "Failed to parse vision response") { + t.Fatalf("bad vision = %s", out) + } + vision.handle("/v1/messages", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "overloaded", http.StatusServiceUnavailable) + }) + if out := callVisionAnalysis(sctx, "https://x/s.png", "p"); !strings.Contains(out, "Vision API error 503") { + t.Fatalf("vision 503 = %s", out) + } +} + +func TestChainSecurityAuditOnPR(t *testing.T) { + requirePOSIX(t) + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + withTempHome(t) + fc := newFakeCloud(t) + fc.json("/api/", `{"audit":"clean"}`) + client := fc.client() + ctx := context.Background() + + if got := chainSecurityAuditOnPR(ctx, client, &api.SessionContext{}, `{"error":"x"}`); got != `{"error":"x"}` { + t.Fatalf("failed PR passthrough = %s", got) + } + noRemote := `{"pr_number": 12}` + if got := chainSecurityAuditOnPR(ctx, client, &api.SessionContext{}, noRemote); got != noRemote { + t.Fatalf("no remote passthrough = %s", got) + } + + repo := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, + {"-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + t.Chdir(repo) + sctx := &api.SessionContext{GitInfo: &api.GitInfo{RemoteURL: "git@github.com:acme/shop.git"}} + prResp := `{"pr_url":"https://github.com/acme/shop/pull/34"}` + if got := chainSecurityAuditOnPR(ctx, client, sctx, prResp); got != prResp { + t.Fatalf("no base ref passthrough = %s", got) + } + cmd := exec.Command("git", "update-ref", "refs/remotes/origin/master", "HEAD") + cmd.Dir = repo + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("update-ref: %v %s", err, out) + } + got := chainSecurityAuditOnPR(ctx, client, sctx, prResp) + if !strings.Contains(got, "Security Audit PR Check") || !strings.Contains(got, "clean") { + t.Fatalf("audit chain = %s", got) + } + + for in, want := range map[string]string{ + "https://github.com/acme/shop.git": "acme/shop", + "git@github.com:acme/shop": "acme/shop", + "https://gitlab.com/acme/shop": "", + } { + if got := repoSlugFromRemote(in); got != want { + t.Errorf("repoSlugFromRemote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index 05a61e9..9d667de 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestToolCost_Free(t *testing.T) { diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 728f33a..68941b9 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -7,10 +7,11 @@ const ( ModelFable51 = "claude-fable-5-1" ModelFable = "claude-fable-5" ModelHaiku = "claude-haiku-4-5-20251001" + ModelHaiku55 = "claude-haiku-5-5" ModelSonnet5 = "claude-sonnet-5" ModelSonnet = "claude-sonnet-4-6" - ModelOpus55 = "claude-opus-5-5" // latest Opus; the "opus" shorthand resolves here - ModelOpus = "claude-opus-4-8" // prior Opus, still selectable by full ID - ModelOpus1M = ModelOpus + "[1m]" // Claude Code 1M-context selector (Opus 4.8) - ModelOpus47 = "claude-opus-4-7" // older Opus, still selectable by full ID + ModelOpus55 = "claude-opus-5-5" // latest Opus; the "opus" shorthand resolves here + ModelOpus = "claude-opus-4-8" // prior Opus, still selectable by full ID + ModelOpus1M = ModelOpus + "[1m]" // Claude Code 1M-context selector (Opus 4.8) + ModelOpus47 = "claude-opus-4-7" // older Opus, still selectable by full ID ) diff --git a/internal/api/auth.go b/internal/api/auth.go index 26303b6..29bd3f8 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/httpx" ) // AuthConfig holds QualityMax authentication credentials. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..fd8b1c4 --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,496 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// Tests for auth.go: the LoadAuth priority chain (env var → auth.json → +// legacy ~/.qamax/config.json), SaveAuth, LoginWithAPIKey, and helpers. +// These tests mutate HOME and env vars — they must not run in parallel. + +// isolateAuthEnv points HOME at a fresh temp dir and clears the QualityMax +// env overrides so auth tests never touch the real user config. +func isolateAuthEnv(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("QUALITYMAX_API_KEY", "") + t.Setenv("QUALITYMAX_URL", "") + return home +} + +func writeAuthFile(t *testing.T, home, content string) { + t.Helper() + dir := filepath.Join(home, QmaxCodeConfigDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, authFileName), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeLegacyConfig(t *testing.T, home, content string) { + t.Helper() + dir := filepath.Join(home, ".qamax") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// ---- LoadAuth priority chain ---- + +func TestLoadAuth_FromEnvVar(t *testing.T) { + isolateAuthEnv(t) + t.Setenv("QUALITYMAX_API_KEY", "qm-env-key") + + cfg := LoadAuth() + if cfg == nil { + t.Fatal("expected config from env var") + } + if cfg.APIKey != "qm-env-key" { + t.Errorf("APIKey = %q, want qm-env-key", cfg.APIKey) + } + if cfg.CloudURL != DefaultCloudURL { + t.Errorf("CloudURL = %q, want %q", cfg.CloudURL, DefaultCloudURL) + } + if !cfg.IsAuthenticated() { + t.Error("IsAuthenticated should be true with env key") + } +} + +func TestLoadAuth_FromEnvVarWithCustomURL(t *testing.T) { + isolateAuthEnv(t) + t.Setenv("QUALITYMAX_API_KEY", "qm-env-key") + t.Setenv("QUALITYMAX_URL", "http://cloud.example.com/") + + cfg := LoadAuth() + if cfg.CloudURL != "http://cloud.example.com/" { + t.Errorf("CloudURL = %q, want the env value verbatim", cfg.CloudURL) + } + if got := cfg.GetCloudURL(); got != "http://cloud.example.com" { + t.Errorf("GetCloudURL() = %q, want trailing slash trimmed", got) + } +} + +func TestLoadAuth_EnvVarBeatsAuthFile(t *testing.T) { + home := isolateAuthEnv(t) + writeAuthFile(t, home, `{"api_key":"file-key"}`) + t.Setenv("QUALITYMAX_API_KEY", "qm-env-key") + + cfg := LoadAuth() + if cfg.APIKey != "qm-env-key" { + t.Errorf("APIKey = %q, want env var to win over auth.json", cfg.APIKey) + } +} + +func TestLoadAuth_FromAuthFile(t *testing.T) { + home := isolateAuthEnv(t) + writeAuthFile(t, home, `{"api_key":"file-key","email":"file@example.com","user_id":"u1","cloud_url":"http://file.example.com"}`) + + cfg := LoadAuth() + if cfg == nil { + t.Fatal("expected config from auth.json") + } + if cfg.APIKey != "file-key" { + t.Errorf("APIKey = %q, want file-key", cfg.APIKey) + } + if cfg.Email != "file@example.com" { + t.Errorf("Email = %q, want file@example.com", cfg.Email) + } + if cfg.UserID != "u1" { + t.Errorf("UserID = %q, want u1", cfg.UserID) + } + if cfg.CloudURL != "http://file.example.com" { + t.Errorf("CloudURL = %q", cfg.CloudURL) + } +} + +func TestLoadAuth_EmptyAuthFileKeyFallsBackToLegacy(t *testing.T) { + home := isolateAuthEnv(t) + writeAuthFile(t, home, `{"api_key":""}`) + writeLegacyConfig(t, home, `{"token":"legacy-token","email":"leg@example.com"}`) + + cfg := LoadAuth() + if cfg == nil || cfg.APIKey != "legacy-token" { + t.Errorf("expected legacy fallback, got %+v", cfg) + } +} + +func TestLoadAuth_LegacyConfigWithTokenAndEmail(t *testing.T) { + home := isolateAuthEnv(t) + writeLegacyConfig(t, home, `{"api_url":"http://legacy.example.com","token":"legacy-token","email":"leg@example.com"}`) + + cfg := LoadAuth() + if cfg == nil { + t.Fatal("expected config from legacy file") + } + if cfg.APIKey != "legacy-token" { + t.Errorf("APIKey = %q, want legacy-token", cfg.APIKey) + } + if cfg.Email != "leg@example.com" { + t.Errorf("Email = %q, want leg@example.com", cfg.Email) + } + if cfg.CloudURL != "http://legacy.example.com" { + t.Errorf("CloudURL = %q", cfg.CloudURL) + } +} + +func TestLoadAuth_LegacyPrefersTokenOverAPIKey(t *testing.T) { + home := isolateAuthEnv(t) + writeLegacyConfig(t, home, `{"token":"jwt-token","api_key":"agent-key","email":"e@example.com"}`) + + cfg := LoadAuth() + if cfg.APIKey != "jwt-token" { + t.Errorf("APIKey = %q, want JWT token preferred over agent API key", cfg.APIKey) + } +} + +func TestLoadAuth_LegacyAPIKeyFallback(t *testing.T) { + home := isolateAuthEnv(t) + writeLegacyConfig(t, home, `{"api_key":"agent-key","email":"e@example.com"}`) + + cfg := LoadAuth() + if cfg == nil || cfg.APIKey != "agent-key" { + t.Errorf("expected api_key fallback, got %+v", cfg) + } +} + +func TestLoadAuth_LegacyDefaultsCloudURL(t *testing.T) { + home := isolateAuthEnv(t) + writeLegacyConfig(t, home, `{"token":"tok","email":"e@example.com"}`) + + cfg := LoadAuth() + if cfg.CloudURL != DefaultCloudURL { + t.Errorf("CloudURL = %q, want %q when legacy file omits api_url", cfg.CloudURL, DefaultCloudURL) + } +} + +func TestLoadAuth_LegacyConfigMigratesEmailViaFetchMe(t *testing.T) { + home := isolateAuthEnv(t) + + meCalls := 0 + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + meCalls++ + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"email":"dev@example.com","id":"user-7"}`)) + })) + defer srv.Close() + + writeLegacyConfig(t, home, fmt.Sprintf(`{"api_url":%q,"token":"legacy-token"}`, srv.URL)) + + cfg := LoadAuth() + if cfg == nil { + t.Fatal("expected config from legacy file") + } + if cfg.APIKey != "legacy-token" { + t.Errorf("APIKey = %q, want legacy-token", cfg.APIKey) + } + if gotPath != "/api/me" { + t.Errorf("migration should call /api/me, got path %q", gotPath) + } + if gotAuth != "Bearer legacy-token" { + t.Errorf("Authorization = %q, want Bearer legacy-token", gotAuth) + } + if cfg.Email != "dev@example.com" { + t.Errorf("Email = %q, want dev@example.com from /api/me", cfg.Email) + } + if cfg.UserID != "user-7" { + t.Errorf("UserID = %q, want user-7 from /api/me", cfg.UserID) + } + if meCalls != 1 { + t.Errorf("expected exactly 1 /api/me call, got %d", meCalls) + } + + // The migrated credentials must be persisted to the new auth.json so the + // legacy file is not needed on the next run. + data, err := os.ReadFile(filepath.Join(home, QmaxCodeConfigDir, authFileName)) + if err != nil { + t.Fatalf("migrated auth.json missing: %v", err) + } + var saved AuthConfig + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("migrated auth.json not valid JSON: %v", err) + } + if saved.APIKey != "legacy-token" || saved.Email != "dev@example.com" || saved.UserID != "user-7" { + t.Errorf("migrated auth.json = %+v", saved) + } +} + +func TestLoadAuth_LegacyFetchMeFailureStillReturnsConfig(t *testing.T) { + home := isolateAuthEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + writeLegacyConfig(t, home, fmt.Sprintf(`{"api_url":%q,"token":"legacy-token"}`, srv.URL)) + + cfg := LoadAuth() + if cfg == nil || cfg.APIKey != "legacy-token" { + t.Fatalf("expected config despite /api/me failure, got %+v", cfg) + } + if cfg.Email != "" { + t.Errorf("Email = %q, want empty when /api/me fails", cfg.Email) + } + if _, err := os.Stat(filepath.Join(home, QmaxCodeConfigDir, authFileName)); !os.IsNotExist(err) { + t.Error("auth.json should not be written when /api/me fails") + } +} + +func TestLoadAuth_ReturnsNilWhenNothingConfigured(t *testing.T) { + isolateAuthEnv(t) + if cfg := LoadAuth(); cfg != nil { + t.Errorf("expected nil config, got %+v", cfg) + } +} + +// ---- loadAuthFile / loadLegacyQMaxAuth direct ---- + +func TestLoadAuthFile_MissingFileReturnsNil(t *testing.T) { + isolateAuthEnv(t) + if cfg := loadAuthFile(); cfg != nil { + t.Errorf("expected nil for missing file, got %+v", cfg) + } +} + +func TestLoadAuthFile_InvalidJSONReturnsNil(t *testing.T) { + home := isolateAuthEnv(t) + writeAuthFile(t, home, `not json`) + if cfg := loadAuthFile(); cfg != nil { + t.Errorf("expected nil for invalid JSON, got %+v", cfg) + } +} + +func TestLoadLegacyQMaxAuth_MissingFileReturnsZero(t *testing.T) { + isolateAuthEnv(t) + if cfg := loadLegacyQMaxAuth(); cfg.Token != "" || cfg.APIKey != "" { + t.Errorf("expected zero config for missing file, got %+v", cfg) + } +} + +func TestLoadLegacyQMaxAuth_ReadsFields(t *testing.T) { + home := isolateAuthEnv(t) + writeLegacyConfig(t, home, `{"api_url":"http://q.example.com","token":"tok","email":"e@example.com","api_key":"ak"}`) + + cfg := loadLegacyQMaxAuth() + if cfg.CloudURL != "http://q.example.com" || cfg.Token != "tok" || + cfg.Email != "e@example.com" || cfg.APIKey != "ak" { + t.Errorf("legacy config fields = %+v", cfg) + } +} + +// ---- SaveAuth ---- + +func TestSaveAuth_WritesJSONFile(t *testing.T) { + home := isolateAuthEnv(t) + + cfg := &AuthConfig{APIKey: "qm-key", Email: "e@example.com", UserID: "u1", CloudURL: "http://c.example.com"} + if err := SaveAuth(cfg); err != nil { + t.Fatalf("SaveAuth: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, QmaxCodeConfigDir, authFileName)) + if err != nil { + t.Fatalf("auth.json not written: %v", err) + } + var loaded AuthConfig + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("auth.json not valid JSON: %v", err) + } + if loaded.APIKey != "qm-key" || loaded.Email != "e@example.com" || + loaded.UserID != "u1" || loaded.CloudURL != "http://c.example.com" { + t.Errorf("round-tripped auth.json = %+v", loaded) + } +} + +func TestSaveAuth_FailsWhenConfigDirIsAFile(t *testing.T) { + home := isolateAuthEnv(t) + // Occupy the ~/.qmax-code path with a regular file so MkdirAll fails. + if err := os.WriteFile(filepath.Join(home, QmaxCodeConfigDir), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := SaveAuth(&AuthConfig{APIKey: "k"}); err == nil { + t.Error("expected error when config dir cannot be created") + } +} + +// ---- AuthConfig helpers ---- + +func TestIsAuthenticated(t *testing.T) { + var nilCfg *AuthConfig + if nilCfg.IsAuthenticated() { + t.Error("nil config should not be authenticated") + } + if (&AuthConfig{}).IsAuthenticated() { + t.Error("empty API key should not be authenticated") + } + if !(&AuthConfig{APIKey: "k"}).IsAuthenticated() { + t.Error("non-empty API key should be authenticated") + } +} + +func TestGetCloudURL(t *testing.T) { + var nilCfg *AuthConfig + if got := nilCfg.GetCloudURL(); got != DefaultCloudURL { + t.Errorf("nil GetCloudURL() = %q, want default", got) + } + if got := (&AuthConfig{}).GetCloudURL(); got != DefaultCloudURL { + t.Errorf("empty GetCloudURL() = %q, want default", got) + } + if got := (&AuthConfig{CloudURL: "http://x.example.com//"}).GetCloudURL(); got != "http://x.example.com" { + t.Errorf("GetCloudURL() = %q, want trailing slashes trimmed", got) + } +} + +// ---- LoginWithAPIKey ---- + +func TestLoginWithAPIKey_SuccessStripsPrefixAndSaves(t *testing.T) { + home := isolateAuthEnv(t) + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"email":"dev@example.com","id":"user-9"}`)) + })) + defer srv.Close() + t.Setenv("QUALITYMAX_URL", srv.URL) + + cfg, err := LoginWithAPIKey("qm-secret") + if err != nil { + t.Fatalf("LoginWithAPIKey: %v", err) + } + if gotPath != "/api/me" { + t.Errorf("validation should call /api/me, got %q", gotPath) + } + // The qm- prefix is stripped server-side; the full key is stored as-is. + if gotAuth != "Bearer secret" { + t.Errorf("Authorization = %q, want Bearer secret (prefix stripped)", gotAuth) + } + if cfg.APIKey != "qm-secret" { + t.Errorf("APIKey = %q, want full key retained", cfg.APIKey) + } + if cfg.Email != "dev@example.com" || cfg.UserID != "user-9" { + t.Errorf("identity fields = %q/%q", cfg.Email, cfg.UserID) + } + + data, err := os.ReadFile(filepath.Join(home, QmaxCodeConfigDir, authFileName)) + if err != nil { + t.Fatalf("auth.json not saved: %v", err) + } + if !strings.Contains(string(data), "qm-secret") { + t.Errorf("saved auth.json missing key: %s", data) + } +} + +func TestLoginWithAPIKey_RejectsInvalidKey(t *testing.T) { + home := isolateAuthEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + t.Setenv("QUALITYMAX_URL", srv.URL) + + cfg, err := LoginWithAPIKey("bad-key") + if err == nil { + t.Fatal("expected error for invalid key") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("error should mention HTTP 401, got %v", err) + } + if cfg != nil { + t.Errorf("expected nil config on failure, got %+v", cfg) + } + if _, statErr := os.Stat(filepath.Join(home, QmaxCodeConfigDir, authFileName)); !os.IsNotExist(statErr) { + t.Error("auth.json should not be written for an invalid key") + } +} + +func TestLoginWithAPIKey_UnreachableServer(t *testing.T) { + isolateAuthEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + t.Setenv("QUALITYMAX_URL", srv.URL) + + cfg, err := LoginWithAPIKey("any-key") + if err == nil { + t.Fatal("expected error for unreachable server") + } + if !strings.Contains(err.Error(), "cannot reach QualityMax") { + t.Errorf("error = %v, want cannot reach QualityMax", err) + } + if cfg != nil { + t.Errorf("expected nil config, got %+v", cfg) + } +} + +// ---- fetchMe ---- + +func TestFetchMe_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"email":"e@example.com","id":"u1"}`)) + })) + defer srv.Close() + + me := fetchMe(&AuthConfig{APIKey: "qm-key", CloudURL: srv.URL}) + if me == nil || me["email"] != "e@example.com" { + t.Errorf("fetchMe = %+v, want email e@example.com", me) + } +} + +func TestFetchMe_Non200ReturnsNil(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + if me := fetchMe(&AuthConfig{APIKey: "qm-key", CloudURL: srv.URL}); me != nil { + t.Errorf("fetchMe should return nil on non-200, got %+v", me) + } +} + +func TestFetchMe_InvalidURLReturnsNil(t *testing.T) { + if me := fetchMe(&AuthConfig{APIKey: "k", CloudURL: "http://exa mple.com"}); me != nil { + t.Errorf("fetchMe should return nil on unparseable URL, got %+v", me) + } +} + +// ---- small helpers ---- + +func TestStripKeyPrefix(t *testing.T) { + cases := []struct{ in, want string }{ + {"qm-abc123", "abc123"}, + {"abc123", "abc123"}, + {"qm-", ""}, + {"qmax-abc", "qmax-abc"}, // only the exact qm- prefix is stripped + {"", ""}, + } + for _, tc := range cases { + if got := stripKeyPrefix(tc.in); got != tc.want { + t.Errorf("stripKeyPrefix(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestEnvOr(t *testing.T) { + t.Setenv("QM_ENV_OR_TEST", "value") + if got := envOr("QM_ENV_OR_TEST", "fallback"); got != "value" { + t.Errorf("envOr with value = %q, want value", got) + } + t.Setenv("QM_ENV_OR_TEST", "") + if got := envOr("QM_ENV_OR_TEST", "fallback"); got != "fallback" { + t.Errorf("envOr with empty value = %q, want fallback", got) + } +} diff --git a/internal/api/claude_code_connection.go b/internal/api/claude_code_connection.go index 2312635..09b780f 100644 --- a/internal/api/claude_code_connection.go +++ b/internal/api/claude_code_connection.go @@ -8,8 +8,8 @@ import ( "io" "net/http" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" ) // ClaudeCodeConnection is the non-sensitive connection state returned by QualityMax. diff --git a/internal/api/claude_code_connection_test.go b/internal/api/claude_code_connection_test.go new file mode 100644 index 0000000..01e536b --- /dev/null +++ b/internal/api/claude_code_connection_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Mirrors codex_connection_test.go: ConnectClaudeCode must send the bearer +// token plus the credentials JSON, return a typed HTTPStatusError on +// failure, and never leak the credential through the error string. + +func TestConnectClaudeCodeSendsBearerAndCredentials(t *testing.T) { + var gotPath, gotAuth string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"connected":true,"status":"connected","account_label":"user@example.com"}`)) + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + credentials := `{"oauth_account_id":"acc-1"}` + connection, err := client.ConnectClaudeCode(context.Background(), credentials) + if err != nil { + t.Fatalf("ConnectClaudeCode: %v", err) + } + if gotPath != "/api/integrations/claude-code/connect" { + t.Fatalf("path = %q", gotPath) + } + if gotAuth != "Bearer qm-token" { + t.Fatalf("Authorization = %q", gotAuth) + } + if gotBody["auth_json"] != credentials { + t.Fatalf("auth_json = %v, want the credentials verbatim", gotBody["auth_json"]) + } + if !connection.Connected || connection.Status != "connected" || connection.AccountLabel != "user@example.com" { + t.Fatalf("connection = %+v", connection) + } +} + +func TestConnectClaudeCodeReturnsTypedHTTPErrorWithoutCredentialLeak(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"detail":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + _, err := client.ConnectClaudeCode(context.Background(), `{"secret":"super-secret-oauth-token"}`) + + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected HTTPStatusError(401), got %v", err) + } + if statusErr.Message != "unauthorized" { + t.Fatalf("message = %q, want server detail", statusErr.Message) + } + if strings.Contains(err.Error(), "super-secret-oauth-token") { + t.Fatal("error leaked Claude Code credential") + } +} + +func TestConnectClaudeCodeFallsBackToStatusText(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `upstream broke`, http.StatusBadGateway) // non-JSON body + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + _, err := client.ConnectClaudeCode(context.Background(), `{}`) + + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusBadGateway { + t.Fatalf("expected HTTPStatusError(502), got %v", err) + } + if statusErr.Message != http.StatusText(http.StatusBadGateway) { + t.Fatalf("message = %q, want %q", statusErr.Message, http.StatusText(http.StatusBadGateway)) + } +} + +func TestConnectClaudeCodeFailsOnNonJSONSuccessBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not json`)) + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + if _, err := client.ConnectClaudeCode(context.Background(), `{}`); err == nil { + t.Fatal("expected decode error for non-JSON 200 body") + } +} + +func TestConnectClaudeCodeFailsOnInvalidBaseURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer server.Close() + + client := &APIClient{BaseURL: "http://exa mple.com", APIKey: "qm-token", HTTP: server.Client()} + _, err := client.ConnectClaudeCode(context.Background(), `{}`) + if err == nil || !strings.Contains(err.Error(), "create Claude Code connection request") { + t.Fatalf("expected request-build error, got %v", err) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index cf01963..dc0f4f2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" - "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/sysutil" ) // APIClient calls QualityMax REST API directly (no qmax CLI needed). diff --git a/internal/api/client_endpoints_test.go b/internal/api/client_endpoints_test.go new file mode 100644 index 0000000..7d5076d --- /dev/null +++ b/internal/api/client_endpoints_test.go @@ -0,0 +1,549 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/sysutil" +) + +// Wire-shape tests for the one-line endpoint wrappers on APIClient. Each +// entry pins the HTTP method, path, query string, and body subset so a +// server-side route rename fails here instead of in a user session. + +// requestCapture records the wire shape of a single inbound request: method, +// path, raw query, bearer token, and the decoded JSON body (nil for +// body-less requests). +type requestCapture struct { + Method string + Path string + RawQuery string + Auth string + Body map[string]any +} + +// recordingHandler captures the next request into got and replies with a +// generic JSON envelope. +func recordingHandler(got *requestCapture) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + *got = requestCapture{ + Method: r.Method, + Path: r.URL.Path, + RawQuery: r.URL.RawQuery, + Auth: r.Header.Get("Authorization"), + Body: body, + } + _, _ = w.Write([]byte(`{"ok":true}`)) + } +} + +// assertBodySubset checks that every entry of want matches the captured body. +func assertBodySubset(t *testing.T, got map[string]any, want map[string]any) { + t.Helper() + for k, v := range want { + if !reflect.DeepEqual(got[k], v) { + t.Errorf("body[%q] = %#v, want %#v", k, got[k], v) + } + } +} + +func assertBodyAbsent(t *testing.T, got map[string]any, keys ...string) { + t.Helper() + for _, k := range keys { + if _, ok := got[k]; ok { + t.Errorf("body[%q] should be omitted, got %#v", k, got[k]) + } + } +} + +type endpointCase struct { + name string + call func(c *APIClient) string + wantMethod string + wantPath string + wantQuery string // exact RawQuery match ("" = no query string) + wantBody map[string]any + wantAbsent []string + emptyBodyOK bool // true = a request body may be absent entirely +} + +func runEndpointTable(t *testing.T, cases []endpointCase) { + t.Helper() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got requestCapture + client, _ := newTestClient(t, recordingHandler(&got)) + + out := tc.call(client) + + if got.Method != tc.wantMethod { + t.Errorf("method = %q, want %q", got.Method, tc.wantMethod) + } + if got.Path != tc.wantPath { + t.Errorf("path = %q, want %q", got.Path, tc.wantPath) + } + if got.RawQuery != tc.wantQuery { + t.Errorf("query = %q, want %q", got.RawQuery, tc.wantQuery) + } + if len(tc.wantBody) == 0 { + if !tc.emptyBodyOK && len(got.Body) != 0 { + t.Errorf("expected no body fields, got %+v", got.Body) + } + } else { + assertBodySubset(t, got.Body, tc.wantBody) + } + assertBodyAbsent(t, got.Body, tc.wantAbsent...) + if strings.Contains(out, `"error"`) { + t.Errorf("unexpected error envelope: %s", out) + } + }) + } +} + +// ---- GET/DELETE endpoints ---- + +func TestClientGetAndDeleteEndpoints(t *testing.T) { + runEndpointTable(t, []endpointCase{ + {"ListProjects", func(c *APIClient) string { return c.ListProjects(context.Background()) }, + "GET", "/api/projects", "limit=200", nil, nil, false}, + {"ListTestCases with limit and search", func(c *APIClient) string { + return c.ListTestCases(context.Background(), 7, 10, "login") + }, "GET", "/api/test-cases", "project_id=7&limit=10&search=login", nil, nil, false}, + {"ListTestCases without optional params", func(c *APIClient) string { + return c.ListTestCases(context.Background(), 7, 0, "") + }, "GET", "/api/test-cases", "project_id=7", nil, nil, false}, + {"ListScripts with limit", func(c *APIClient) string { + return c.ListScripts(context.Background(), 3, 5) + }, "GET", "/api/automation/scripts", "project_id=3&limit=5", nil, nil, false}, + {"ListScripts without limit", func(c *APIClient) string { + return c.ListScripts(context.Background(), 3, 0) + }, "GET", "/api/automation/scripts", "project_id=3", nil, nil, false}, + {"CheckTestStatus", func(c *APIClient) string { + return c.CheckTestStatus(context.Background(), "exec-9") + }, "GET", "/api/playwright-execution/status/exec-9", "", nil, nil, false}, + {"GetExecutionArtifact", func(c *APIClient) string { + return c.GetExecutionArtifact(context.Background(), "exec-9", "video") + }, "GET", "/api/playwright-execution/artifacts/exec-9/video", "", nil, nil, false}, + {"CrawlStatus", func(c *APIClient) string { + return c.CrawlStatus(context.Background(), "crawl-1") + }, "GET", "/api/ai-crawl/status/crawl-1", "", nil, nil, false}, + {"CrawlResults", func(c *APIClient) string { + return c.CrawlResults(context.Background(), "crawl-1") + }, "GET", "/api/ai-crawl/results/crawl-1", "", nil, nil, false}, + {"ListCrawlJobs with limit", func(c *APIClient) string { + return c.ListCrawlJobs(context.Background(), 5) + }, "GET", "/api/ai-crawl/jobs", "limit=5", nil, nil, false}, + {"ListCrawlJobs without limit", func(c *APIClient) string { + return c.ListCrawlJobs(context.Background(), 0) + }, "GET", "/api/ai-crawl/jobs", "", nil, nil, false}, + {"ListRepos", func(c *APIClient) string { return c.ListRepos(context.Background(), 4) }, + "GET", "/api/repositories/project/4", "", nil, nil, false}, + {"RepoCoverage", func(c *APIClient) string { return c.RepoCoverage(context.Background(), 4) }, + "GET", "/api/repositories/4/coverage", "", nil, nil, false}, + {"RepoQuality", func(c *APIClient) string { return c.RepoQuality(context.Background(), 4) }, + "GET", "/api/repositories/4/quality", "", nil, nil, false}, + {"ListAgentSessions", func(c *APIClient) string { + return c.ListAgentSessions(context.Background(), 6, 25) + }, "GET", "/api/agent-sessions", "project_id=6&limit=25", nil, nil, false}, + {"GetScript", func(c *APIClient) string { return c.GetScript(context.Background(), 8) }, + "GET", "/api/automation/scripts/8", "", nil, nil, false}, + {"K6ListScripts", func(c *APIClient) string { return c.K6ListScripts(context.Background(), 9) }, + "GET", "/api/k6/scripts", "project_id=9", nil, nil, false}, + {"K6GetScript", func(c *APIClient) string { return c.K6GetScript(context.Background(), 10) }, + "GET", "/api/k6/scripts/10", "", nil, nil, false}, + {"K6CheckStatus", func(c *APIClient) string { + return c.K6CheckStatus(context.Background(), "k6exec-1") + }, "GET", "/api/k6/status/k6exec-1", "", nil, nil, false}, + {"K6Report", func(c *APIClient) string { + return c.K6Report(context.Background(), "k6exec-1") + }, "GET", "/api/k6/executions/k6exec-1/report", "", nil, nil, false}, + {"GetProjectSummary", func(c *APIClient) string { return c.GetProjectSummary(context.Background(), 11) }, + "GET", "/api/projects/11", "", nil, nil, false}, + {"ExportFramework with framework", func(c *APIClient) string { + return c.ExportFramework(context.Background(), 12, "pytest") + }, "GET", "/api/frameworks/12/export", "framework=pytest", nil, nil, false}, + {"ExportFramework without framework", func(c *APIClient) string { + return c.ExportFramework(context.Background(), 12, "") + }, "GET", "/api/frameworks/12/export", "", nil, nil, false}, + {"GetInstallCommand", func(c *APIClient) string { return c.GetInstallCommand(context.Background(), 12) }, + "GET", "/api/frameworks/12/install-command", "", nil, nil, false}, + {"ExportQTML", func(c *APIClient) string { return c.ExportQTML(context.Background(), 13) }, + "GET", "/api/qtml/export", "project_id=13", nil, nil, false}, + {"CheckBackgroundJob", func(c *APIClient) string { + return c.CheckBackgroundJob(context.Background(), "job-1") + }, "GET", "/api/job-health/background/job-1", "", nil, nil, false}, + {"DeleteTestCase", func(c *APIClient) string { return c.DeleteTestCase(context.Background(), 14) }, + "DELETE", "/api/test-cases/14", "", nil, nil, false}, + {"DeleteProject", func(c *APIClient) string { return c.DeleteProject(context.Background(), 15) }, + "DELETE", "/api/projects/15", "", nil, nil, false}, + }) +} + +// ---- POST/PUT endpoints with request bodies ---- + +func TestClientPostEndpoints(t *testing.T) { + hold := float64(sysutil.LiveFeedHoldSeconds) + runEndpointTable(t, []endpointCase{ + {"RunTest minimal", func(c *APIClient) string { + return c.RunTest(context.Background(), 16, false, "", "", false) + }, "POST", "/api/playwright-execution/run/16", "", + map[string]any{"headless": false, "use_browserbase": false, "live_feed_hold_seconds": hold}, + []string{"use_e2b", "browser", "base_url"}, false}, + {"RunTest with sandbox browser and baseURL", func(c *APIClient) string { + return c.RunTest(context.Background(), 16, true, "firefox", "https://staging.example.com", true) + }, "POST", "/api/playwright-execution/run/16", "", + map[string]any{"headless": true, "use_e2b": true, "browser": "firefox", + "base_url": "https://staging.example.com", "live_feed_hold_seconds": hold}, + nil, false}, + {"RunTestsBatch parses ids and skips junk", func(c *APIClient) string { + return c.RunTestsBatch(context.Background(), " 7 , 8 ,junk,9", "https://stg", true) + }, "POST", "/api/automation/execute-batch", "", + map[string]any{"script_ids": []any{float64(7), float64(8), float64(9)}, + "use_e2b": true, "base_url": "https://stg", "live_feed_hold_seconds": hold}, + nil, false}, + {"RunTestsBatch minimal", func(c *APIClient) string { + return c.RunTestsBatch(context.Background(), "5", "", false) + }, "POST", "/api/automation/execute-batch", "", + map[string]any{"script_ids": []any{float64(5)}, "live_feed_hold_seconds": hold}, + []string{"use_e2b", "base_url"}, false}, + {"ReportLocalResult", func(c *APIClient) string { + return c.ReportLocalResult(context.Background(), 17, "passed", "all green", "go_test", 1.5) + }, "POST", "/api/playwright-execution/report-local", "", + map[string]any{"script_id": float64(17), "status": "passed", "output": "all green", + "framework": "go_test", "duration": 1.5}, nil, false}, + {"StartCrawl minimal", func(c *APIClient) string { + return c.StartCrawl(context.Background(), 5, "https://site.example.com", 0, 0, "", "", false) + }, "POST", "/api/ai-crawl/start", "", + map[string]any{"project_id": float64(5), "url": "https://site.example.com", + "live_feed_hold_seconds": hold}, + []string{"use_e2b", "depth", "pages_limit", "test_type", "custom_instructions"}, false}, + {"StartCrawl with all options", func(c *APIClient) string { + return c.StartCrawl(context.Background(), 5, "https://site.example.com", 3, 10, "functional", "focus login", true) + }, "POST", "/api/ai-crawl/start", "", + map[string]any{"project_id": float64(5), "url": "https://site.example.com", "use_e2b": true, + "depth": float64(3), "pages_limit": float64(10), "test_type": "functional", + "custom_instructions": "focus login", "live_feed_hold_seconds": hold}, + nil, false}, + {"ReviewRepo", func(c *APIClient) string { return c.ReviewRepo(context.Background(), 18) }, + "POST", "/api/repositories/18/ai-review", "", nil, nil, true}, + {"GetReviewPreferences without repo", func(c *APIClient) string { + return c.GetReviewPreferences(context.Background(), 0) + }, "POST", "/api/mcp/tool/get_review_preferences", "", nil, nil, true}, + {"GetReviewPreferences with repo", func(c *APIClient) string { + return c.GetReviewPreferences(context.Background(), 19) + }, "POST", "/api/mcp/tool/get_review_preferences", "", + map[string]any{"repository_id": float64(19)}, nil, false}, + {"SetReviewPreferences global scope", func(c *APIClient) string { + return c.SetReviewPreferences(context.Background(), "global", 0, map[string]any{"focus": "security"}) + }, "POST", "/api/mcp/tool/set_review_preferences", "", + map[string]any{"scope": "global", "preferences": map[string]any{"focus": "security"}}, + []string{"repository_id"}, false}, + {"SetReviewPreferences repo scope", func(c *APIClient) string { + return c.SetReviewPreferences(context.Background(), "repo", 20, map[string]any{"strictness": "high"}) + }, "POST", "/api/mcp/tool/set_review_preferences", "", + map[string]any{"scope": "repo", "repository_id": float64(20), + "preferences": map[string]any{"strictness": "high"}}, nil, false}, + {"ImportDocument with source name", func(c *APIClient) string { + return c.ImportDocument(context.Background(), 21, "release notes", "notes.md") + }, "POST", "/api/import/document/text", "", + map[string]any{"project_id": float64(21), "text_content": "release notes", + "source_name": "notes.md"}, nil, false}, + {"ImportDocument without source name", func(c *APIClient) string { + return c.ImportDocument(context.Background(), 21, "release notes", "") + }, "POST", "/api/import/document/text", "", + map[string]any{"project_id": float64(21), "text_content": "release notes"}, + []string{"source_name"}, false}, + {"CreatePR", func(c *APIClient) string { return c.CreatePR(context.Background(), 22, 23) }, + "POST", "/api/repositories/create-pr", "", + map[string]any{"repo_id": float64(22), "project_id": float64(23)}, nil, false}, + {"SecurityAuditPRCheck", func(c *APIClient) string { + return c.SecurityAuditPRCheck(context.Background(), "acme/app", 7, "sha-base", "sha-head") + }, "POST", "/api/security-audit/pr-check", "", + map[string]any{"repo_slug": "acme/app", "pr_number": float64(7), + "base_sha": "sha-base", "head_sha": "sha-head"}, nil, false}, + {"GenerateTestCode with force", func(c *APIClient) string { + return c.GenerateTestCode(context.Background(), 100, true, "rust_cargo") + }, "POST", "/api/automation/generate", "", + map[string]any{"test_case_id": float64(100), "force": true, "framework": "rust_cargo"}, nil, false}, + {"SetupCICD with baseURL", func(c *APIClient) string { + return c.SetupCICD(context.Background(), 1, "rust", "main", "https://stg.example.com") + }, "POST", "/api/repositories/1/setup-cicd", "", + map[string]any{"framework": "rust", "target_branch": "main", + "base_url": "https://stg.example.com"}, nil, false}, + {"ImportRepo with all options", func(c *APIClient) string { + return c.ImportRepo(context.Background(), "https://github.com/acme/app", 35, true, "New Project", "https://stg", "opt_in") + }, "POST", "/api/repositories/import", "", + map[string]any{"repo_url": "https://github.com/acme/app", "project_id": float64(35), + "create_new_project": true, "new_project_name": "New Project", + "base_url": "https://stg", "training_consent": "opt_in"}, nil, false}, + {"K6CreateScript without code", func(c *APIClient) string { + return c.K6CreateScript(context.Background(), 25, "smoke", "load", "https://api.example.com", "") + }, "POST", "/api/k6/scripts", "", + map[string]any{"project_id": float64(25), "name": "smoke", "test_type": "load", + "target_url": "https://api.example.com"}, []string{"code"}, false}, + {"K6CreateScript with code", func(c *APIClient) string { + return c.K6CreateScript(context.Background(), 25, "smoke", "load", "https://api.example.com", "import http from 'k6/http'") + }, "POST", "/api/k6/scripts", "", + map[string]any{"project_id": float64(25), "name": "smoke", "test_type": "load", + "target_url": "https://api.example.com", "code": "import http from 'k6/http'"}, nil, false}, + {"K6RunTest minimal", func(c *APIClient) string { + return c.K6RunTest(context.Background(), 26, 0, "") + }, "POST", "/api/k6/run/26", "", + map[string]any{"script_id": float64(26)}, []string{"vus", "duration"}, false}, + {"K6RunTest with vus and duration", func(c *APIClient) string { + return c.K6RunTest(context.Background(), 26, 5, "30s") + }, "POST", "/api/k6/run/26", "", + map[string]any{"script_id": float64(26), "vus": float64(5), "duration": "30s"}, nil, false}, + {"K6Generate without endpoints", func(c *APIClient) string { + return c.K6Generate(context.Background(), 27, "https://api.example.com", "load", "") + }, "POST", "/api/k6/generate", "", + map[string]any{"project_id": float64(27), "target_url": "https://api.example.com", + "test_type": "load"}, []string{"endpoints"}, false}, + {"K6Generate with endpoints", func(c *APIClient) string { + return c.K6Generate(context.Background(), 27, "https://api.example.com", "load", "/api/a,/api/b") + }, "POST", "/api/k6/generate", "", + map[string]any{"project_id": float64(27), "target_url": "https://api.example.com", + "test_type": "load", "endpoints": "/api/a,/api/b"}, nil, false}, + {"K6Convert", func(c *APIClient) string { + return c.K6Convert(context.Background(), "const x = 1", "playwright", "spike") + }, "POST", "/api/k6/convert", "", + map[string]any{"source_code": "const x = 1", "source_framework": "playwright", + "test_type": "spike"}, nil, false}, + {"CreateProject generates key from name", func(c *APIClient) string { + return c.CreateProject(context.Background(), "My Project 42!", "demo project", "https://acme.example.com") + }, "POST", "/api/projects", "", + map[string]any{"name": "My Project 42!", "key": "MYPROJECT4", "description": "demo project", + "main_url": "https://acme.example.com"}, nil, false}, + {"CreateProject falls back to PROJ key", func(c *APIClient) string { + return c.CreateProject(context.Background(), "!!", "", "") + }, "POST", "/api/projects", "", + map[string]any{"name": "!!", "key": "PROJ"}, []string{"description", "main_url"}, false}, + {"TriggerFrameworkRun with type", func(c *APIClient) string { + return c.TriggerFrameworkRun(context.Background(), 27, "pytest") + }, "POST", "/api/frameworks/27/run", "", + map[string]any{"framework_type": "pytest"}, nil, false}, + {"TriggerFrameworkRun without type", func(c *APIClient) string { + return c.TriggerFrameworkRun(context.Background(), 27, "") + }, "POST", "/api/frameworks/27/run", "", nil, nil, true}, + {"AddScriptToFramework", func(c *APIClient) string { + return c.AddScriptToFramework(context.Background(), 27, 29) + }, "POST", "/api/frameworks/27/add-script", "", + map[string]any{"script_id": float64(29)}, nil, false}, + {"EnhanceTestCase", func(c *APIClient) string { return c.EnhanceTestCase(context.Background(), 30) }, + "POST", "/api/test-cases/30/enhance", "", nil, nil, true}, + {"GenerateGapTests", func(c *APIClient) string { return c.GenerateGapTests(context.Background(), 31) }, + "POST", "/api/repositories/31/generate-gap-tests", "", nil, nil, true}, + {"StartCrawlFromTestCase", func(c *APIClient) string { return c.StartCrawlFromTestCase(context.Background(), 32) }, + "POST", "/api/ai-crawl/start-from-test-case/32", "", nil, nil, true}, + {"ImportQTML", func(c *APIClient) string { + return c.ImportQTML(context.Background(), 33, "") + }, "POST", "/api/qtml/import", "", + map[string]any{"project_id": float64(33), "content": ""}, nil, false}, + {"ConvertQTMLToPlaywright", func(c *APIClient) string { + return c.ConvertQTMLToPlaywright(context.Background(), "") + }, "POST", "/api/qtml/convert-to-playwright", "", + map[string]any{"content": ""}, nil, false}, + {"TestDeployedEnvironment", func(c *APIClient) string { + return c.TestDeployedEnvironment(context.Background(), 34, "https://deploy.example.com") + }, "POST", "/api/automation/test-deployed", "", + map[string]any{"project_id": float64(34), "url": "https://deploy.example.com"}, nil, false}, + }) +} + +func TestClientPutEndpoints(t *testing.T) { + runEndpointTable(t, []endpointCase{ + {"UpdateScript", func(c *APIClient) string { + return c.UpdateScript(context.Background(), 24, "renamed", "new code") + }, "PUT", "/api/automation/scripts/24", "", + map[string]any{"name": "renamed", "code": "new code"}, nil, false}, + {"UpdateProject all fields", func(c *APIClient) string { + return c.UpdateProject(context.Background(), 28, "renamed", "updated", "https://new.example.com") + }, "PUT", "/api/projects/28", "", + map[string]any{"name": "renamed", "description": "updated", + "base_url": "https://new.example.com"}, nil, false}, + {"UpdateTestCase all fields", func(c *APIClient) string { + return c.UpdateTestCase(context.Background(), 36, "new title", "new desc", "smoke", "high", "passed") + }, "PUT", "/api/test-cases/36", "", + map[string]any{"title": "new title", "description": "new desc", "category": "smoke", + "priority": float64(2), "status": "passed"}, nil, false}, + }) +} + +// ---- NewAPIClient ---- + +func TestNewAPIClient_NilAuthReturnsNil(t *testing.T) { + if NewAPIClient(nil) != nil { + t.Error("NewAPIClient(nil) should return nil") + } +} + +func TestNewAPIClient_UsesAuthConfig(t *testing.T) { + auth := &AuthConfig{APIKey: "qm-key", CloudURL: "https://cloud.example.com/"} + c := NewAPIClient(auth) + if c == nil { + t.Fatal("expected client") + } + if c.BaseURL != "https://cloud.example.com" { + t.Errorf("BaseURL = %q, want trailing slash trimmed", c.BaseURL) + } + if c.APIKey != "qm-key" { + t.Errorf("APIKey = %q, want qm-key", c.APIKey) + } + if c.HTTP == nil { + t.Error("HTTP client must be initialized") + } +} + +// ---- GetProjectBySlug fallback chain ---- + +func TestGetProjectBySlug_ExactHitMakesSingleRequest(t *testing.T) { + calls := 0 + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = w.Write([]byte(`{"project":{"id":1,"slug":"acme"}}`)) + }) + + out := client.GetProjectBySlug(context.Background(), "acme") + + if calls != 1 { + t.Errorf("exact slug hit should need exactly 1 request, got %d", calls) + } + if !strings.Contains(out, `"slug":"acme"`) { + t.Errorf("expected project payload, got %q", out) + } +} + +func TestGetProjectBySlug_FuzzyFallbackMatchesName(t *testing.T) { + var bySlugCalls []string + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/projects/by-slug/"): + bySlugCalls = append(bySlugCalls, r.URL.Path) + if r.URL.Path == "/api/projects/by-slug/acme-app" { + _, _ = w.Write([]byte(`{"project":{"id":3,"slug":"acme-app"}}`)) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Project not found"}`)) + case r.URL.Path == "/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":2,"name":"Other","key":"OTH","slug":"other"},{"id":3,"name":"Acme App","key":"ACME","slug":"acme-app"}]}`)) + default: + http.NotFound(w, r) + } + }) + + out := client.GetProjectBySlug(context.Background(), "acme") + + want := []string{"/api/projects/by-slug/acme", "/api/projects/by-slug/acme-app"} + if !reflect.DeepEqual(bySlugCalls, want) { + t.Errorf("by-slug calls = %v, want %v", bySlugCalls, want) + } + if !strings.Contains(out, `"slug":"acme-app"`) { + t.Errorf("expected fuzzy-matched project, got %q", out) + } +} + +func TestGetProjectBySlug_FuzzyFallbackMatchesKey(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/projects/by-slug/ACME": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Project not found"}`)) + case r.URL.Path == "/api/projects/by-slug/acme-app": + _, _ = w.Write([]byte(`{"project":{"id":3,"slug":"acme-app"}}`)) + case r.URL.Path == "/api/projects": + _, _ = w.Write([]byte(`{"projects":[{"id":3,"name":"Unrelated Name","key":"ACME","slug":"acme-app"}]}`)) + default: + http.NotFound(w, r) + } + }) + + out := client.GetProjectBySlug(context.Background(), "ACME") + if !strings.Contains(out, `"slug":"acme-app"`) { + t.Errorf("expected key-matched project, got %q", out) + } +} + +func TestGetProjectBySlug_NoFuzzyMatchReturnsOriginal404(t *testing.T) { + total := 0 + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + total++ + switch { + case strings.HasPrefix(r.URL.Path, "/api/projects/by-slug/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Project not found"}`)) + default: + _, _ = w.Write([]byte(`{"projects":[{"id":9,"name":"Zebras","key":"ZEB","slug":"zebras"}]}`)) + } + }) + + out := client.GetProjectBySlug(context.Background(), "acme") + + if total != 2 { + t.Errorf("expected by-slug + list requests only, got %d", total) + } + if !strings.Contains(out, "404") { + t.Errorf("expected original 404 preserved, got %q", out) + } +} + +func TestGetProjectBySlug_UnparseableListReturnsOriginal404(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/projects/by-slug/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Project not found"}`)) + default: + _, _ = w.Write([]byte(`not json at all`)) + } + }) + + out := client.GetProjectBySlug(context.Background(), "acme") + if !strings.Contains(out, "404") { + t.Errorf("expected original 404 when list is not JSON, got %q", out) + } +} + +// ---- HTTP helper error branches ---- + +func TestHTTPHelpersReturnJSONErrorOnInvalidURL(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {}) + client.BaseURL = "http://exa mple.com" // space forces httpx.NewRequest parse failure + + calls := map[string]func() string{ + "get": func() string { return client.get(context.Background(), "/x") }, + "post": func() string { return client.post(context.Background(), "/x", map[string]any{}) }, + "put": func() string { return client.put(context.Background(), "/x", map[string]any{}) }, + "patch": func() string { return client.patch(context.Background(), "/x", map[string]any{}) }, + "delete": func() string { return client.delete(context.Background(), "/x") }, + } + for name, fn := range calls { + if out := fn(); !strings.HasPrefix(out, `{"error":`) { + t.Errorf("%s: expected JSON error envelope, got %q", name, out) + } + } +} + +func TestDoRequest_ReturnsJSONErrorOnTransportFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + client := &APIClient{BaseURL: srv.URL, APIKey: "qm-test-key", HTTP: srv.Client()} + out := client.ListProjects(context.Background()) + + if !strings.HasPrefix(out, `{"error":`) { + t.Errorf("expected JSON error envelope, got %q", out) + } + if !strings.Contains(out, "request failed") { + t.Errorf("expected transport error detail, got %q", out) + } +} diff --git a/internal/api/codex_connection.go b/internal/api/codex_connection.go index db73156..80a0c46 100644 --- a/internal/api/codex_connection.go +++ b/internal/api/codex_connection.go @@ -8,8 +8,8 @@ import ( "io" "net/http" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" ) // CodexConnection is the non-sensitive connection state returned by QualityMax. diff --git a/internal/api/config_extra_test.go b/internal/api/config_extra_test.go new file mode 100644 index 0000000..72001a1 --- /dev/null +++ b/internal/api/config_extra_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Additional config.go coverage: narration normalization, Cerebras key +// validation, env overrides, and failure branches. Tests that mutate HOME or +// PATH must not run in parallel. + +// emptyPATH replaces PATH with an empty temp dir for the duration of the +// test so OS-keychain helper binaries (security, secret-tool) cannot be +// resolved. This lets us exercise the keychain failure branches without +// touching the real OS keychain. +func emptyPATH(t *testing.T) { + t.Helper() + t.Setenv("PATH", t.TempDir()) +} + +func TestNormalizeNarrateToolCalls(t *testing.T) { + cases := []struct{ in, want string }{ + {"off", "off"}, + {"NONE", "off"}, // case-insensitive + {" Silent ", "off"}, // trimmed aliases + {"full", "full"}, + {"VERBOSE", "full"}, + {"detailed", "full"}, + {"brief", "brief"}, + {"", "brief"}, // empty falls back to default + {"nonsense", "brief"}, + } + for _, tc := range cases { + if got := NormalizeNarrateToolCalls(tc.in); got != tc.want { + t.Errorf("NormalizeNarrateToolCalls(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestValidateCerebrasKey(t *testing.T) { + cases := []struct { + key string + wantLooks bool + wantErr bool + }{ + {"", false, true}, + {"csk-abc123", true, false}, + {"sk-proj-xyz", false, false}, // wrong prefix: soft signal, not an error + {"csk-abc 123", false, true}, // embedded space + {"csk-abc\t123", false, true}, // tab + {"csk-bad\x01key", false, true}, // non-printable byte + } + for _, tc := range cases { + looks, err := ValidateCerebrasKey(tc.key) + if looks != tc.wantLooks { + t.Errorf("ValidateCerebrasKey(%q) looks = %v, want %v", tc.key, looks, tc.wantLooks) + } + if (err != nil) != tc.wantErr { + t.Errorf("ValidateCerebrasKey(%q) err = %v, want error %v", tc.key, err, tc.wantErr) + } + } +} + +func TestLoadQMaxCodeConfig_EnvOverridesFileAndKeychain(t *testing.T) { + emptyPATH(t) + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, QmaxCodeConfigDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + // File values must lose to env vars for Ollama and Cerebras. + content := `{"ollama_url":"http://from-file:11434","cerebras_base_url":"http://file"}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OLLAMA_BASE_URL", "http://env-ollama:11434") + t.Setenv("OLLAMA_MODEL", "gemma3:4b") + t.Setenv("CEREBRAS_API_KEY", "csk-env-key") + t.Setenv("CEREBRAS_API_BASE", "http://env-cerebras") + t.Setenv("CEREBRAS_MODEL", "gpt-oss-120b") + t.Setenv("CEREBRAS_REASONING_EFFORT", "high") + + cfg := LoadQMaxCodeConfig() + if cfg.OllamaURL != "http://env-ollama:11434" { + t.Errorf("OllamaURL = %q, want env value to win", cfg.OllamaURL) + } + if cfg.OllamaModel != "gemma3:4b" { + t.Errorf("OllamaModel = %q", cfg.OllamaModel) + } + if cfg.CerebrasKey != "csk-env-key" { + t.Errorf("CerebrasKey = %q, want env value", cfg.CerebrasKey) + } + if cfg.CerebrasBaseURL != "http://env-cerebras" { + t.Errorf("CerebrasBaseURL = %q", cfg.CerebrasBaseURL) + } + if cfg.CerebrasModel != "gpt-oss-120b" { + t.Errorf("CerebrasModel = %q", cfg.CerebrasModel) + } + if cfg.CerebrasReasoningEffort != "high" { + t.Errorf("CerebrasReasoningEffort = %q", cfg.CerebrasReasoningEffort) + } +} + +func TestLoadQMaxCodeConfig_MergesFileOverDefaults(t *testing.T) { + emptyPATH(t) + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, QmaxCodeConfigDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"default_model":"haiku","backend":"cc","local_only":true}`), 0o600); err != nil { + t.Fatal(err) + } + + cfg := LoadQMaxCodeConfig() + if cfg.DefaultModel != "haiku" { + t.Errorf("DefaultModel = %q, want haiku from file", cfg.DefaultModel) + } + if cfg.Backend != "cc" { + t.Errorf("Backend = %q, want cc", cfg.Backend) + } + if !cfg.LocalOnly { + t.Error("LocalOnly should be true") + } + // Unset file fields keep the built-in defaults. + if !cfg.AutoSave { + t.Error("AutoSave should stay true when the file omits it") + } + if cfg.MaxTokenBudget != 200000 { + t.Errorf("MaxTokenBudget = %d, want default 200000", cfg.MaxTokenBudget) + } +} + +func TestLoadQMaxCodeConfig_HomeUnsetKeepsDefaults(t *testing.T) { + emptyPATH(t) + t.Setenv("HOME", "") + cfg := LoadQMaxCodeConfig() + if cfg.DefaultModel != "auto" || !cfg.AutoSave || cfg.MaxTokenBudget != 200000 || cfg.NarrateToolCalls != "brief" { + t.Error("defaults not preserved without HOME") + } +} + +func TestConfigSave_FailsWhenConfigDirIsAFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Occupy the ~/.qmax-code path with a regular file so MkdirAll fails. + if err := os.WriteFile(filepath.Join(home, QmaxCodeConfigDir), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := (&Config{}).Save(); err == nil { + t.Error("expected error when config dir cannot be created") + } +} + +func TestConfigSaveRoundTripPreservesFields(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := &Config{ + AnthropicKey: "fixture-anthropic-key", + CerebrasKey: "fixture-cerebras-key", + DefaultModel: "sonnet", + Backend: "opencode", + Theme: "ocean", + EnabledProviders: []string{"groq", "openrouter"}, + CloudSync: func(b bool) *bool { return &b }(true), + CerebrasReasoningEffort: "medium", + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + data, err := os.ReadFile(filepath.Join(home, QmaxCodeConfigDir, "config.json")) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("saved config not valid JSON: %v", err) + } + for key, value := range raw { + if strings.Contains(strings.ToLower(key), "key") || value == cfg.AnthropicKey || value == cfg.CerebrasKey { + t.Fatal("API credentials must not be persisted in config JSON") + } + } + if !strings.Contains(string(data), `"cerebras_reasoning_effort": "medium"`) { + t.Errorf("reasoning effort not persisted: %s", data) + } +} + +func TestSaveAnthropicAndCerebrasKeysFailWithoutKeychainHelper(t *testing.T) { + // The key cannot be stored when the OS keychain helper is unavailable; + // the wrappers must surface that error rather than dropping it. + emptyPATH(t) + if err := SaveAnthropicKey("sk-test"); err == nil { + t.Error("SaveAnthropicKey should fail without a keychain helper") + } + if err := SaveCerebrasKey("csk-test"); err == nil { + t.Error("SaveCerebrasKey should fail without a keychain helper") + } +} diff --git a/internal/api/context.go b/internal/api/context.go index 0d826c2..0de1b56 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -63,40 +63,71 @@ type GitInfo struct { // TokenUsage tracks cumulative token usage across the session. type TokenUsage struct { - InputTokens int - OutputTokens int - Requests int + InputTokens int + OutputTokens int + Requests int + CacheReadInputTokens int `json:",omitempty"` + CacheCreationInputTokens int `json:",omitempty"` + CostUSD *float64 `json:",omitempty"` +} + +// TotalInputTokens includes both uncached and cached prompt tokens. +func (u *TokenUsage) TotalInputTokens() int { + return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens } // TotalTokens returns the total token count. func (u *TokenUsage) TotalTokens() int { - return u.InputTokens + u.OutputTokens + return u.TotalInputTokens() + u.OutputTokens } -// EstimatedCost returns estimated cost in USD, based on standard per-MTok API -// rates (https://platform.claude.com/docs/en/about-claude/pricing). The 1M -// context window bills at these same standard rates, so a flat rate per model -// is correct. Pricing: Fable 5 input=$10/MTok output=$50/MTok, -// Opus 4.6/4.7/4.8/5.5 input=$5/MTok output=$25/MTok, Sonnet 4.6 input=$3/MTok -// output=$15/MTok, Sonnet 5 intro $2/$10 through Aug 31 2026 then $3/$15, -// Haiku 4.5 input=$1/MTok output=$5/MTok. +// RecordRequest retains each request's estimated API cost before totals lose +// its model, pricing date, and prompt-size tier. Legacy sessions without costs +// use their aggregate estimate for the existing portion of the session. +func (u *TokenUsage) RecordRequest(model string, usage APIUsage) { + request := TokenUsage{ + InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, + } + cost := u.EstimatedCost(model) + request.EstimatedCost(model) + u.CostUSD = &cost + u.InputTokens += usage.InputTokens + u.OutputTokens += usage.OutputTokens + u.CacheReadInputTokens += usage.CacheReadInputTokens + u.CacheCreationInputTokens += usage.CacheCreationInputTokens + u.Requests++ +} + +// EstimatedCost returns recorded per-request estimates when available. Legacy +// aggregate-only usage is estimated using the supplied model and current rates. +// Cache writes use the five-minute rate; CLI plan billing is provider-owned. func (u *TokenUsage) EstimatedCost(model string) float64 { return u.estimatedCostAt(model, time.Now()) } func (u *TokenUsage) estimatedCostAt(model string, now time.Time) float64 { + if u.CostUSD != nil { + return *u.CostUSD + } var inputRate, outputRate float64 switch { case strings.Contains(model, "fable"): inputRate, outputRate = 10.0, 50.0 case strings.Contains(model, "opus"): inputRate, outputRate = 5.0, 25.0 + case model == ModelHaiku55: + inputRate, outputRate = 0.10, 0.50 + if u.TotalInputTokens() > 100_000 { + inputRate, outputRate = 0.50, 2.50 + } case strings.Contains(model, "haiku"): inputRate, outputRate = 1.0, 5.0 default: // sonnet inputRate, outputRate = sonnetCostRates(model, now) } - return (float64(u.InputTokens)/1_000_000)*inputRate + (float64(u.OutputTokens)/1_000_000)*outputRate + input := float64(u.InputTokens) + float64(u.CacheReadInputTokens)*0.1 + float64(u.CacheCreationInputTokens)*1.25 + return (input/1_000_000)*inputRate + (float64(u.OutputTokens)/1_000_000)*outputRate } func sonnetCostRates(model string, now time.Time) (inputRate, outputRate float64) { diff --git a/internal/api/context_discovery_test.go b/internal/api/context_discovery_test.go new file mode 100644 index 0000000..b8084e7 --- /dev/null +++ b/internal/api/context_discovery_test.go @@ -0,0 +1,308 @@ +package api + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Tests for the discovery/context helpers in context.go that were previously +// uncovered. These tests mutate HOME, PATH, and the process working +// directory — they must not run in parallel. + +// chdirTemp switches the test process into a fresh temp dir and restores the +// original working directory on cleanup. +func chdirTemp(t *testing.T) string { + t.Helper() + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) + return dir +} + +// ---- TokenUsage ---- + +func TestTokenUsageTotalTokens(t *testing.T) { + u := TokenUsage{InputTokens: 100, OutputTokens: 50, Requests: 3} + if got := u.TotalTokens(); got != 150 { + t.Errorf("TotalTokens() = %d, want 150", got) + } +} + +func TestEstimatedCostUsesOpusRates(t *testing.T) { + // The configured Opus rates have no date-based promotion, so this result + // does not depend on time.Now() inside EstimatedCost. + u := TokenUsage{InputTokens: 1_000_000, OutputTokens: 1_000_000} + if got := u.EstimatedCost("claude-opus-4-8"); got != 30.0 { + t.Errorf("EstimatedCost(opus) = %v, want 30.0", got) + } +} + +// ---- LoadQMaxConfig ---- + +func TestLoadQMaxConfig_ReadsLegacyFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".qamax"), 0o700); err != nil { + t.Fatal(err) + } + content := `{"api_url":"https://q.example.com","token":"tok","email":"e@example.com","agent_id":"a1","api_key":"ak"}` + if err := os.WriteFile(filepath.Join(home, ".qamax", "config.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + cfg := LoadQMaxConfig() + if cfg.CloudURL != "https://q.example.com" || cfg.Token != "tok" || + cfg.Email != "e@example.com" || cfg.AgentID != "a1" || cfg.APIKey != "ak" { + t.Errorf("LoadQMaxConfig() = %+v", cfg) + } +} + +func TestLoadQMaxConfig_MissingFileReturnsZero(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if cfg := LoadQMaxConfig(); cfg != (QMaxConfig{}) { + t.Errorf("expected zero config for missing file, got %+v", cfg) + } +} + +func TestLoadQMaxConfig_InvalidJSONReturnsZero(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".qamax"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".qamax", "config.json"), []byte(`{invalid`), 0o600); err != nil { + t.Fatal(err) + } + if cfg := LoadQMaxConfig(); cfg != (QMaxConfig{}) { + t.Errorf("expected zero config for invalid JSON, got %+v", cfg) + } +} + +// ---- DiscoverQMaxBinary ---- + +func TestDiscoverQMaxBinary_NoneFound(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + chdirTemp(t) + + if got := DiscoverQMaxBinary(); got != "" { + t.Errorf("DiscoverQMaxBinary() = %q, want empty when nothing exists", got) + } +} + +func TestDiscoverQMaxBinary_FromCwd(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PATH", t.TempDir()) + dir := chdirTemp(t) + if err := os.WriteFile(filepath.Join(dir, "qmax"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if got := DiscoverQMaxBinary(); got != "./qmax" { + t.Errorf("DiscoverQMaxBinary() = %q, want ./qmax", got) + } +} + +func TestDiscoverQMaxBinary_FromHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) + chdirTemp(t) + + want := filepath.Join(home, ".qamax", "qmax") + if err := os.MkdirAll(filepath.Join(home, ".qamax"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if got := DiscoverQMaxBinary(); got != want { + t.Errorf("DiscoverQMaxBinary() = %q, want %q", got, want) + } +} + +func TestDiscoverQMaxBinary_FromPATH(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + bin := t.TempDir() + t.Setenv("PATH", bin) + chdirTemp(t) + + want := filepath.Join(bin, "qmax") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if got := DiscoverQMaxBinary(); got != want { + t.Errorf("DiscoverQMaxBinary() = %q, want %q", got, want) + } +} + +func TestDiscoverQMaxBinary_CwdWinsOverHomeAndPATH(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + bin := t.TempDir() + t.Setenv("PATH", bin) + dir := chdirTemp(t) + + for _, p := range []string{ + filepath.Join(home, ".qamax", "qmax"), + filepath.Join(bin, "qmax"), + filepath.Join(dir, "qmax"), + } { + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + + if got := DiscoverQMaxBinary(); got != "./qmax" { + t.Errorf("DiscoverQMaxBinary() = %q, want ./qmax to take precedence", got) + } +} + +// ---- ProbeQMaxStatus ---- + +func TestProbeQMaxStatus_EmptyBinary(t *testing.T) { + if got := ProbeQMaxStatus(""); got != "" { + t.Errorf("ProbeQMaxStatus(\"\") = %q, want empty", got) + } +} + +func TestProbeQMaxStatus_MissingBinaryReturnsEmpty(t *testing.T) { + if got := ProbeQMaxStatus("/nonexistent/qmax-binary"); got != "" { + t.Errorf("ProbeQMaxStatus(missing) = %q, want empty", got) + } +} + +func TestProbeQMaxStatus_RunsBinaryAndTrimsOutput(t *testing.T) { + if _, err := os.Stat("/bin/echo"); err != nil { + t.Skip("/bin/echo not available") + } + // `echo status` prints "status\n"; the probe must trim the newline. + if got := ProbeQMaxStatus("/bin/echo"); got != "status" { + t.Errorf("ProbeQMaxStatus(echo) = %q, want status", got) + } +} + +func TestProbeQMaxStatus_FailingBinaryReturnsEmpty(t *testing.T) { + if _, err := os.Stat("/bin/false"); err != nil { + t.Skip("/bin/false not available") + } + if got := ProbeQMaxStatus("/bin/false"); got != "" { + t.Errorf("ProbeQMaxStatus(false) = %q, want empty on non-zero exit", got) + } +} + +// ---- FormatQMaxInstallHint ---- + +func TestFormatQMaxInstallHint(t *testing.T) { + hint := FormatQMaxInstallHint() + if !strings.Contains(hint, "qmax CLI not found") || !strings.Contains(hint, "https://get.qualitymax.io/cli") { + t.Errorf("install hint missing expected text: %q", hint) + } +} + +// ---- DetectProjectFromCwd ---- + +func TestDetectProjectFromCwd_NoFile(t *testing.T) { + chdirTemp(t) + if id, file := DetectProjectFromCwd(); id != 0 || file != "" { + t.Errorf("DetectProjectFromCwd() = %d, %q; want 0, empty", id, file) + } +} + +func TestDetectProjectFromCwd_QmaxYml(t *testing.T) { + dir := chdirTemp(t) + if err := os.WriteFile(filepath.Join(dir, ".qmax.yml"), []byte("project_id: 42\nname: demo\n"), 0o600); err != nil { + t.Fatal(err) + } + if id, file := DetectProjectFromCwd(); id != 42 || file != ".qmax.yml" { + t.Errorf("DetectProjectFromCwd() = %d, %q; want 42, .qmax.yml", id, file) + } +} + +func TestDetectProjectFromCwd_QualitymaxYamlHyphenated(t *testing.T) { + dir := chdirTemp(t) + if err := os.WriteFile(filepath.Join(dir, ".qualitymax.yaml"), []byte("project-id: 7\n"), 0o600); err != nil { + t.Fatal(err) + } + if id, file := DetectProjectFromCwd(); id != 7 || file != ".qualitymax.yaml" { + t.Errorf("DetectProjectFromCwd() = %d, %q; want 7, .qualitymax.yaml", id, file) + } +} + +func TestDetectProjectFromCwd_InvalidIDReturnsZero(t *testing.T) { + dir := chdirTemp(t) + if err := os.WriteFile(filepath.Join(dir, ".qmax.yml"), []byte("project_id: not-a-number\n"), 0o600); err != nil { + t.Fatal(err) + } + if id, file := DetectProjectFromCwd(); id != 0 || file != "" { + t.Errorf("DetectProjectFromCwd() = %d, %q; want 0, empty for unparseable id", id, file) + } +} + +// ---- DetectGitInfo ---- + +func TestDetectGitInfo_InTempRepo(t *testing.T) { + dir := chdirTemp(t) + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) + t.Setenv("GIT_CONFIG_COUNT", "0") + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-q") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("one\n"), 0o600); err != nil { + t.Fatal(err) + } + run("add", "file.txt") + run("-c", "commit.gpgsign=false", "commit", "-q", "-m", "init") + run("remote", "add", "origin", "https://github.com/acme/app.git") + // Leave one uncommitted change so ChangedFiles is populated. + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("one\ntwo\n"), 0o600); err != nil { + t.Fatal(err) + } + + info := DetectGitInfo() + if info == nil { + t.Fatal("expected GitInfo inside a git repo") + } + if info.Branch == "" { + t.Error("Branch should be detected") + } + if info.RemoteURL != "https://github.com/acme/app.git" { + t.Errorf("RemoteURL = %q", info.RemoteURL) + } + if info.RecentCommits == "" { + t.Error("RecentCommits should be detected after a commit") + } + if len(info.ChangedFiles) != 1 || info.ChangedFiles[0] != "file.txt" { + t.Errorf("ChangedFiles = %v, want [file.txt]", info.ChangedFiles) + } +} + +func TestDetectGitInfo_OutsideRepoReturnsNil(t *testing.T) { + chdirTemp(t) // fresh temp dir is not inside a work tree + if info := DetectGitInfo(); info != nil { + t.Errorf("expected nil outside a repo, got %+v", info) + } +} diff --git a/internal/api/context_test.go b/internal/api/context_test.go index 3dea0b5..5f919c2 100644 --- a/internal/api/context_test.go +++ b/internal/api/context_test.go @@ -11,9 +11,8 @@ import ( // https://platform.claude.com/docs/en/about-claude/pricing — Opus 4.6+ is // $5/$25 (not the retired Opus 4.1 $15/$75), Haiku 4.5 is $1/$5 (not the // retired Haiku 3 $0.25/$1.25), Fable 5 is $10/$50, Sonnet 4.6 is $3/$15, -// and Sonnet 5 is $2/$10 intro through Aug 31 2026 then $3/$15. The 1M -// context window bills at these same rates, so model substring alone -// determines the rate. +// and Sonnet 5 is $2/$10 intro through Aug 31 2026 then $3/$15. +// Haiku 5.5 uses the long-prompt tier above 100k input tokens. func TestEstimatedCost(t *testing.T) { introSonnet5 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) standardSonnet5 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) @@ -33,6 +32,7 @@ func TestEstimatedCost(t *testing.T) { {"claude-sonnet-5", standardSonnet5, 3.0 + 15.0}, {"claude-sonnet-4-6", introSonnet5, 3.0 + 15.0}, {"claude-haiku-4-5-20251001", introSonnet5, 1.0 + 5.0}, + {ModelHaiku55, standardSonnet5, 0.50 + 2.50}, {"auto", introSonnet5, 3.0 + 15.0}, // unknown → sonnet default } for _, c := range cases { diff --git a/internal/api/haiku55_test.go b/internal/api/haiku55_test.go new file mode 100644 index 0000000..f83e20f --- /dev/null +++ b/internal/api/haiku55_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "encoding/json" + "math" + "strings" + "testing" +) + +func TestHaiku55ExplicitSelectionPreservesDefaults(t *testing.T) { + if !IsValidClaudeModelName(ModelHaiku55) || ResolveClaudeModel(ModelHaiku55) != ModelHaiku55 { + t.Fatal("Haiku 5.5 must be selectable by its full ID") + } + if ResolveClaudeModel("haiku") != "claude-haiku-4-5-20251001" || ModelHaiku != "claude-haiku-4-5-20251001" { + t.Fatal("the Haiku shorthand must stay on the verified default") + } + if !strings.Contains(ValidClaudeModelsHelp(), ModelHaiku55) { + t.Fatal("model help is missing Haiku 5.5") + } +} + +func TestHaiku55RequestSettings(t *testing.T) { + for _, model := range []string{ModelHaiku55, ModelHaiku, ModelSonnet} { + t.Run(model, func(t *testing.T) { + data, err := json.Marshal(APIRequest{Model: model, Thinking: DefaultThinking(model)}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if model == ModelHaiku55 { + if string(fields["thinking"]) != `{"type":"disabled"}` { + t.Fatalf("thinking = %s", fields["thinking"]) + } + } else if _, ok := fields["thinking"]; ok { + t.Fatal("existing models must retain their request settings") + } + for _, key := range []string{"temperature", "top_p", "top_k"} { + if _, ok := fields[key]; ok { + t.Fatalf("unsupported sampling parameter %s", key) + } + } + }) + } +} + +func TestAPIUsageContextIncludesCachedPromptTokens(t *testing.T) { + for _, tt := range []struct { + name, response string + want int + }{ + {"empty", `{}`, 0}, + {"uncached", `{"input_tokens":120,"output_tokens":40}`, 120}, + {"cached only", `{"cache_read_input_tokens":900,"cache_creation_input_tokens":100}`, 1000}, + {"mixed", `{"input_tokens":20,"cache_read_input_tokens":900,"cache_creation_input_tokens":100,"output_tokens":40}`, 1020}, + } { + t.Run(tt.name, func(t *testing.T) { + var usage APIUsage + if err := json.Unmarshal([]byte(tt.response), &usage); err != nil { + t.Fatal(err) + } + if got := usage.TotalInputTokens(); got != tt.want { + t.Fatalf("prompt context = %d, want %d; output tokens must not count", got, tt.want) + } + }) + } +} + +func TestHaiku55CostTiersAndCache(t *testing.T) { + for _, tt := range []struct { + name string + usage TokenUsage + want float64 + }{ + {"at short limit", TokenUsage{InputTokens: 100_000, OutputTokens: 1_000}, 0.0105}, + {"above short limit", TokenUsage{InputTokens: 100_001, OutputTokens: 1_000}, 0.0525005}, + {"cached short prompt", TokenUsage{InputTokens: 1, CacheReadInputTokens: 99_999, OutputTokens: 10}, 0.00100509}, + {"cache read crosses limit", TokenUsage{InputTokens: 1, CacheReadInputTokens: 100_000, OutputTokens: 10}, 0.0050255}, + {"cache creation crosses limit", TokenUsage{InputTokens: 1, CacheCreationInputTokens: 100_000, OutputTokens: 10}, 0.0625255}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := tt.usage.EstimatedCost(ModelHaiku55); math.Abs(got-tt.want) > 1e-12 { + t.Fatalf("cost = %.12f, want %.12f", got, tt.want) + } + }) + } +} + +func TestRecordRequestPreservesTiersModelsAndSnapshots(t *testing.T) { + var usage TokenUsage + usage.RecordRequest(ModelHaiku55, APIUsage{InputTokens: 60_000}) + snapshot := usage + usage.RecordRequest(ModelHaiku55, APIUsage{InputTokens: 60_000}) + if got := usage.EstimatedCost(ModelHaiku55); math.Abs(got-0.012) > 1e-12 { + t.Fatalf("two short requests must not become one long request: %v", got) + } + usage.RecordRequest(ModelSonnet, APIUsage{InputTokens: 1_000, OutputTokens: 1_000}) + if got := usage.EstimatedCost(ModelHaiku); math.Abs(got-0.030) > 1e-12 { + t.Fatalf("switching the display model repriced earlier requests: %v", got) + } + if snapshot.EstimatedCost(ModelSonnet) != 0.006 || snapshot.Requests != 1 { + t.Fatal("a later request mutated the saved usage snapshot") + } +} + +func TestTokenUsageLegacyAndRecordedJSON(t *testing.T) { + var usage TokenUsage + if err := json.Unmarshal([]byte(`{"InputTokens":1000,"OutputTokens":100,"Requests":1}`), &usage); err != nil { + t.Fatal(err) + } + if usage.CostUSD != nil || math.Abs(usage.EstimatedCost(ModelHaiku)-0.0015) > 1e-12 { + t.Fatal("legacy usage should retain its aggregate estimate") + } + usage.RecordRequest(ModelHaiku, APIUsage{InputTokens: 20, OutputTokens: 10, CacheReadInputTokens: 30, CacheCreationInputTokens: 40}) + data, err := json.Marshal(usage) + if err != nil { + t.Fatal(err) + } + var restored TokenUsage + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + if restored.Requests != 2 || restored.TotalTokens() != 1200 || restored.CostUSD == nil || math.Abs(restored.EstimatedCost(ModelSonnet)-0.001623) > 1e-12 { + t.Fatalf("usage did not round-trip: %+v", restored) + } +} diff --git a/internal/api/keychain_test.go b/internal/api/keychain_test.go new file mode 100644 index 0000000..69d6bf0 --- /dev/null +++ b/internal/api/keychain_test.go @@ -0,0 +1,35 @@ +package api + +import "testing" + +// keychain.go shells out to the real OS keychain (macOS `security`, +// Linux `secret-tool`). Success paths would create or read real user +// keychain entries, so these tests only cover the deterministic failure +// branch where the helper binary cannot be resolved. The env-var fallback +// paths that sit on top of the keychain are covered in +// config_extra_test.go and providers_key_test.go. + +func TestSaveToKeychainFailsWithoutHelper(t *testing.T) { + emptyPATH(t) + if err := SaveToKeychain("no-helper-account", "secret"); err == nil { + t.Error("SaveToKeychain should fail when the keychain helper is unavailable") + } +} + +func TestLoadFromKeychainFailsWithoutHelper(t *testing.T) { + emptyPATH(t) + key, err := LoadFromKeychain("no-helper-account") + if err == nil { + t.Error("LoadFromKeychain should fail when the keychain helper is unavailable") + } + if key != "" { + t.Errorf("expected empty key on failure, got %q", key) + } +} + +func TestDeleteFromKeychainFailsWithoutHelper(t *testing.T) { + emptyPATH(t) + if err := DeleteFromKeychain("no-helper-account"); err == nil { + t.Error("DeleteFromKeychain should fail when the keychain helper is unavailable") + } +} diff --git a/internal/api/messages.go b/internal/api/messages.go index 946dcc7..b24bb7f 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -31,12 +31,27 @@ type ImageSource struct { // APIRequest is the Claude API request body. type APIRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System string `json:"system"` - Messages []Message `json:"messages"` - Tools []ToolDef `json:"tools"` - Stream bool `json:"stream"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system"` + Messages []Message `json:"messages"` + Tools []ToolDef `json:"tools"` + Stream bool `json:"stream"` + Thinking *ThinkingConfig `json:"thinking,omitempty"` +} + +// ThinkingConfig controls Anthropic thinking without sending sampling parameters. +type ThinkingConfig struct { + Type string `json:"type"` +} + +// DefaultThinking reserves Haiku 5.5's response budget for visible text and tools. +// qmax-code does not retain the signed thinking blocks needed for replay. +func DefaultThinking(model string) *ThinkingConfig { + if model == ModelHaiku55 { + return &ThinkingConfig{Type: "disabled"} + } + return nil } // APIResponse is the Claude API response (non-streaming). @@ -51,8 +66,15 @@ type APIResponse struct { // APIUsage tracks token usage on a single request/response. type APIUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` +} + +// TotalInputTokens includes cached prompt tokens, which count toward context limits. +func (u APIUsage) TotalInputTokens() int { + return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens } // ToolDef is a Claude API tool definition. diff --git a/internal/api/models.go b/internal/api/models.go index 263eebe..bf38b77 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -32,7 +32,7 @@ func ResolveClaudeModel(m string) string { // instead of being forwarded to the Anthropic API or Claude Code. func IsValidClaudeModelName(m string) bool { switch ResolveClaudeModel(m) { - case "auto", ModelFable51, ModelFable, ModelSonnet5, ModelSonnet, ModelOpus55, ModelOpus, ModelOpus1M, ModelOpus47, ModelHaiku: + case "auto", ModelFable51, ModelFable, ModelSonnet5, ModelSonnet, ModelOpus55, ModelOpus, ModelOpus1M, ModelOpus47, ModelHaiku, ModelHaiku55: return true default: return false @@ -40,7 +40,7 @@ func IsValidClaudeModelName(m string) bool { } func ValidClaudeModelsHelp() string { - return "auto, fable, sonnet, opus, haiku, " + ModelFable51 + ", " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus55 + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku + return "auto, fable, sonnet, opus, haiku, " + ModelFable51 + ", " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus55 + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku + ", " + ModelHaiku55 } // ContextWindow returns the assumed context-window size in tokens for a model diff --git a/internal/api/models_extra_test.go b/internal/api/models_extra_test.go new file mode 100644 index 0000000..de835da --- /dev/null +++ b/internal/api/models_extra_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "strings" + "testing" +) + +// Additional models.go coverage: shorthand resolution, validation of +// unknown names, and the help string shown in error messages. + +func TestResolveClaudeModelShorthandsAndPassthrough(t *testing.T) { + cases := []struct{ in, want string }{ + {"OPUS", ModelOpus55}, // case-insensitive shorthand + {"Haiku", ModelHaiku}, + {"sonnet", ModelSonnet5}, + {"fable", ModelFable51}, + {"auto", "auto"}, // routing sentinel is preserved + {"claude-opus-4-6", "claude-opus-4-6"}, // full IDs pass through lowercased + {"Claude-Opus-5-5", "claude-opus-5-5"}, + } + for _, tc := range cases { + if got := ResolveClaudeModel(tc.in); got != tc.want { + t.Errorf("ResolveClaudeModel(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestIsValidClaudeModelNameRejectsUnknown(t *testing.T) { + for _, bad := range []string{"", "gibberish", "claude-opus-9-9", "sonnet-xl"} { + if IsValidClaudeModelName(bad) { + t.Errorf("IsValidClaudeModelName(%q) = true, want false", bad) + } + } + for _, good := range []string{ModelOpus47, ModelOpus, ModelSonnet} { + if !IsValidClaudeModelName(good) { + t.Errorf("IsValidClaudeModelName(%q) = false, want true", good) + } + } +} + +func TestValidClaudeModelsHelpListsKnownModels(t *testing.T) { + help := ValidClaudeModelsHelp() + for _, want := range []string{"auto", "fable", "sonnet", "opus", "haiku", ModelFable51, ModelHaiku, ModelOpus1M} { + if !strings.Contains(help, want) { + t.Errorf("help string missing %q: %s", want, help) + } + } +} diff --git a/internal/api/providers_key_test.go b/internal/api/providers_key_test.go new file mode 100644 index 0000000..530d748 --- /dev/null +++ b/internal/api/providers_key_test.go @@ -0,0 +1,57 @@ +package api + +import "testing" + +// Provider key storage sits on the OS keychain with env-var fallback. These +// tests cover the unknown-provider rejection and the deterministic +// env-fallback / helper-unavailable paths; they never touch the real +// keychain (PATH is emptied for the keychain lookups). + +func TestSaveProviderKeyRejectsUnknownProvider(t *testing.T) { + err := SaveProviderKey("nope", "k") + if err == nil { + t.Fatal("expected error for unknown provider") + } +} + +func TestSaveProviderKeyFailsWithoutKeychainHelper(t *testing.T) { + emptyPATH(t) + if err := SaveProviderKey("groq", "gsk-key"); err == nil { + t.Error("SaveProviderKey should fail when the keychain helper is unavailable") + } +} + +func TestDeleteProviderKeyFailsWithoutKeychainHelper(t *testing.T) { + emptyPATH(t) + if err := DeleteProviderKey("groq"); err == nil { + t.Error("DeleteProviderKey should fail when the keychain helper is unavailable") + } +} + +func TestLoadProviderKeyFallsBackToEnvVar(t *testing.T) { + emptyPATH(t) + t.Setenv("GROQ_API_KEY", " gsk-trimmed ") + if got := LoadProviderKey("groq"); got != "gsk-trimmed" { + t.Errorf("LoadProviderKey = %q, want env value trimmed to gsk-trimmed", got) + } +} + +func TestLoadProviderKeyEmptyWhenNoKeyAnywhere(t *testing.T) { + emptyPATH(t) + t.Setenv("GROQ_API_KEY", "") + if got := LoadProviderKey("groq"); got != "" { + t.Errorf("LoadProviderKey = %q, want empty with no keychain entry and no env var", got) + } +} + +func TestProviderKeySetUsesEnvFallback(t *testing.T) { + emptyPATH(t) + t.Setenv("GROQ_API_KEY", "gsk-live") + if !ProviderKeySet("groq") { + t.Error("ProviderKeySet should be true when the env var is set") + } + t.Setenv("GROQ_API_KEY", "") + if ProviderKeySet("groq") { + t.Error("ProviderKeySet should be false with no key available") + } +} diff --git a/internal/gate/gate.go b/internal/gate/gate.go index 14371e1..6e1952c 100644 --- a/internal/gate/gate.go +++ b/internal/gate/gate.go @@ -14,7 +14,7 @@ import ( "time" "unicode" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/security" ) const ( diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go index 2b16114..275fb46 100644 --- a/internal/httpx/httpx.go +++ b/internal/httpx/httpx.go @@ -28,9 +28,9 @@ import ( "sync" "time" + "github.com/Quality-Max/qmax-code/internal/exposure" receipt "github.com/Quality-Max/qmax-receipt" "github.com/coder/websocket" - "github.com/qualitymax/qmax-code/internal/exposure" ) // NewClient returns an *http.Client whose Transport records every request into diff --git a/internal/mcp/cloud.go b/internal/mcp/cloud.go index cc369ad..2a6586f 100644 --- a/internal/mcp/cloud.go +++ b/internal/mcp/cloud.go @@ -12,10 +12,10 @@ import ( "time" "unicode" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/exposure" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/exposure" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" ) const ( diff --git a/internal/mcp/cloud_test.go b/internal/mcp/cloud_test.go index 3e82030..ff8b695 100644 --- a/internal/mcp/cloud_test.go +++ b/internal/mcp/cloud_test.go @@ -10,7 +10,7 @@ import ( "sync/atomic" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func authenticatedTestState(cloudURL string) *serverState { diff --git a/internal/mcp/compat.go b/internal/mcp/compat.go index 8e4acd9..16e7f2f 100644 --- a/internal/mcp/compat.go +++ b/internal/mcp/compat.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/qualitymax/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/agent" ) type compatibilityAlias struct { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6b86f3d..7b80ba2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -10,10 +10,10 @@ import ( "strconv" "strings" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/security" - "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/sysutil" ) // RunServer starts an MCP (Model Context Protocol) server over stdin/stdout. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index b558673..fc9c355 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestHandleLineReturnsParseErrorForMalformedJSON(t *testing.T) { diff --git a/internal/repl/backend_switch_test.go b/internal/repl/backend_switch_test.go index 096db0b..e6f8d09 100644 --- a/internal/repl/backend_switch_test.go +++ b/internal/repl/backend_switch_test.go @@ -3,7 +3,7 @@ package repl import ( "testing" - "github.com/qualitymax/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/agent" ) func TestDeactivateEmbeddedBackendsClearsCerebrasAndOllamaMode(t *testing.T) { diff --git a/internal/repl/browserfeed.go b/internal/repl/browserfeed.go index f175eae..49088f9 100644 --- a/internal/repl/browserfeed.go +++ b/internal/repl/browserfeed.go @@ -22,7 +22,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "golang.org/x/term" - "github.com/qualitymax/qmax-code/internal/vnc" + "github.com/Quality-Max/qmax-code/internal/vnc" ) type blockMode int @@ -62,10 +62,7 @@ func showBrowserFeedStream(stream *vnc.VNCStream, mode blockMode, status string) status: status, } m.updateEffectiveDims() - p := tea.NewProgram(m, - tea.WithAltScreen(), - tea.WithMouseAllMotion(), - ) + p := tea.NewProgram(m, browserFeedProgramOptions()...) // Bridge stream channels → Bubble Tea. Both goroutines exit when their // respective channels close (stream.Close() closes both). A WaitGroup @@ -93,6 +90,12 @@ func showBrowserFeedStream(stream *vnc.VNCStream, mode blockMode, status string) return runErr } +// browserFeedProgramOptions configures the feed's Bubble Tea program. Tests +// replace it to run without a terminal. +var browserFeedProgramOptions = func() []tea.ProgramOption { + return []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseAllMotion()} +} + // ─── Bubble Tea model ──────────────────────────────────────────────────────── type frameMsg struct{ frame *vnc.VNCFrame } diff --git a/internal/repl/browserfeed_model_test.go b/internal/repl/browserfeed_model_test.go new file mode 100644 index 0000000..c33879f --- /dev/null +++ b/internal/repl/browserfeed_model_test.go @@ -0,0 +1,394 @@ +package repl + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/coder/websocket" + + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/vnc" +) + +// rfbServer is a minimal RFB 3.8 server over WebSocket. It completes the +// handshake for a 4x4 desktop, optionally sends one raw framebuffer update, +// then records every client message until the client disconnects. +type rfbServer struct { + srv *httptest.Server + url string + sendFrame bool + + mu sync.Mutex + received []byte + done chan struct{} + once sync.Once +} + +// disconnect drops every client connection from the server side. +func (rs *rfbServer) disconnect() { rs.once.Do(func() { close(rs.done) }) } + +func newRFBServer(t *testing.T, sendFrame bool) *rfbServer { + t.Helper() + rs := &rfbServer{sendFrame: sendFrame, done: make(chan struct{})} + mux := http.NewServeMux() + mux.HandleFunc("/websockify", func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{"binary"}, InsecureSkipVerify: true}) + if err != nil { + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + nc := websocket.NetConn(r.Context(), conn, websocket.MessageBinary) + _, _ = nc.Write([]byte("RFB 003.008\n")) + _, _ = io.ReadFull(nc, make([]byte, 12)) + _, _ = nc.Write([]byte{1, 1}) + _, _ = io.ReadFull(nc, make([]byte, 1)) + _, _ = nc.Write([]byte{0, 0, 0, 0}) + _, _ = io.ReadFull(nc, make([]byte, 1)) + si := make([]byte, 24) + binary.BigEndian.PutUint16(si[0:], 4) + binary.BigEndian.PutUint16(si[2:], 4) + _, _ = nc.Write(si) + if rs.sendFrame { + msg := []byte{0, 0, 0, 1} + rect := make([]byte, 12) + binary.BigEndian.PutUint16(rect[4:], 4) + binary.BigEndian.PutUint16(rect[6:], 4) + msg = append(msg, rect...) + for i := 0; i < 16; i++ { + msg = append(msg, byte(i*16), byte(255-i*16), byte(i*8), 0) + } + _, _ = nc.Write(msg) + } + go func() { + select { + case <-rs.done: + _ = conn.Close(websocket.StatusGoingAway, "test disconnect") + case <-r.Context().Done(): + } + }() + buf := make([]byte, 1024) + for { + n, err := nc.Read(buf) + if err != nil { + return + } + rs.mu.Lock() + rs.received = append(rs.received, buf[:n]...) + rs.mu.Unlock() + } + }) + rs.srv = httptest.NewServer(mux) + rs.url = "ws://" + rs.srv.Listener.Addr().String() + "/websockify" + t.Cleanup(func() { + rs.disconnect() + rs.srv.Close() + }) + return rs +} + +func (rs *rfbServer) bytes() []byte { + rs.mu.Lock() + defer rs.mu.Unlock() + return append([]byte(nil), rs.received...) +} + +func dialTestStream(t *testing.T, rs *rfbServer) *vnc.VNCStream { + t.Helper() + stream, err := vnc.DialVNC(context.Background(), rs.url, 1) + if err != nil { + t.Fatalf("DialVNC: %v", err) + } + t.Cleanup(stream.Close) + return stream +} + +func gradientFrame(w, h int) *vnc.VNCFrame { + f := &vnc.VNCFrame{Width: w, Height: h, Pixels: make([]byte, w*h*4)} + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := (y*w + x) * 4 + f.Pixels[i] = byte(x * 255 / w) + f.Pixels[i+1] = byte(y * 255 / h) + if (x+y)%2 == 0 { + f.Pixels[i+2] = 255 + } + } + } + return f +} + +func TestRenderBlocksBothModes(t *testing.T) { + frame := gradientFrame(64, 32) + for _, mode := range []blockMode{blockModeQuarter, blockModeHalf} { + body, layout := renderBlocks(frame, 40, 12, mode) + if !strings.Contains(body, "\x1b[") { + t.Fatalf("mode %v: expected ANSI colour output", mode) + } + if layout.srcW != 64 || layout.srcH != 32 || layout.cellSrcWidth <= 0 || layout.cellSrcHeight <= 0 { + t.Fatalf("mode %v: layout = %+v", mode, layout) + } + if x, y, ok := mouseToSrcPixel(layout.leftPadCells, layout.topPadCells, layout); !ok || x < 0 || y < 0 { + t.Fatalf("mode %v: top-left cell should map into the frame, got %d,%d ok=%v", mode, x, y, ok) + } + if _, _, ok := mouseToSrcPixel(-1, -1, layout); ok { + t.Fatalf("mode %v: negative cell must not map", mode) + } + if _, _, ok := mouseToSrcPixel(1000, 1000, layout); ok { + t.Fatalf("mode %v: far cell must not map", mode) + } + + tiny, _ := renderBlocks(frame, 1, 1, mode) + if !strings.Contains(tiny, "too small") { + t.Fatalf("mode %v: tiny terminal = %q", mode, tiny) + } + empty, _ := renderBlocks(&vnc.VNCFrame{}, 40, 12, mode) + if empty != "" { + t.Fatalf("mode %v: empty frame rendered %q", mode, empty) + } + } + if _, _, ok := mouseToSrcPixel(0, 0, blockLayout{}); ok { + t.Fatal("zero layout must not map") + } +} + +func TestPixelAtBoundsAndIndexMap(t *testing.T) { + f := gradientFrame(2, 2) + if got := pixelAt(f, -1, 0); got != [3]byte{} { + t.Fatalf("out-of-bounds pixel = %v", got) + } + if got := pixelAt(f, 1, 1); got != [3]byte{f.Pixels[14], f.Pixels[13], f.Pixels[12]} { + t.Fatalf("pixelAt(1,1) = %v", got) + } + if m := buildIndexMap(10, 0); len(m) != 0 { + t.Fatalf("empty map = %v", m) + } + if m := buildIndexMap(3, 6); m[5] != 2 { + t.Fatalf("upscale map = %v", m) + } + if bfTruncate("abcdef", 0) != "" || bfTruncate("abc", 5) != "abc" || len([]rune(bfTruncate("abcdefgh", 4))) > 4 { + t.Fatal("bfTruncate bounds") + } +} + +func TestKeyMsgToKeysym(t *testing.T) { + cases := map[tea.KeyType]uint32{ + tea.KeyEnter: 0xff0d, tea.KeyBackspace: 0xff08, tea.KeyTab: 0xff09, tea.KeyShiftTab: 0xfe20, + tea.KeyEsc: 0xff1b, tea.KeySpace: 0x20, tea.KeyDelete: 0xffff, tea.KeyHome: 0xff50, + tea.KeyEnd: 0xff57, tea.KeyPgUp: 0xff55, tea.KeyPgDown: 0xff56, tea.KeyLeft: 0xff51, + tea.KeyUp: 0xff52, tea.KeyRight: 0xff53, tea.KeyDown: 0xff54, + tea.KeyF1: 0xffbe, tea.KeyF2: 0xffbf, tea.KeyF3: 0xffc0, tea.KeyF4: 0xffc1, + tea.KeyF5: 0xffc2, tea.KeyF6: 0xffc3, tea.KeyF7: 0xffc4, tea.KeyF8: 0xffc5, + tea.KeyF9: 0xffc6, tea.KeyF10: 0xffc7, tea.KeyF11: 0xffc8, tea.KeyF12: 0xffc9, + } + for k, want := range cases { + if got, ok := keyMsgToKeysym(tea.KeyMsg{Type: k}); !ok || got != want { + t.Errorf("key %v = %#x ok=%v, want %#x", k, got, ok, want) + } + } + if got, ok := keyMsgToKeysym(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("é")}); !ok || got != 'é' { + t.Fatalf("rune key = %#x ok=%v", got, ok) + } + if _, ok := keyMsgToKeysym(tea.KeyMsg{Type: tea.KeyRunes}); ok { + t.Fatal("empty runes must not map") + } + if _, ok := keyMsgToKeysym(tea.KeyMsg{Type: tea.KeyCtrlA}); ok { + t.Fatal("unmapped control key must not map") + } +} + +func TestMouseButtonMask(t *testing.T) { + press := func(b tea.MouseButton) tea.MouseMsg { + return tea.MouseMsg{Button: b, Action: tea.MouseActionPress} + } + var mask byte + for bit, b := range []tea.MouseButton{tea.MouseButtonLeft, tea.MouseButtonMiddle, tea.MouseButtonRight, tea.MouseButtonWheelUp, tea.MouseButtonWheelDown} { + mask = updateButtonMask(mask, press(b)) + if mask&(1< 0 && agent.FindOpenCode() != "" { + if len(cfg.ActiveProviders()) > 0 && findOpenCode() != "" { term.PrintSystem("Querying opencode models for your enabled providers…") } ocModels, modelQueryWarnings := buildOpenCodeModelEntries(cfg, ag.Cfg.Context) @@ -569,17 +600,17 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if len(ocModels) == 0 { ocModels = openCodeSetupEntries() } - result := tui.ShowModelPicker(tui.ModelPickerOpts{ + result := showModelPicker(tui.ModelPickerOpts{ CurrentBackend: currentBackend, CurrentModelID: currentModelID, Effort: currentEffort, OllamaURL: cfg.OllamaURL, OllamaModel: cfg.OllamaModel, - CCInstalled: agent.FindClaudeCode() != "", - CodexInstalled: agent.FindCodex() != "", - AgyInstalled: agent.FindAgy() != "", + CCInstalled: findClaudeCode() != "", + CodexInstalled: findCodex() != "", + AgyInstalled: findAgy() != "", CerebrasKeySet: cfg.CerebrasKey != "", - OpenCodeInstalled: agent.FindOpenCode() != "", + OpenCodeInstalled: findOpenCode() != "", OpenCodeModels: ocModels, }) if !result.Confirmed { @@ -627,7 +658,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // leaving it blank cancels the switch without changing anything. if cfg.CerebrasKey == "" { term.PrintSystem("Cerebras needs an API key (get one at https://cloud.cerebras.ai).") - key := setup.ReadSecret(" Paste your Cerebras key (blank to cancel): ") + key := readSecret(" Paste your Cerebras key (blank to cancel): ") if key == "" { term.PrintSystem("No key entered — backend not changed.") continue @@ -679,26 +710,26 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // ── Validate the chosen CLI is actually installed ───────────────── switch result.Backend { case "cc": - if agent.FindClaudeCode() == "" { + if findClaudeCode() == "" { term.PrintError("Claude Code ('claude') not found. Install it first.") term.PrintSystem(" https://claude.ai/download") continue } case "codex": - if agent.FindCodex() == "" { + if findCodex() == "" { term.PrintError("Codex CLI ('codex') not found.") term.PrintSystem(" npm install -g @openai/codex") continue } case "agy": - if agent.FindAgy() == "" { + if findAgy() == "" { term.PrintError("Antigravity CLI ('agy') not found.") term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") term.PrintSystem(" Then run `agy` and sign in with Google") continue } case "opencode": - if agent.FindOpenCode() == "" { + if findOpenCode() == "" { term.PrintError("opencode CLI ('opencode') not found.") term.PrintSystem(" curl -fsSL https://opencode.ai/install | bash") continue @@ -707,7 +738,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // Consent gate: required before activating CC/Codex (autonomous shell + edits). if result.Backend != "" { - consent := setup.PromptOrchConsent(cfg, result.Backend) + consent := promptOrchConsent(cfg, result.Backend) if !consent.Proceed { term.PrintSystem("Backend not changed.") continue @@ -728,7 +759,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin setup.InstallSkillsReport(result.Backend, term) } if result.Backend == "agy" { - setup.PromptAgyGoogleLogin(agent.FindAgy()) + promptAgyLogin(findAgy()) } } @@ -742,24 +773,24 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // Spin up the new agent with selected model + effort. switch result.Backend { case "cc": - cliAgent = agent.NewCCAgent(agent.FindClaudeCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) + cliAgent = agent.NewCCAgent(findClaudeCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) term.PrintSystem(fmt.Sprintf("Backend: Claude Code model: %s effort: %s", result.ModelID, result.Effort)) case "codex": - ca := agent.NewCodexAgent(agent.FindCodex(), result.ModelID, result.Effort, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) + ca := agent.NewCodexAgent(findCodex(), result.ModelID, result.Effort, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) if err := ca.WriteMCPConfig(); err != nil { term.PrintSystem(fmt.Sprintf("Warning: Codex MCP config: %v", err)) } cliAgent = ca term.PrintSystem(fmt.Sprintf("Backend: Codex model: %s policy: Codex config prompt effort: %s", codexModelLabel(result.ModelID), result.Effort)) case "agy": - aa := agent.NewAgyAgent(agent.FindAgy(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) + aa := agent.NewAgyAgent(findAgy(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) if err := aa.WriteMCPConfig(); err != nil { term.PrintSystem(fmt.Sprintf("Warning: Antigravity MCP config: %v", err)) } cliAgent = aa term.PrintSystem(fmt.Sprintf("Backend: Antigravity model: %s effort: %s (Google OAuth)", agyModelLabel(result.ModelID), result.Effort)) case "opencode": - oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) + oc := agent.NewOpenCodeAgent(findOpenCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) } @@ -783,7 +814,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintError("api.Config not loaded.") continue } - chosen, ok := tui.ShowThemePicker(cfg.Theme) + chosen, ok := showThemePicker(cfg.Theme) if !ok { continue } @@ -805,7 +836,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintError("api.Config not loaded.") continue } - enabled, ok := tui.ShowCloudSyncPicker(cfg.CloudSync) + enabled, ok := showCloudSyncPicker(cfg.CloudSync) if !ok { continue } @@ -860,7 +891,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin // from a different backend (e.g. gpt-5.6 from Codex), which must never // be passed to opencode. if wantBackend == "opencode" { - if agent.FindOpenCode() == "" { + if findOpenCode() == "" { term.PrintError("'opencode' CLI not found.") term.PrintSystem(" curl -fsSL https://opencode.ai/install | bash") continue @@ -874,7 +905,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintError("No opencode models available for your enabled providers. Check keys with /providers or pick a model in /orch.") continue } - consent := setup.PromptOrchConsent(cfg, "opencode") + consent := promptOrchConsent(cfg, "opencode") if !consent.Proceed { term.PrintSystem("Backend not changed.") continue @@ -884,13 +915,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } if wantBackend == "agy" { - if agent.FindAgy() == "" { + if findAgy() == "" { term.PrintError("'agy' CLI not found.") term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") term.PrintSystem(" Then run `agy` and sign in with Google") continue } - consent := setup.PromptOrchConsent(cfg, "agy") + consent := promptOrchConsent(cfg, "agy") if !consent.Proceed { term.PrintSystem("Backend not changed.") continue @@ -903,7 +934,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } setup.InstallSkillsReport("agy", term) } - setup.PromptAgyGoogleLogin(agent.FindAgy()) + promptAgyLogin(findAgy()) } // Tear down current CLI agent. @@ -915,13 +946,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin switch wantBackend { case "cc": - bin := agent.FindClaudeCode() + bin := findClaudeCode() if bin == "" { term.PrintError("'claude' CLI not found. Install Claude Code first.") term.PrintSystem(" https://claude.ai/download") continue } - consent := setup.PromptOrchConsent(cfg, "cc") + consent := promptOrchConsent(cfg, "cc") if !consent.Proceed { term.PrintSystem("Backend not changed.") continue @@ -940,13 +971,13 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem(fmt.Sprintf("Backend → Claude Code (%s) · %s mode", bin, cfg.OrchPermissionMode)) case "codex": - bin := agent.FindCodex() + bin := findCodex() if bin == "" { term.PrintError("'codex' CLI not found.") term.PrintSystem(" npm install -g @openai/codex") continue } - consent := setup.PromptOrchConsent(cfg, "codex") + consent := promptOrchConsent(cfg, "codex") if !consent.Proceed { term.PrintSystem("Backend not changed.") continue @@ -969,7 +1000,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem(fmt.Sprintf("Backend → Codex (%s) · Codex config policy", bin)) case "agy": - bin := agent.FindAgy() + bin := findAgy() aa := agent.NewAgyAgent(bin, cfg.ModelOverride, cfg.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg.NarrateToolCalls, ag.Cfg.Context) if err := aa.WriteMCPConfig(); err != nil { term.PrintSystem(fmt.Sprintf("Warning: MCP config: %v", err)) @@ -982,7 +1013,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin case "opencode": // Pre-flight above already validated the CLI, providers, model, and // consent (and set cfg.ModelOverride / cfg.OrchPermissionMode). - bin := agent.FindOpenCode() + bin := findOpenCode() oc := agent.NewOpenCodeAgent(bin, cfg.ModelOverride, cfg.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) @@ -1040,7 +1071,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if cfg.CerebrasKey == "" { term.PrintSystem("Cerebras needs an API key (get one at https://cloud.cerebras.ai).") - key := setup.ReadSecret(" Paste your Cerebras key (blank to cancel): ") + key := readSecret(" Paste your Cerebras key (blank to cancel): ") if key == "" { term.PrintSystem("No key entered — Gemma not activated.") continue @@ -1143,7 +1174,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin term.PrintSystem(" --half : use 2-pixel half-blocks (more portable, lower res)") continue } - if err := showBrowserFeed(arg, mode); err != nil { + if err := openBrowserFeed(arg, mode); err != nil { term.PrintError(fmt.Sprintf("browserfeed: %v", err)) } continue @@ -1175,7 +1206,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin case "": // No argument → open the TUI picker. Matches the /cloudsync // pattern so toggles feel consistent across the app. - next, ok := tui.ShowLiveFeedPicker(cfg.LiveFeed) + next, ok := showLiveFeedPicker(cfg.LiveFeed) if !ok { continue // user cancelled; leave current value alone } @@ -1217,12 +1248,12 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } continue } - if err := showBrowserFeed(url, blockModeQuarter); err != nil { + if err := openBrowserFeed(url, blockModeQuarter); err != nil { term.PrintError(fmt.Sprintf("browserfeed: %v", err)) } continue case input == "/screenshot": - img, err := tui.CaptureScreenshot() + img, err := captureScreenshot() if err != nil { term.PrintError(err.Error()) continue @@ -1232,7 +1263,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin turnImages = append(turnImages, *img) case input == "/paste": // Try image first, then text - img, imgErr := tui.PasteImageFromClipboard() + img, imgErr := pasteImage() if imgErr == nil { term.PrintSystem(fmt.Sprintf("Pasted image from clipboard (%s)", img.FileName)) input = "Analyze this pasted image." @@ -1240,7 +1271,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin break } // Fall back to text paste - text, textErr := tui.PasteTextFromClipboard() + text, textErr := pasteText() if textErr != nil || text == "" { term.PrintError("Nothing in clipboard") continue @@ -1370,7 +1401,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin watchCancel() } - turnInput := tui.RunTurnViewport(term, term.Prompt(), inputStatus(), cancelCurrent, func() { + turnInput := runTurnViewport(term, term.Prompt(), inputStatus(), cancelCurrent, func() { if runWithCLI { if len(images) > 0 { term.PrintSystem("Note: image attachments are not supported in CLI backend mode.") @@ -1383,9 +1414,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin if err == nil { if stats, ok := cliAgent.(agent.TurnStatsProvider); ok { if inputTokens, outputTokens, reported := stats.LastTurnStats(); reported { - ag.Usage.InputTokens += inputTokens - ag.Usage.OutputTokens += outputTokens - ag.Usage.Requests++ + ag.Usage.RecordRequest(ag.Cfg.Model, api.APIUsage{ + InputTokens: inputTokens, OutputTokens: outputTokens, + }) lastContextTokens = inputTokens ag.LastContextTokens = inputTokens turnIn, turnOut = inputTokens, outputTokens @@ -1508,7 +1539,7 @@ func handleConnect(ag *agent.Agent, term *tui.Terminal) { tui.AnimateMax(tui.MoodWaving, "Let's connect you to QualityMax!") fmt.Println() - auth, err := setup.LoginViaBrowser() + auth, err := loginViaBrowser() if err != nil { tui.AnimateMax(tui.MoodSad, "Connection failed: "+err.Error()) fmt.Println() @@ -1614,7 +1645,7 @@ func handleKeys(ag *agent.Agent, term *tui.Terminal) { } fmt.Println() - choice := setup.PromptChoice(" What would you like to do?", []string{ + choice := promptChoice(" What would you like to do?", []string{ "Set Anthropic API key", "Connect to QualityMax (browser)", "Disconnect from QualityMax", @@ -1626,7 +1657,7 @@ func handleKeys(ag *agent.Agent, term *tui.Terminal) { fmt.Println() fmt.Println(" Get your key at: https://console.anthropic.com/settings/keys") fmt.Println() - key := setup.ReadSecret(" Paste your Anthropic key: ") + key := readSecret(" Paste your Anthropic key: ") if key == "" { term.PrintSystem("Cancelled.") return @@ -1968,7 +1999,7 @@ func handleSetCommand(input string, ag *agent.Agent, term *tui.Terminal) { case "apikey", "anthropic-key", "anthropic_key": // Prompt hidden rather than making the user paste a secret into the // visible input line (and its history). - value := setup.ReadSecret(" Paste the value (blank to cancel): ") + value := readSecret(" Paste the value (blank to cancel): ") if value == "" { term.PrintSystem("No value entered — nothing changed.") return @@ -2206,7 +2237,7 @@ func applySettingValue(key, value string, ag *agent.Agent, term *tui.Terminal) s // For live switching use /cc, /codex, or /api instead. switch strings.ToLower(value) { case "cc": - if agent.FindClaudeCode() == "" { + if findClaudeCode() == "" { term.PrintError("'claude' CLI not found. Install Claude Code first.") term.PrintSystem(" https://claude.ai/download") return settingInvalid @@ -2214,7 +2245,7 @@ func applySettingValue(key, value string, ag *agent.Agent, term *tui.Terminal) s cfg.Backend = "cc" term.PrintSystem("Backend set to CC. Use /cc to switch live, or restart to apply.") case "codex": - if agent.FindCodex() == "" { + if findCodex() == "" { term.PrintError("'codex' CLI not found.") term.PrintSystem(" npm install -g @openai/codex") return settingInvalid @@ -2222,7 +2253,7 @@ func applySettingValue(key, value string, ag *agent.Agent, term *tui.Terminal) s cfg.Backend = "codex" term.PrintSystem("Backend set to Codex. Use /codex to switch live, or restart to apply.") case "agy": - if agent.FindAgy() == "" { + if findAgy() == "" { term.PrintError("'agy' CLI not found.") term.PrintSystem(" curl -fsSL https://antigravity.google/cli/install.sh | bash") term.PrintSystem(" Then run `agy` and sign in with Google") @@ -2384,7 +2415,7 @@ func runSettingsPicker(ag *agent.Agent, term *tui.Terminal) { cfg = api.DefaultConfig() ag.AppConfig = cfg } - res := tui.ShowSettingsPicker(buildSettingsRows(cfg)) + res := showSettingsPicker(buildSettingsRows(cfg)) if !res.Confirmed { term.PrintSystem("Settings unchanged.") return @@ -2497,7 +2528,7 @@ func embeddedInferenceAvailable(ag *agent.Agent) bool { // its models silently vanish from the picker. The failure is now returned as a // warning string so the caller can tell the user why rows are missing. func buildOpenCodeModelEntries(cfg *api.Config, sctx *api.SessionContext) ([]tui.OpenCodeModelEntry, []string) { - bin := agent.FindOpenCode() + bin := findOpenCode() if bin == "" { return nil, nil } @@ -2626,7 +2657,7 @@ func pickAndEnableProvider(cfg *api.Config, ag *agent.Agent, term *tui.Terminal) } items = append(items, item) } - result := tui.ShowProviderPicker(items) + result := showProviderPicker(items) if !result.Confirmed { return } @@ -2645,7 +2676,7 @@ func enableProvider(cfg *api.Config, id string, ag *agent.Agent, term *tui.Termi } if !api.ProviderKeySet(id) { term.PrintSystem(fmt.Sprintf("%s needs an API key (%s).", p.DisplayName, p.SignupURL)) - key := setup.ReadSecret(fmt.Sprintf(" Paste your %s key (blank to cancel): ", p.DisplayName)) + key := readSecret(fmt.Sprintf(" Paste your %s key (blank to cancel): ", p.DisplayName)) if key == "" { term.PrintSystem("No key entered — provider not enabled.") return @@ -2673,7 +2704,7 @@ func enableProvider(cfg *api.Config, id string, ag *agent.Agent, term *tui.Termi term.PrintSystem(fmt.Sprintf("Warning: opencode config: %v", err)) } term.PrintSystem(fmt.Sprintf("Enabled %s. Pick a model in /orch (opencode section).", p.DisplayName)) - if agent.FindOpenCode() == "" { + if findOpenCode() == "" { term.PrintSystem(" Heads-up: the opencode CLI isn't installed yet — curl -fsSL https://opencode.ai/install | bash") } // qmax now holds this provider's key in the OS keychain and injects it at @@ -2719,14 +2750,14 @@ var sdkCreditCutover = time.Date(2026, time.June, 15, 0, 0, 0, 0, time.Local) // silent. Best-effort: any read/write failure simply re-shows. // maybeProposeUpdate offers a self-update when a newer release exists. func maybeProposeUpdate(term *tui.Terminal, current string) { - rel := update.MaybeCheck(current) + rel := maybeCheckUpdate(current) if rel == nil { return } fmt.Printf(" %sUpdate available: %s%s (you have %s)\n", tui.ColorYellow, rel.Version, tui.ColorReset, current) fmt.Printf(" %sUpdate now? [y/N] %s", tui.ColorBold, tui.ColorReset) - answer, err := term.ReadConsent() + answer, err := readConsent(term) if err != nil { update.MarkSkipped(rel.Version) // non-interactive stdin: don't loop return diff --git a/internal/repl/repl_set_test.go b/internal/repl/repl_set_test.go index 170164e..1334bf5 100644 --- a/internal/repl/repl_set_test.go +++ b/internal/repl/repl_set_test.go @@ -3,9 +3,9 @@ package repl import ( "testing" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) func TestHandleSetCommandCloudSyncDocumentedKey(t *testing.T) { diff --git a/internal/repl/run_harness_test.go b/internal/repl/run_harness_test.go new file mode 100644 index 0000000..d5eacfd --- /dev/null +++ b/internal/repl/run_harness_test.go @@ -0,0 +1,318 @@ +package repl + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/gate" + "github.com/Quality-Max/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/update" +) + +// fakeCLI is a scripted CLI backend: it records prompts and returns canned +// responses without spawning a subprocess. +type fakeCLI struct { + mu sync.Mutex + prompts []string + response string + err error + verbose bool + cleaned int + resets int + canceled int + statsIn int + statsOut int + limitHit bool + limitTime time.Time +} + +func (f *fakeCLI) Run(msg string, term *tui.Terminal) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.prompts = append(f.prompts, msg) + return f.response, f.err +} +func (f *fakeCLI) Cancel() { f.canceled++ } +func (f *fakeCLI) SetOutputVerbose(v bool) { f.verbose = v } +func (f *fakeCLI) Cleanup() { f.cleaned++ } +func (f *fakeCLI) ResetConversation() { f.resets++ } +func (f *fakeCLI) LastTurnStats() (int, int, bool) { + return f.statsIn, f.statsOut, f.statsIn > 0 || f.statsOut > 0 +} +func (f *fakeCLI) LastPlanLimit() (time.Time, bool) { return f.limitTime, f.limitHit } + +// replEnv isolates a REPL test from the developer machine: a temporary HOME, +// a PATH holding only fake keychain tools, and no update checks. +func replEnv(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("REPL harness uses POSIX shell stubs") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("QMAX_NO_UPDATE_CHECK", "1") + t.Setenv("ANTHROPIC_API_KEY", "") + for _, key := range []string{ + "CEREBRAS_API_KEY", "CEREBRAS_API_BASE", "CEREBRAS_MODEL", "CEREBRAS_REASONING_EFFORT", + "OLLAMA_BASE_URL", "OLLAMA_MODEL", "QUALITYMAX_API_KEY", + "GROQ_API_KEY", "OPENROUTER_API_KEY", "QMAX_PC_ZAI_CODING_PLAN", + } { + t.Setenv(key, "") + } + bin := filepath.Join(home, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + // Keychain stand-ins: writes succeed, lookups miss. The real OS keychain + // is never touched. + writeStub(t, bin, "security", `case "$1" in find-generic-password) exit 44;; esac; exit 0`) + writeStub(t, bin, "secret-tool", `case "$1" in lookup) exit 1;; esac; cat >/dev/null; exit 0`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+"/usr/bin"+string(os.PathListSeparator)+"/bin") + // Clients built during the test wrap http.DefaultTransport; fail every + // request locally so no test reaches a real API. + origTransport := http.DefaultTransport + http.DefaultTransport = offlineTransport{} + t.Cleanup(func() { http.DefaultTransport = origTransport }) + return home +} + +type offlineTransport struct{} + +func (offlineTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("offline test transport: %s %s", req.Method, req.URL.Host) +} + +func writeStub(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// stubSeams replaces every interactive entry point with an inert default for +// the duration of the test. Individual tests override what they exercise. +func stubSeams(t *testing.T) { + t.Helper() + saved := []func(){} + save := func(restore func()) { saved = append(saved, restore) } + { + v := readInput + save(func() { readInput = v }) + } + { + v := runTurnViewport + save(func() { runTurnViewport = v }) + } + { + v := showModelPicker + save(func() { showModelPicker = v }) + } + { + v := showThemePicker + save(func() { showThemePicker = v }) + } + { + v := showSessionPicker + save(func() { showSessionPicker = v }) + } + { + v := showCloudSyncPicker + save(func() { showCloudSyncPicker = v }) + } + { + v := showLiveFeedPicker + save(func() { showLiveFeedPicker = v }) + } + { + v := showSettingsPicker + save(func() { showSettingsPicker = v }) + } + { + v := showProviderPicker + save(func() { showProviderPicker = v }) + } + { + v := captureScreenshot + save(func() { captureScreenshot = v }) + } + { + v := pasteImage + save(func() { pasteImage = v }) + } + { + v := pasteText + save(func() { pasteText = v }) + } + { + v := promptOrchConsent + save(func() { promptOrchConsent = v }) + } + { + v := readSecret + save(func() { readSecret = v }) + } + { + v := promptAgyLogin + save(func() { promptAgyLogin = v }) + } + { + v := promptChoice + save(func() { promptChoice = v }) + } + { + v := loginViaBrowser + save(func() { loginViaBrowser = v }) + } + { + v := checkUpdate + save(func() { checkUpdate = v }) + } + { + v := runGate + save(func() { runGate = v }) + } + { + v1, v2 := maybeCheckUpdate, readConsent + save(func() { maybeCheckUpdate, readConsent = v1, v2 }) + } + { + v := openBrowserFeed + save(func() { openBrowserFeed = v }) + } + { + v1, v2, v3, v4 := findClaudeCode, findCodex, findAgy, findOpenCode + save(func() { findClaudeCode, findCodex, findAgy, findOpenCode = v1, v2, v3, v4 }) + } + t.Cleanup(func() { + for _, restore := range saved { + restore() + } + }) + + runTurnViewport = func(_ *tui.Terminal, _ string, _ *tui.StatusInfo, _ func(), run func()) tui.TurnInputResult { + run() + return tui.TurnInputResult{} + } + showModelPicker = func(tui.ModelPickerOpts) tui.ModelPickerResult { return tui.ModelPickerResult{} } + showThemePicker = func(string) (string, bool) { return "", false } + showSessionPicker = func([]tui.SessionPickerItem, string) (string, bool) { return "", false } + showCloudSyncPicker = func(*bool) (bool, bool) { return false, false } + showLiveFeedPicker = func(bool) (bool, bool) { return false, false } + showSettingsPicker = func([]tui.SettingsRow) tui.SettingsPickerResult { return tui.SettingsPickerResult{} } + showProviderPicker = func([]tui.ProviderPickerItem) tui.ProviderPickerResult { return tui.ProviderPickerResult{} } + captureScreenshot = func() (*tui.ImageAttachment, error) { return nil, errors.New("no display") } + pasteImage = func() (*tui.ImageAttachment, error) { return nil, errors.New("no image") } + pasteText = func() (string, error) { return "", errors.New("empty") } + promptOrchConsent = func(*api.Config, string) setup.ConsentResult { return setup.ConsentResult{} } + readSecret = func(string) string { return "" } + promptAgyLogin = func(string) {} + promptChoice = func(string, []string) int { return 3 } + loginViaBrowser = func() (*api.AuthConfig, error) { return nil, errors.New("browser login disabled in tests") } + checkUpdate = func(string) (*update.Release, error) { return nil, nil } + maybeCheckUpdate = func(string) *update.Release { return nil } + readConsent = func(*tui.Terminal) (string, error) { return "", errors.New("no tty") } + runGate = func(context.Context, gate.Options) gate.Result { return gate.Result{} } + openBrowserFeed = func(string, blockMode) error { return errors.New("no feed in tests") } + findClaudeCode = func() string { return "" } + findCodex = func() string { return "" } + findAgy = func() string { return "" } + findOpenCode = func() string { return "" } +} + +// scriptInputs feeds lines to the REPL prompt in order, then exits. +func scriptInputs(t *testing.T, lines ...string) { + t.Helper() + var mu sync.Mutex + i := 0 + readInput = func(string, []string, bool, *tui.StatusInfo) tui.InputResult { + mu.Lock() + defer mu.Unlock() + if i >= len(lines) { + return tui.InputResult{Text: "/exit"} + } + line := lines[i] + i++ + switch line { + case "": + return tui.InputResult{CtrlC: true} + case "": + return tui.InputResult{OutputToggle: true} + } + return tui.InputResult{Text: line} + } +} + +// captureOutput runs fn with stdout and stderr redirected and returns both. +func captureOutput(t *testing.T, fn func()) string { + t.Helper() + origOut, origErr := os.Stdout, os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout, os.Stderr = w, w + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + defer func() { + os.Stdout, os.Stderr = origOut, origErr + }() + fn() + _ = w.Close() + os.Stdout, os.Stderr = origOut, origErr + return <-done +} + +func newTestAgent(cfg *api.Config, ctx *api.SessionContext) *agent.Agent { + if cfg == nil { + cfg = api.DefaultConfig() + } + if ctx == nil { + ctx = &api.SessionContext{} + } + ag := agent.NewAgent(agent.AgentConfig{Model: api.ModelSonnet, Context: ctx}) + ag.AppConfig = cfg + return ag +} + +// runREPL drives Run with the scripted inputs and returns everything printed. +func runREPL(t *testing.T, ag *agent.Agent, cli agent.CLIAgent, lines ...string) string { + t.Helper() + scriptInputs(t, lines...) + finalized := false + out := captureOutput(t, func() { + Run(ag, cli, true, "9.9.9", func() { finalized = true }) + }) + if !finalized { + t.Fatal("Run returned without finalizing the receipt") + } + return out +} + +func assertContains(t *testing.T, out string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\n--- output ---\n%s", want, out) + } + } +} diff --git a/internal/repl/run_orch_test.go b/internal/repl/run_orch_test.go new file mode 100644 index 0000000..5be9a75 --- /dev/null +++ b/internal/repl/run_orch_test.go @@ -0,0 +1,367 @@ +package repl + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/update" +) + +// pickModel makes /orch return the given selection. +func pickModel(backend, model, effort string) { + showModelPicker = func(tui.ModelPickerOpts) tui.ModelPickerResult { + return tui.ModelPickerResult{Backend: backend, ModelID: model, Effort: effort, Confirmed: true} + } +} + +func TestOrchCancelAndPickerOptions(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + cfg.Backend = "cerebras" + cfg.CerebrasModel = api.CerebrasGemma4Model + cfg.CerebrasReasoningEffort = "none" + ag := newTestAgent(cfg, nil) + var seen tui.ModelPickerOpts + showModelPicker = func(opts tui.ModelPickerOpts) tui.ModelPickerResult { + seen = opts + return tui.ModelPickerResult{} + } + + runREPL(t, ag, nil, "/orch") + + if seen.CurrentBackend != "cerebras" || seen.Effort != "low" || seen.CurrentModelID != api.CerebrasGemma4Model { + t.Fatalf("picker opts = %+v", seen) + } + if len(seen.OpenCodeModels) == 0 || !strings.HasPrefix(seen.OpenCodeModels[0].ModelID, "enable:") { + t.Fatalf("with no providers enabled the picker should offer setup rows: %+v", seen.OpenCodeModels) + } + + cfg.Backend = "codex" + cfg.CodexModel = "gpt-5.6" + runREPL(t, ag, nil, "/orch") + if seen.CurrentModelID != "gpt-5.6" { + t.Fatalf("codex picker model = %q", seen.CurrentModelID) + } + + ag.Mode = agent.OllamaModeFull + runREPL(t, ag, nil, "/orch") + if seen.CurrentBackend != "ollama" { + t.Fatalf("ollama mode picker backend = %q", seen.CurrentBackend) + } +} + +func TestOrchListsEnabledProviderModelsWhenOpenCodeIsInstalled(t *testing.T) { + for _, installed := range []bool{false, true} { + name := "missing CLI" + if installed { + name = "installed CLI" + } + t.Run(name, func(t *testing.T) { + home := replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + cfg.EnabledProviders = []string{"groq"} + ag := newTestAgent(cfg, nil) + if installed { + bin := writeStub(t, filepath.Join(home, "bin"), "opencode", `printf '%s\n' 'groq/test-model'`) + findOpenCode = func() string { return bin } + } + var seen tui.ModelPickerOpts + showModelPicker = func(opts tui.ModelPickerOpts) tui.ModelPickerResult { + seen = opts + return tui.ModelPickerResult{} + } + + out := runREPL(t, ag, nil, "/orch") + if got := strings.Contains(out, "Querying opencode models"); got != installed { + t.Fatalf("model-query notice = %v, installed = %v", got, installed) + } + if seen.OpenCodeInstalled != installed { + t.Fatalf("picker CLI availability = %v, want %v", seen.OpenCodeInstalled, installed) + } + if installed && (len(seen.OpenCodeModels) != 1 || seen.OpenCodeModels[0].ModelID != "groq/test-model") { + t.Fatalf("enabled provider models did not reach the picker: %+v", seen.OpenCodeModels) + } + }) + } +} + +func TestOrchSelectsOllama(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + pickModel("ollama", "", "") + + out := runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Ollama not configured.") + + cfg.OllamaURL, cfg.OllamaModel = "file:///etc/passwd", "llama3.2:3b" + out = runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Ollama URL rejected") + + cfg.OllamaURL = "https://user:secret@llm.example.com" + cli := &fakeCLI{} + out = runREPL(t, ag, cli, "/orch") + assertContains(t, out, "Backend: Ollama model: llama3.2:3b") + if strings.Contains(out, "secret") { + t.Fatal("Ollama URL credentials were printed") + } + if ag.Mode != agent.OllamaModeFull || cli.cleaned == 0 { + t.Fatalf("mode=%v cleaned=%d", ag.Mode, cli.cleaned) + } +} + +func TestOrchSelectsCerebras(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + pickModel("cerebras", "gemma", "high") + + out := runREPL(t, ag, nil, "/orch") + assertContains(t, out, "No key entered — backend not changed.") + + readSecret = func(string) string { return "two words" } + out = runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Backend not changed.") + + readSecret = func(string) string { return "csk-test-key" } + out = runREPL(t, ag, &fakeCLI{}, "/orch") + assertContains(t, out, "Saved to OS keychain.", "Backend: Cerebras", "reasoning: high") + if cfg.Backend != "cerebras" || ag.Cerebras == nil { + t.Fatalf("backend=%q cerebras=%v", cfg.Backend, ag.Cerebras) + } + + pickModel("cerebras", api.CerebrasDefaultModel, "") + out = runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Backend: Cerebras model: "+api.CerebrasDefaultModel) +} + +func TestOrchSelectsAnthropicAPI(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + pickModel("", "auto", "medium") + + out := runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Anthropic API is not configured.") + + ag.Cfg.AnthropicKey = "sk-ant-test" + out = runREPL(t, ag, &fakeCLI{}, "/orch") + assertContains(t, out, "Backend: Anthropic API model: auto") + if !ag.Cfg.AutoRoute || cfg.DefaultModel != "auto" || cfg.Effort != "medium" { + t.Fatalf("auto route=%v default=%q effort=%q", ag.Cfg.AutoRoute, cfg.DefaultModel, cfg.Effort) + } +} + +func TestOrchCLIBackends(t *testing.T) { + home := replEnv(t) + stubSeams(t) + bin := filepath.Join(home, "bin") + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + for _, backend := range []string{"cc", "codex", "agy", "opencode"} { + pickModel(backend, "", "low") + out := runREPL(t, ag, nil, "/orch") + assertContains(t, out, "not found") + } + + claude := writeStub(t, bin, "claude", "exit 0") + codex := writeStub(t, bin, "codex", "exit 0") + agy := writeStub(t, bin, "agy", "exit 0") + opencode := writeStub(t, bin, "opencode", "exit 0") + findClaudeCode = func() string { return claude } + findCodex = func() string { return codex } + findAgy = func() string { return agy } + findOpenCode = func() string { return opencode } + + pickModel("cc", "opus", "high") + out := runREPL(t, ag, nil, "/orch") + assertContains(t, out, "Backend not changed.") + + promptOrchConsent = func(*api.Config, string) setup.ConsentResult { + return setup.ConsentResult{Proceed: true, PermissionMode: "unattended", GlobalInstall: true} + } + logins := 0 + promptAgyLogin = func(string) { logins++ } + + cases := []struct { + backend, model, want string + }{ + {"cc", "opus", "Backend: Claude Code model: opus effort: high"}, + {"codex", "gpt-5.6", "Backend: Codex model: gpt-5.6"}, + {"codex", "", "Backend: Codex model: Codex config"}, + {"agy", "", "Backend: Antigravity model: Antigravity config"}, + {"opencode", "groq/llama", "Backend: opencode model: groq/llama"}, + } + for _, tc := range cases { + pickModel(tc.backend, tc.model, "high") + out := runREPL(t, ag, &fakeCLI{}, "/orch") + assertContains(t, out, tc.want) + if cfg.Backend != tc.backend || ag.Cfg.Context.Backend != tc.backend { + t.Fatalf("%s: backend = %q/%q", tc.backend, cfg.Backend, ag.Cfg.Context.Backend) + } + if cfg.OrchPermissionMode != "unattended" { + t.Fatalf("%s: permission mode = %q", tc.backend, cfg.OrchPermissionMode) + } + } + if cfg.CodexModel != "" { + t.Fatalf("codex model = %q, want empty after default selection", cfg.CodexModel) + } + if logins != 1 { + t.Fatalf("agy login prompts = %d, want 1", logins) + } + + out = runREPL(t, ag, nil, "/reconnect") + assertContains(t, out, "No CC/Codex/Antigravity MCP transport is active") +} + +func TestOrchEnablesProviderFromSetupRow(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + pickModel("opencode-enable", "groq", "") + readSecret = func(string) string { return "gsk_testkey" } + + out := runREPL(t, ag, nil, "/orch") + + assertContains(t, out, "Enabled Groq", "opencode CLI isn't installed") + if !cfg.IsProviderEnabled("groq") { + t.Fatal("groq provider was not enabled") + } +} + +func TestProvidersCommand(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + out := runREPL(t, ag, nil, + "/providers enable", + "/providers disable", + "/providers frob x", + "/providers enable nope", + "/providers disable nope", + "/providers enable groq", + ) + assertContains(t, out, + "Usage: /providers enable ", + "Usage: /providers disable ", + `Unknown subcommand "frob"`, + `Unknown provider "nope". See /providers`, + `Unknown provider "nope".`, + "No key entered — provider not enabled.", + ) + + readSecret = func(string) string { return "bad key" } + out = runREPL(t, ag, nil, "/providers enable openrouter") + assertContains(t, out, "Not enabled.") + + readSecret = func(string) string { return "unprefixed-key" } + out = runREPL(t, ag, nil, "/providers enable openrouter") + assertContains(t, out, "saving anyway", "Enabled OpenRouter") + + var items []tui.ProviderPickerItem + showProviderPicker = func(in []tui.ProviderPickerItem) tui.ProviderPickerResult { + items = in + return tui.ProviderPickerResult{ID: "zai-coding-plan", Confirmed: true} + } + t.Setenv("OPENROUTER_API_KEY", "sk-or-env") + readSecret = func(string) string { return "" } + out = runREPL(t, ag, nil, "/providers") + statuses := map[string]string{} + for _, it := range items { + statuses[it.ID] = it.Status + } + if statuses["openrouter"] != "enabled" || statuses["groq"] != "available" { + t.Fatalf("provider statuses = %v", statuses) + } + assertContains(t, out, "provider not enabled") + + out = runREPL(t, ag, nil, "/providers disable openrouter") + assertContains(t, out, "Disabled openrouter.") + if cfg.IsProviderEnabled("openrouter") { + t.Fatal("openrouter still enabled after disable") + } +} + +func TestRunBannerAndUpdateProposal(t *testing.T) { + replEnv(t) + stubSeams(t) + id := session.GenerateSessionID() + if err := session.SaveSession(id, []api.Message{{Role: "user", Content: "hi"}}, 0, api.TokenUsage{}, api.ModelSonnet, api.ConversationState{}); err != nil { + t.Fatal(err) + } + maybeCheckUpdate = func(string) *update.Release { return &update.Release{Version: "99.0.0", Asset: "http://127.0.0.1:1/x"} } + answers := []string{"n", "yes"} + readConsent = func(*tui.Terminal) (string, error) { + a := answers[0] + answers = answers[1:] + return a, nil + } + ag := newTestAgent(nil, &api.SessionContext{Backend: "cc"}) + + for i := 0; i < 2; i++ { + scriptInputs(t) + out := captureOutput(t, func() { Run(ag, nil, false, "1.0.0", nil) }) + assertContains(t, out, "Session: ", "Update available: 99.0.0") + if i == 0 { + assertContains(t, out, "Recent session: "+id, "Skipped — /update checks again anytime.", "Agent SDK") + } else { + assertContains(t, out, "Update failed") + } + } +} + +func TestRunSkillsCommands(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "/skills", "/skills install") + + if !strings.Contains(strings.ToLower(out), "skill") { + t.Fatalf("skills output missing:\n%s", out) + } +} + +func TestRunImagesWithoutCLIUseEmbeddedBackend(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + ag.Cfg.AnthropicKey = "sk-ant-test" + img := &tui.ImageAttachment{MediaType: "image/png", Data: "aGk=", FileName: "shot.png"} + captureScreenshot = func() (*tui.ImageAttachment, error) { return img, nil } + // A cancelled context makes the embedded request fail fast without network. + runTurnViewport = func(_ *tui.Terminal, _ string, _ *tui.StatusInfo, cancel func(), run func()) tui.TurnInputResult { + done := make(chan struct{}) + go func() { + defer close(done) + run() + }() + select { + case <-done: + case <-time.After(50 * time.Millisecond): + cancel() + <-done + } + return tui.TurnInputResult{} + } + + out := runREPL(t, ag, nil, "/screenshot") + + assertContains(t, out, "Attached 1 image(s): shot.png", "offline test transport") +} diff --git a/internal/repl/run_test.go b/internal/repl/run_test.go new file mode 100644 index 0000000..6879f7d --- /dev/null +++ b/internal/repl/run_test.go @@ -0,0 +1,555 @@ +package repl + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/gate" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/update" +) + +func TestRunInformationalCommands(t *testing.T) { + replEnv(t) + stubSeams(t) + var gateBase string + runGate = func(_ context.Context, opts gate.Options) gate.Result { + gateBase = opts.Base + opts.OnProgress("checking diff") + return gate.Result{} + } + ag := newTestAgent(nil, &api.SessionContext{ProjectID: 7, GitInfo: &api.GitInfo{Branch: "feat", RemoteURL: "git@example.com:o/r.git", ChangedFiles: []string{"a.go"}}}) + + out := runREPL(t, ag, nil, + "", + "/help", + "/context", + "/status", + "/cost", + "/plan", + "/config", + "/gate develop", + "/gate a b", + "/queue", + "/queue write more tests", + "/queue clear", + "/project 42", + "/project nope", + "/reconnect", + "/disconnect", + "/ollama", + "/live status", + "/feed", + "/browserfeed", + "/browserfeed --half https://example.test/vnc.html", + "/q", + ) + + assertContains(t, out, + "Commands:", + "Mode: connected", + "Git branch: feat", + "Changed files: 1", + "No coding-plan window yet", + "Gate: checking diff", + "usage: /gate [base]", + "Queue is empty", + "queued [1]: write more tests", + "processing queued prompt", + "Queue was already empty.", + "Project set to #42", + "Invalid project ID", + "No CC/Codex/Antigravity MCP transport is active", + "Not connected.", + "Ollama not configured", + "Live feed is off.", + "No live feed URL captured yet.", + "Usage: /browserfeed", + "browserfeed: no feed in tests", + "Goodbye!", + ) + if gateBase != "develop" { + t.Fatalf("gate base = %q, want develop", gateBase) + } + if ag.Cfg.Context.ProjectID != 42 { + t.Fatalf("ProjectID = %d, want 42", ag.Cfg.Context.ProjectID) + } +} + +func TestRunLocalOnlyBlocksCloudCommands(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, &api.SessionContext{LocalOnly: true}) + + out := runREPL(t, ag, nil, + "/project 3", "/cloudsync", "/browserfeed https://x", "/live on", "/feed", "/connect", "/context", + ) + + for _, cmd := range []string{"/project", "/cloudsync", "/browserfeed", "/live", "/feed", "/connect"} { + assertContains(t, out, cmd+" is unavailable in standalone local-only mode.") + } + assertContains(t, out, "Mode: standalone local-only") +} + +func TestRunFeedOpensCapturedURLAndReportsFailure(t *testing.T) { + for _, fails := range []bool{false, true} { + name := "opens feed" + if fails { + name = "reports failure" + } + t.Run(name, func(t *testing.T) { + replEnv(t) + stubSeams(t) + const capturedURL = "https://example.test/vnc.html" + ag := newTestAgent(nil, &api.SessionContext{LastLiveURL: capturedURL}) + calls := 0 + openBrowserFeed = func(url string, mode blockMode) error { + calls++ + if url != capturedURL || mode != blockModeQuarter { + t.Errorf("feed target = %q, mode = %v", url, mode) + } + if fails { + return errors.New("feed disconnected") + } + return nil + } + + out := runREPL(t, ag, nil, "/feed") + if calls != 1 { + t.Fatalf("feed opened %d times, want once", calls) + } + if reported := strings.Contains(out, "browserfeed: feed disconnected"); reported != fails { + t.Fatalf("feed failure reported = %v, want %v", reported, fails) + } + }) + } +} + +func TestRunCtrlCAndOutputToggle(t *testing.T) { + replEnv(t) + stubSeams(t) + cli := &fakeCLI{} + ag := newTestAgent(nil, nil) + + scriptInputs(t, "", "", "") + out := captureOutput(t, func() { Run(ag, cli, true, "1.0.0", nil) }) + + assertContains(t, out, "Output mode: verbose", "Goodbye!") + if !cli.verbose { + t.Fatal("Ctrl+O did not propagate verbose output to the CLI backend") + } + if cli.cleaned == 0 { + t.Fatal("Run did not clean up the CLI backend on exit") + } +} + +func TestRunCLITurnRecordsUsageAndSavesSession(t *testing.T) { + home := replEnv(t) + stubSeams(t) + cli := &fakeCLI{response: "all tests pass", statsIn: 120, statsOut: 30} + ctx := &api.SessionContext{Backend: "cc"} + ag := newTestAgent(nil, ctx) + + out := runREPL(t, ag, cli, "run the tests", "/plan", "/save", "/clear") + + if len(cli.prompts) != 1 || !strings.Contains(cli.prompts[0], "run the tests") { + t.Fatalf("CLI prompts = %q", cli.prompts) + } + if ag.Usage.InputTokens != 0 { + // /clear keeps usage; only history is cleared. + t.Logf("usage after clear: %+v", ag.Usage) + } + assertContains(t, out, "Conversation cleared.", "saved.") + entries, err := os.ReadDir(filepath.Join(home, ".qmax-code", "sessions")) + if err != nil || len(entries) == 0 { + t.Fatalf("expected a saved session file, err=%v entries=%d", err, len(entries)) + } + if cli.resets == 0 { + t.Fatal("/clear did not reset the CLI conversation") + } +} + +func TestRunCLITurnErrorIsReported(t *testing.T) { + replEnv(t) + stubSeams(t) + cli := &fakeCLI{err: errors.New("subprocess exited 1"), limitHit: true, limitTime: time.Now().Add(time.Hour)} + ag := newTestAgent(nil, &api.SessionContext{Backend: "codex"}) + ag.AppConfig.CodexModel = "gpt-test" + + out := runREPL(t, ag, cli, "do work", "/plan") + + assertContains(t, out, "subprocess exited 1") +} + +func TestRunQueuedPromptFromTurnViewport(t *testing.T) { + replEnv(t) + stubSeams(t) + cli := &fakeCLI{response: "done"} + ag := newTestAgent(nil, nil) + calls := 0 + runTurnViewport = func(_ *tui.Terminal, _ string, _ *tui.StatusInfo, _ func(), run func()) tui.TurnInputResult { + run() + calls++ + if calls == 1 { + return tui.TurnInputResult{Text: "follow-up prompt"} + } + return tui.TurnInputResult{} + } + + out := runREPL(t, ag, cli, "first prompt") + + assertContains(t, out, "queued [1]: follow-up prompt", "processing queued prompt") + if len(cli.prompts) != 2 { + t.Fatalf("CLI ran %d prompts, want 2 (typed + queued)", len(cli.prompts)) + } +} + +func TestRunWithoutInferenceRefusesTurn(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "hello there") + + assertContains(t, out, "No inference backend configured") +} + +func TestRunScreenshotAndPaste(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "/screenshot", "/paste") + assertContains(t, out, "no display", "Nothing in clipboard") + + img := &tui.ImageAttachment{MediaType: "image/png", Data: "aGk=", FileName: "clip.png"} + captureScreenshot = func() (*tui.ImageAttachment, error) { return img, nil } + pasteImage = func() (*tui.ImageAttachment, error) { return img, nil } + out = runREPL(t, ag, nil, "/screenshot", "/paste") + assertContains(t, out, "Screenshot captured (clip.png)", "Pasted image from clipboard (clip.png)", "No inference backend configured") + + pasteImage = func() (*tui.ImageAttachment, error) { return nil, errors.New("none") } + pasteText = func() (string, error) { return "pasted words", nil } + cli := &fakeCLI{response: "ok"} + out = runREPL(t, ag, cli, "/paste") + assertContains(t, out, "Pasted 12 chars from clipboard") + if len(cli.prompts) != 1 || !strings.Contains(cli.prompts[0], "pasted words") { + t.Fatalf("pasted text was not sent to the backend: %q", cli.prompts) + } +} + +func TestRunLargePasteIsSavedToFile(t *testing.T) { + replEnv(t) + stubSeams(t) + cli := &fakeCLI{response: "ok"} + ag := newTestAgent(nil, nil) + big := strings.Repeat("line of pasted log output\n", 400) + pasteText = func() (string, error) { return big, nil } + + out := runREPL(t, ag, cli, "/paste") + + assertContains(t, out, "Large paste saved as pasted_file:") + if len(cli.prompts) != 1 || strings.Contains(cli.prompts[0], big) { + t.Fatal("large paste should reach the backend as a file reference, not inline") + } +} + +func TestRunUpdateCommand(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "/update") + assertContains(t, out, "qmax-code 9.9.9 is up to date") + + checkUpdate = func(string) (*update.Release, error) { return nil, errors.New("offline") } + out = runREPL(t, ag, nil, "/update") + assertContains(t, out, "offline") + + checkUpdate = func(string) (*update.Release, error) { + return &update.Release{Version: "10.0.0", Asset: "http://127.0.0.1:1/missing"}, nil + } + out = runREPL(t, ag, nil, "/update") + assertContains(t, out, "Update available: 10.0.0", "Update failed") +} + +func TestRunResumeAndSessions(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "/resume", "/resume ../etc/passwd", "/sessions") + assertContains(t, out, "Cannot resume", `invalid session ID "../etc/passwd"`, "No saved sessions.") + + id := session.GenerateSessionID() + msgs := []api.Message{{Role: "user", Content: "earlier"}, {Role: "assistant", Content: "reply"}} + if err := session.SaveSession(id, msgs, 5, api.TokenUsage{InputTokens: 10}, api.ModelSonnet, api.ConversationState{}); err != nil { + t.Fatal(err) + } + + ag = newTestAgent(nil, nil) + out = runREPL(t, ag, nil, "/resume "+id) + assertContains(t, out, "Resumed session "+id) + if ag.Cfg.Context.ProjectID != 5 { + t.Fatalf("resume did not restore project: %d", ag.Cfg.Context.ProjectID) + } + + ag = newTestAgent(nil, nil) + out = runREPL(t, ag, nil, "/resume last") + assertContains(t, out, "Resumed session") + + showSessionPicker = func(items []tui.SessionPickerItem, _ string) (string, bool) { + if len(items) == 0 { + t.Fatal("session picker received no sessions") + } + return items[0].ID, true + } + ag = newTestAgent(nil, nil) + out = runREPL(t, ag, nil, "/sessions") + assertContains(t, out, "Resumed session") + + showSessionPicker = func([]tui.SessionPickerItem, string) (string, bool) { return "../bad", true } + out = runREPL(t, ag, nil, "/sessions") + assertContains(t, out, `Invalid session ID "../bad"`) +} + +func TestRunThemeCloudSyncAndLivePickers(t *testing.T) { + replEnv(t) + stubSeams(t) + t.Cleanup(func() { tui.ApplyTheme(tui.ThemeByName("")) }) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + showThemePicker = func(string) (string, bool) { return tui.ThemeNames()[0], true } + showCloudSyncPicker = func(*bool) (bool, bool) { return true, true } + showLiveFeedPicker = func(current bool) (bool, bool) { return !current, true } + + out := runREPL(t, ag, nil, "/theme", "/cloudsync", "/live", "/live on", "/live off", "/live bogus") + + assertContains(t, out, + "Theme set to: "+tui.ThemeNames()[0], + "Cloud session sync enabled.", + "Live feed enabled.", + "Live feed disabled.", + "Usage: /live", + ) + if cfg.CloudSync == nil || !*cfg.CloudSync { + t.Fatal("/cloudsync did not enable sync") + } + + showCloudSyncPicker = func(*bool) (bool, bool) { return false, true } + showLiveFeedPicker = func(current bool) (bool, bool) { return current, true } + out = runREPL(t, ag, nil, "/cloudsync", "/live") + assertContains(t, out, "Cloud session sync disabled.", "Live feed unchanged (off).") +} + +func TestRunNilConfigCommands(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + ag.AppConfig = nil + + out := runREPL(t, ag, nil, "/orch", "/theme", "/cloudsync", "/cc", "/gemma", "/live", "/providers") + + if n := strings.Count(out, "api.Config not loaded."); n != 7 { + t.Fatalf("got %d 'not loaded' errors, want 7\n%s", n, out) + } +} + +func TestRunSetAndSettings(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + showSettingsPicker = func(rows []tui.SettingsRow) tui.SettingsPickerResult { + return tui.SettingsPickerResult{Confirmed: true, Changes: map[string]string{"budget": "500", "autosave": "false"}} + } + + out := runREPL(t, ag, nil, "/set budget 250", "/set", "/settings") + + assertContains(t, out, "Token budget set to: 250", "Token budget set to: 500", "Auto-save disabled.") + if loaded := api.LoadQMaxCodeConfig(); loaded.MaxTokenBudget != 500 || loaded.AutoSave { + t.Fatalf("settings not persisted: budget=%d autosave=%v", loaded.MaxTokenBudget, loaded.AutoSave) + } +} + +func TestRunConnectAndKeys(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + + out := runREPL(t, ag, nil, "/connect", "/keys") + assertContains(t, out, "Connection failed", "/set apikey", "API Keys", "Anthropic: ") + + loginViaBrowser = func() (*api.AuthConfig, error) { + return &api.AuthConfig{APIKey: "qm-test", Email: "qa@example.com", CloudURL: "http://127.0.0.1:1"}, nil + } + out = runREPL(t, ag, nil, "/connect", "/connect", "/disconnect") + assertContains(t, out, "Connected as qa@example.com", "Already connected as qa@example.com", "Disconnected from qa@example.com") + + promptChoice = func(string, []string) int { return 0 } + readSecret = func(string) string { return "sk-ant-api03-testkey-0000" } + out = runREPL(t, ag, nil, "/keys") + if ag.Cfg.AnthropicKey != "sk-ant-api03-testkey-0000" { + t.Fatalf("/keys did not set the Anthropic key") + } + assertContains(t, out, "Key saved to OS keychain!") + + readSecret = func(string) string { return "" } + out = runREPL(t, ag, nil, "/keys") + assertContains(t, out, "Cancelled.", "● Set") + + promptChoice = func(string, []string) int { return 1 } + out = runREPL(t, ag, nil, "/keys") + assertContains(t, out, "Connected as qa@example.com") + promptChoice = func(string, []string) int { return 2 } + out = runREPL(t, ag, nil, "/keys") + assertContains(t, out, "Disconnected from qa@example.com") +} + +func TestRunBackendSwitchCommands(t *testing.T) { + home := replEnv(t) + stubSeams(t) + bin := filepath.Join(home, "bin") + claude := writeStub(t, bin, "claude", "exit 0") + codex := writeStub(t, bin, "codex", "exit 0") + agy := writeStub(t, bin, "agy", "exit 0") + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + // Nothing installed, no key: every switch is refused. + out := runREPL(t, ag, nil, "/cc", "/codex", "/agy", "/opencode", "/api") + assertContains(t, out, + "'claude' CLI not found", + "'codex' CLI not found", + "'agy' CLI not found", + "'opencode' CLI not found", + "Anthropic API is not configured.", + ) + + findClaudeCode = func() string { return claude } + findCodex = func() string { return codex } + findAgy = func() string { return agy } + + // Installed but consent declined. + out = runREPL(t, ag, nil, "/cc", "/codex", "/agy") + if n := strings.Count(out, "Backend not changed."); n != 3 { + t.Fatalf("declined consent count = %d, want 3\n%s", n, out) + } + + promptOrchConsent = func(*api.Config, string) setup.ConsentResult { + return setup.ConsentResult{Proceed: true, PermissionMode: "standard"} + } + agyLogins := 0 + promptAgyLogin = func(string) { agyLogins++ } + out = runREPL(t, ag, nil, "/cc") + assertContains(t, out, "Backend → Claude Code") + if cfg.Backend != "cc" || ag.Cfg.Context.Backend != "cc" { + t.Fatalf("backend = %q/%q, want cc", cfg.Backend, ag.Cfg.Context.Backend) + } + + out = runREPL(t, ag, nil, "/codex", "/agy") + assertContains(t, out, "Backend → Codex", "Backend → Antigravity") + if agyLogins != 1 { + t.Fatalf("agy login prompts = %d, want 1", agyLogins) + } + + // Toggling the active backend off needs an Anthropic key. + cfg.Backend = "agy" + ag.Cfg.AnthropicKey = "sk-ant-test" + out = runREPL(t, ag, nil, "/agy") + assertContains(t, out, "Backend → Anthropic API (direct)") + if cfg.Backend != "" { + t.Fatalf("toggle did not return to the API backend: %q", cfg.Backend) + } +} + +func TestRunOpenCodeSwitchPreflight(t *testing.T) { + home := replEnv(t) + stubSeams(t) + opencode := writeStub(t, filepath.Join(home, "bin"), "opencode", ` +if [ "$1" = "models" ]; then echo "groq/llama-3.3-70b"; echo "groq/qwen-qwq"; exit 0; fi +exit 0`) + findOpenCode = func() string { return opencode } + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + out := runREPL(t, ag, nil, "/opencode") + assertContains(t, out, "No opencode providers enabled yet.") + + cfg.EnableProviderID("groq") + t.Setenv("GROQ_API_KEY", "gsk_test") + out = runREPL(t, ag, nil, "/opencode") + assertContains(t, out, "Backend not changed.") + + promptOrchConsent = func(*api.Config, string) setup.ConsentResult { + return setup.ConsentResult{Proceed: true, PermissionMode: "standard"} + } + cfg.ModelOverride = "gpt-5.6" + out = runREPL(t, ag, nil, "/opencode") + assertContains(t, out, "Backend → opencode") + if !strings.HasPrefix(cfg.ModelOverride, "groq/") { + t.Fatalf("stale model override was not replaced: %q", cfg.ModelOverride) + } +} + +func TestRunGemmaCommand(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + ag := newTestAgent(cfg, nil) + + out := runREPL(t, ag, nil, "/gemma bogus", "/gemma", "/gemma off") + assertContains(t, out, `"bogus" is invalid`, "No key entered — Gemma not activated.", "Anthropic API is not configured.") + + readSecret = func(string) string { return "has space" } + out = runREPL(t, ag, nil, "/gemma") + assertContains(t, out, "Gemma not activated.") + + readSecret = func(string) string { return "not-a-csk-key" } + out = runREPL(t, ag, nil, "/gemma high") + assertContains(t, out, "saving anyway", "Backend: Cerebras", "reasoning: high") + if cfg.Backend != "cerebras" || ag.Cerebras == nil { + t.Fatalf("gemma did not activate cerebras: backend=%q", cfg.Backend) + } + + out = runREPL(t, ag, nil, "/gemma none") + assertContains(t, out, "reasoning off") + + ag.Cfg.AnthropicKey = "sk-ant-test" + out = runREPL(t, ag, &fakeCLI{}, "/gemma off") + assertContains(t, out, "Gemma disabled → Anthropic API (direct)") + if ag.Cerebras != nil || cfg.Backend != "" { + t.Fatal("/gemma off did not deactivate cerebras") + } +} + +func TestRunOllamaModeCycle(t *testing.T) { + replEnv(t) + stubSeams(t) + cfg := api.DefaultConfig() + cfg.OllamaURL = "ftp://bad" + cfg.OllamaModel = "llama3.2:3b" + ag := newTestAgent(cfg, nil) + + out := runREPL(t, ag, nil, "/ollama") + assertContains(t, out, "Ollama URL rejected") + + cfg.OllamaURL = "http://127.0.0.1:11434" + out = runREPL(t, ag, &fakeCLI{}, "/ollama") + assertContains(t, out, "Ollama remains in FULL mode") + + ag.Cfg.AnthropicKey = "sk-ant-test" + ag.Mode = agent.OllamaModeOff + out = runREPL(t, ag, nil, "/ollama", "/ollama", "/ollama") + assertContains(t, out, "Ollama: CHAT mode", "Ollama: FULL mode", "Ollama: OFF") +} diff --git a/internal/repl/settings_table_test.go b/internal/repl/settings_table_test.go new file mode 100644 index 0000000..507dacb --- /dev/null +++ b/internal/repl/settings_table_test.go @@ -0,0 +1,322 @@ +package repl + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +func TestApplySettingValueTable(t *testing.T) { + home := replEnv(t) + stubSeams(t) + t.Cleanup(func() { tui.ApplyTheme(tui.ThemeByName("")) }) + claude := writeStub(t, filepath.Join(home, "bin"), "claude", "exit 0") + + type check func(*testing.T, *agent.Agent) + cases := []struct { + key, value string + local bool + want settingResult + check check + }{ + {key: "local_only", value: "on", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if !ag.AppConfig.LocalOnly { + t.Error("local_only not set") + } + }}, + {key: "local", value: "off", want: settingApplied}, + {key: "local-only", value: "maybe", want: settingInvalid}, + {key: "model", value: "opus", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if ag.AppConfig.DefaultModel == "" || ag.AppConfig.DefaultModel == "auto" { + t.Errorf("model = %q", ag.AppConfig.DefaultModel) + } + }}, + {key: "model", value: "gpt-4", want: settingInvalid}, + {key: "project", value: "5", local: true, want: settingInvalid}, + {key: "professional", value: "yes", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if !ag.Cfg.Professional { + t.Error("professional not applied to the live agent") + } + }}, + {key: "professional", value: "no", want: settingApplied}, + {key: "professional", value: "?", want: settingInvalid}, + {key: "autosave", value: "1", want: settingApplied}, + {key: "autosave", value: "0", want: settingApplied}, + {key: "autosave", value: "x", want: settingInvalid}, + {key: "output", value: "verbose", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if !ag.Cfg.OutputVerbose { + t.Error("verbose output not applied") + } + }}, + {key: "output-verbose", value: "compact", want: settingApplied}, + {key: "output_verbose", value: "loud", want: settingInvalid}, + {key: "cloud_sync", value: "on", local: true, want: settingInvalid}, + {key: "cloudsync", value: "nah", want: settingInvalid}, + {key: "live_feed", value: "on", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if !ag.Cfg.Context.LiveFeed { + t.Error("live feed not applied to the session") + } + }}, + {key: "live-feed", value: "off", want: settingApplied}, + {key: "livefeed", value: "sometimes", want: settingInvalid}, + {key: "live_feed", value: "on", local: true, want: settingInvalid}, + {key: "budget", value: "-1", want: settingInvalid}, + {key: "apikey", value: "qm-x", local: true, want: settingInvalid}, + {key: "apikey", value: "", want: settingInvalid}, + {key: "ollama", value: "on", want: settingInvalid}, + {key: "ollama", value: "maybe", want: settingInvalid}, + {key: "ollama", value: "off", want: settingAppliedNoSave}, + {key: "backend", value: "cc", want: settingInvalid}, + {key: "backend", value: "codex", want: settingInvalid}, + {key: "backend", value: "agy", want: settingInvalid}, + {key: "backend", value: "api", want: settingApplied}, + {key: "backend", value: "cerebras", want: settingInvalid}, + {key: "cerebras_model", value: "gemma", want: settingApplied, check: func(t *testing.T, ag *agent.Agent) { + if ag.AppConfig.CerebrasModel != api.CerebrasGemma4Model { + t.Errorf("cerebras model = %q", ag.AppConfig.CerebrasModel) + } + }}, + {key: "cerebras_reasoning_effort", value: "high", want: settingApplied}, + {key: "cerebras-reasoning-effort", value: "", want: settingApplied}, + {key: "cerebras_reasoning_effort", value: "ultra", want: settingInvalid}, + {key: "theme", value: "no-such-theme", want: settingInvalid}, + {key: "theme", value: tui.ThemeNames()[0], want: settingApplied}, + {key: "anthropic_key", value: "sk-ant-api03-table", want: settingAppliedNoSave, check: func(t *testing.T, ag *agent.Agent) { + if ag.Cfg.AnthropicKey != "sk-ant-api03-table" { + t.Error("anthropic key not applied") + } + }}, + {key: "frobnicate", value: "1", want: settingInvalid}, + } + for _, tc := range cases { + t.Run(tc.key+"="+tc.value, func(t *testing.T) { + ag := newTestAgent(nil, &api.SessionContext{LocalOnly: tc.local}) + var got settingResult + captureOutput(t, func() { got = applySettingValue(tc.key, tc.value, ag, &tui.Terminal{}) }) + if got != tc.want { + t.Fatalf("applySettingValue(%q, %q) = %v, want %v", tc.key, tc.value, got, tc.want) + } + if tc.check != nil { + tc.check(t, ag) + } + }) + } + + t.Run("backend cc when installed", func(t *testing.T) { + findClaudeCode = func() string { return claude } + ag := newTestAgent(nil, nil) + captureOutput(t, func() { + if got := applySettingValue("backend", "cc", ag, &tui.Terminal{}); got != settingApplied || ag.AppConfig.Backend != "cc" { + t.Fatalf("backend cc = %v (%q)", got, ag.AppConfig.Backend) + } + }) + findCodex = func() string { return claude } + findAgy = func() string { return claude } + captureOutput(t, func() { + applySettingValue("backend", "codex", ag, &tui.Terminal{}) + applySettingValue("backend", "agy", ag, &tui.Terminal{}) + }) + if ag.AppConfig.Backend != "agy" { + t.Fatalf("backend = %q, want agy", ag.AppConfig.Backend) + } + }) + + t.Run("ollama on and live cerebras updates", func(t *testing.T) { + cfg := api.DefaultConfig() + cfg.OllamaURL, cfg.OllamaModel = "http://127.0.0.1:11434", "llama3.2:3b" + ag := newTestAgent(cfg, nil) + ag.Cerebras = &agent.CerebrasClient{} + captureOutput(t, func() { + if got := applySettingValue("ollama", "enabled", ag, &tui.Terminal{}); got != settingAppliedNoSave || ag.Ollama == nil { + t.Fatalf("ollama on = %v", got) + } + applySettingValue("cerebras_model", "gemma", ag, &tui.Terminal{}) + applySettingValue("cerebras_reasoning_effort", "low", ag, &tui.Terminal{}) + }) + if ag.Cerebras.Model != api.CerebrasGemma4Model || ag.Cerebras.ReasoningEffort != "low" { + t.Fatalf("live cerebras = %+v", ag.Cerebras) + } + cfg.OllamaURL = "javascript:alert(1)" + captureOutput(t, func() { + if got := applySettingValue("ollama", "on", ag, &tui.Terminal{}); got != settingInvalid { + t.Fatalf("bad ollama URL = %v", got) + } + }) + }) + + t.Run("ollama off refused without fallback", func(t *testing.T) { + ag := newTestAgent(nil, &api.SessionContext{LocalOnly: true}) + captureOutput(t, func() { + if got := applySettingValue("ollama", "off", ag, &tui.Terminal{}); got != settingInvalid { + t.Fatalf("ollama off with no fallback = %v", got) + } + }) + }) + + t.Run("nil config is created", func(t *testing.T) { + ag := newTestAgent(nil, nil) + ag.AppConfig = nil + captureOutput(t, func() { applySettingValue("budget", "10", ag, &tui.Terminal{}) }) + if ag.AppConfig == nil || ag.AppConfig.MaxTokenBudget != 10 { + t.Fatal("applySettingValue did not create a config") + } + }) +} + +func TestHandleSetCommandPromptsForSecrets(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + term := &tui.Terminal{} + + out := captureOutput(t, func() { + handleSetCommand("/set anthropic_key", ag, term) + handleSetCommand("/set budget", ag, term) + }) + assertContains(t, out, "No value entered — nothing changed.", "Usage: /set ") + + readSecret = func(string) string { return "sk-ant-api03-hidden" } + captureOutput(t, func() { handleSetCommand("/set anthropic-key", ag, term) }) + if ag.Cfg.AnthropicKey != "sk-ant-api03-hidden" { + t.Fatal("hidden prompt value was not applied") + } + + ag.AppConfig = nil + out = captureOutput(t, func() { applyAndSaveSetting("budget", "77", ag, term) }) + assertContains(t, out, "api.Config saved") +} + +func TestRunSettingsPickerBranches(t *testing.T) { + replEnv(t) + stubSeams(t) + ag := newTestAgent(nil, nil) + ag.AppConfig = nil + term := &tui.Terminal{} + + out := captureOutput(t, func() { runSettingsPicker(ag, term) }) + assertContains(t, out, "Settings unchanged.") + + showSettingsPicker = func(rows []tui.SettingsRow) tui.SettingsPickerResult { + for _, r := range rows { + if r.Display != nil { + _ = r.Display("") + _ = r.Display("true") + _ = r.Display("verbose") + } + } + return tui.SettingsPickerResult{Confirmed: true, Changes: map[string]string{"budget": "bogus"}} + } + out = captureOutput(t, func() { runSettingsPicker(ag, term) }) + if strings.Contains(out, "api.Config saved") { + t.Fatalf("invalid change should not save:\n%s", out) + } +} + +func TestPrintConfigInfoVariants(t *testing.T) { + replEnv(t) + term := &tui.Terminal{} + out := captureOutput(t, func() { printConfigInfo(nil, nil, term) }) + assertContains(t, out, "No config loaded") + + cfg := api.DefaultConfig() + on, off := true, false + cfg.CloudSync = &on + cfg.LiveFeed = true + cfg.OutputVerbose = true + cfg.OllamaURL, cfg.OllamaModel = "https://u:p@llm.example.com", "m" + out = captureOutput(t, func() { printConfigInfo(cfg, &api.SessionContext{LocalOnly: true}, term) }) + assertContains(t, out, "standalone local-only", "verbose", "enabled", "on (test/crawl", "Ollama model:") + + cfg.CloudSync = &off + out = captureOutput(t, func() { printConfigInfo(cfg, nil, term) }) + assertContains(t, out, "disabled") +} + +func TestToggleOutputVerboseCreatesConfig(t *testing.T) { + replEnv(t) + toggleOutputVerbose(nil, nil, &tui.Terminal{}) + ag := newTestAgent(nil, nil) + ag.AppConfig = nil + out := captureOutput(t, func() { toggleOutputVerbose(ag, nil, &tui.Terminal{}) }) + if ag.AppConfig == nil || !ag.AppConfig.OutputVerbose { + t.Fatal("toggle did not create and flip the config") + } + assertContains(t, out, "Output mode: verbose") + + t.Setenv("HOME", "/dev/null/nope") + out = captureOutput(t, func() { toggleOutputVerbose(ag, nil, &tui.Terminal{}) }) + assertContains(t, out, "could not save config") +} + +func TestHandleDisconnectClearsLegacyToken(t *testing.T) { + home := replEnv(t) + legacy := filepath.Join(home, ".qamax", "config.json") + if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacy, []byte(`{"token":"t","api_key":"k","cloud_url":"u"}`), 0o600); err != nil { + t.Fatal(err) + } + ag := newTestAgent(nil, &api.SessionContext{Auth: &api.AuthConfig{APIKey: "qm-k", Email: "me@example.com"}}) + + out := captureOutput(t, func() { handleDisconnect(ag, &tui.Terminal{}) }) + + assertContains(t, out, "Disconnected from me@example.com") + data, err := os.ReadFile(legacy) + if err != nil { + t.Fatal(err) + } + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if m["token"] != "" || m["api_key"] != "" || m["cloud_url"] != "u" { + t.Fatalf("legacy config = %v", m) + } +} + +func TestReconnectMCPTransportPerBackend(t *testing.T) { + replEnv(t) + ctx := &api.SessionContext{} + term := &tui.Terminal{} + for _, cli := range []agent.CLIAgent{ + agent.NewCCAgent("claude", "", "", "standard", false, "", ctx), + agent.NewCodexAgent("codex", "", "", false, "", ctx), + agent.NewAgyAgent("agy", "", "", "standard", false, "", ctx), + } { + out := captureOutput(t, func() { reconnectMCPTransport(cli, term) }) + if !strings.Contains(out, "transport restored") && !strings.Contains(out, "Could not restore") { + t.Fatalf("%T: unexpected output %q", cli, out) + } + } +} + +func TestFormatDurationAndLabels(t *testing.T) { + cases := []struct{ got, want string }{ + {formatDuration(42 * time.Second), "42s"}, + {formatDuration(5 * time.Minute), "5m"}, + {formatDuration(3 * time.Hour), "3h"}, + {codexModelLabel(""), "Codex config"}, + {codexModelLabel("m"), "m"}, + {agyModelLabel(""), "Antigravity config"}, + {agyModelLabel("g"), "g"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("got %q, want %q", c.got, c.want) + } + } + if isPlanBackend("api") || !isPlanBackend("agy") { + t.Error("isPlanBackend") + } + if currentBackend(nil) != "" { + t.Error("currentBackend(nil)") + } +} diff --git a/internal/repl/settings_test.go b/internal/repl/settings_test.go index 9f7f1f1..0d021f3 100644 --- a/internal/repl/settings_test.go +++ b/internal/repl/settings_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/tui" ) // TestApplySettingStrictNumericParsing pins the Sscanf-to-Atoi fix: partial diff --git a/internal/session/cloud_session.go b/internal/session/cloud_session.go index 973942a..39d91b1 100644 --- a/internal/session/cloud_session.go +++ b/internal/session/cloud_session.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // PromptCloudSyncConsent asks the user once whether they want sessions synced diff --git a/internal/session/cloud_session_more_test.go b/internal/session/cloud_session_more_test.go new file mode 100644 index 0000000..8cb93af --- /dev/null +++ b/internal/session/cloud_session_more_test.go @@ -0,0 +1,154 @@ +package session + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func TestPromptCloudSyncConsentPersistsAnswer(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + {name: "yes enables sync", line: "y\n", want: true}, + {name: "no disables sync", line: "n\n", want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + withTempHome(t) + + cfg := api.DefaultConfig() + got := PromptCloudSyncConsent(cfg, func() (string, error) { return tt.line, nil }) + if got != tt.want { + t.Fatalf("PromptCloudSyncConsent = %v, want %v", got, tt.want) + } + if cfg.CloudSync == nil || *cfg.CloudSync != tt.want { + t.Fatalf("cfg.CloudSync = %v, want %v", cfg.CloudSync, tt.want) + } + + loaded := api.LoadQMaxCodeConfig() + if loaded.CloudSync == nil || *loaded.CloudSync != tt.want { + t.Fatalf("choice not persisted; loaded CloudSync = %v, want %v", loaded.CloudSync, tt.want) + } + }) + } +} + +func TestCloudTracker_SwitchProject_Guards(t *testing.T) { + t.Run("nil client is not a switch", func(t *testing.T) { + tracker := CloudSessionTracker{cloudID: "c", projectID: 1} + if tracker.SwitchProject(nil, 2, "m", 0, nil) { + t.Fatal("nil client must not report a project switch") + } + }) + + t.Run("unstarted tracker opens first session without switching", func(t *testing.T) { + creates := 0 + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/agent-sessions" { + creates++ + _, _ = w.Write([]byte(`{"session_id":"cloud-first"}`)) + return + } + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + }) + + var tracker CloudSessionTracker + msgs := []api.Message{{Role: "user", Content: "hi"}} + if tracker.SwitchProject(client, 5, "m", 10, msgs) { + t.Fatal("opening the first session must return false (nothing moved)") + } + if creates != 1 { + t.Fatalf("creates = %d, want 1", creates) + } + if tracker.cloudID != "cloud-first" || tracker.projectID != 5 || tracker.historyStart != len(msgs) { + t.Fatalf("tracker state after first open = %+v", tracker) + } + }) + + t.Run("same project is a no-op", func(t *testing.T) { + calls := 0 + client, _ := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + calls++ + _, _ = w.Write([]byte(`{}`)) + }) + + tracker := CloudSessionTracker{cloudID: "cloud-a", projectID: 5} + if tracker.SwitchProject(client, 5, "m", 10, nil) { + t.Fatal("switching to the current project must return false") + } + if calls != 0 { + t.Fatalf("same-project switch made %d HTTP calls, want 0", calls) + } + }) +} + +func TestCloudTracker_StartWithHistoryRecordsOffsets(t *testing.T) { + client, _ := newTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"session_id":"off-1"}`)) + }) + + var tracker CloudSessionTracker + tracker.StartWithHistory(client, 9, "model", 4, 100) + + if tracker.cloudID != "off-1" || tracker.projectID != 9 { + t.Fatalf("session not started: %+v", tracker) + } + if tracker.historyStart != 4 || tracker.tokenStart != 100 { + t.Fatalf("history/token offsets not recorded: %+v", tracker) + } +} + +func TestCloudTracker_CompleteCurrent_ClampsNegativeStartAndTokenDelta(t *testing.T) { + var patchedTokens []int + var eventCounts []int + client, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch: + var body struct { + TotalTokens int `json:"total_tokens"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode finalize request: %v", err) + } + patchedTokens = append(patchedTokens, body.TotalTokens) + _, _ = w.Write([]byte("{}")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/events"): + var body struct { + Events []json.RawMessage `json:"events"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode events request: %v", err) + } + eventCounts = append(eventCounts, len(body.Events)) + _, _ = w.Write([]byte("{}")) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + _, _ = w.Write([]byte("{}")) + } + }) + + // A negative historyStart (from a /load shrinking the conversation) and a + // token delta below the session's start must both clamp to zero. + tracker := CloudSessionTracker{cloudID: "neg", historyStart: -5, tokenStart: 500} + msgs := []api.Message{ + {Role: "user", Content: "a"}, + {Role: "assistant", Content: "b"}, + } + tracker.CompleteCurrent(client, 100, msgs) + + if len(patchedTokens) != 1 || patchedTokens[0] != 0 { + t.Fatalf("patched total_tokens = %v, want [0] (negative delta clamped)", patchedTokens) + } + if len(eventCounts) != 1 || eventCounts[0] != 2 { + t.Fatalf("uploaded event counts = %v, want [2] (negative start clamped to 0)", eventCounts) + } +} diff --git a/internal/session/cloud_session_test.go b/internal/session/cloud_session_test.go index a7d9389..06ff9c4 100644 --- a/internal/session/cloud_session_test.go +++ b/internal/session/cloud_session_test.go @@ -4,10 +4,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "os" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // newTestClient wires an api.APIClient to a local httptest.Server so callers @@ -25,14 +24,13 @@ func newTestClient(t *testing.T, handler http.HandlerFunc) (*api.APIClient, *htt }, srv } -// withTempHome points $HOME at a fresh temp dir for the test and restores it -// in cleanup. Local copy of the helper in main package's config_command_test.go. +// withTempHome isolates preferences and prevents access to OS credentials. func withTempHome(t *testing.T) string { t.Helper() - orig := os.Getenv("HOME") tmp := t.TempDir() - os.Setenv("HOME", tmp) - t.Cleanup(func() { os.Setenv("HOME", orig) }) + t.Setenv("HOME", tmp) + t.Setenv("PATH", "") + t.Setenv("CEREBRAS_API_KEY", "") return tmp } diff --git a/internal/session/conversation_test.go b/internal/session/conversation_test.go index 6f9e307..e587216 100644 --- a/internal/session/conversation_test.go +++ b/internal/session/conversation_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestDurableSessionIsAtomicPrivateAndRetained(t *testing.T) { diff --git a/internal/session/session.go b/internal/session/session.go index e842c43..13d221d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -12,8 +12,8 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/security" ) // Session represents a saved conversation session. diff --git a/internal/session/session_edge_test.go b/internal/session/session_edge_test.go new file mode 100644 index 0000000..ea5677c --- /dev/null +++ b/internal/session/session_edge_test.go @@ -0,0 +1,281 @@ +package session + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func TestSessionFilePathValidatesIDs(t *testing.T) { + for _, bad := range []string{"", "../escape", "a/b", "spaces here", "with.dot", strings.Repeat("a", 65)} { + if got := sessionFilePath(bad); got != "" { + t.Errorf("sessionFilePath(%q) = %q, want empty for invalid ID", bad, got) + } + if IsValidSessionID(bad) { + t.Errorf("IsValidSessionID(%q) = true, want false", bad) + } + } + if got := sessionFilePath("valid-1_X"); got == "" { + t.Error("sessionFilePath(valid-1_X) = empty, want a real path") + } +} + +func TestSaveSessionRejectsInvalidID(t *testing.T) { + withTempHome(t) + err := SaveSession("bad id", nil, 0, api.TokenUsage{}, "") + if err == nil || !strings.Contains(err.Error(), "invalid session ID") { + t.Fatalf("SaveSession(bad id) error = %v, want invalid session ID", err) + } +} + +func TestSessionPathsFailWithoutHome(t *testing.T) { + orig := os.Getenv("HOME") + os.Unsetenv("HOME") + defer os.Setenv("HOME", orig) + + if got := sessionDirPath(); got != "" { + t.Errorf("sessionDirPath without HOME = %q, want empty", got) + } + err := SaveSession("abc123", nil, 0, api.TokenUsage{}, "") + if err == nil || !strings.Contains(err.Error(), "home directory") { + t.Fatalf("SaveSession without HOME error = %v, want cannot-determine-home failure", err) + } +} + +func TestLoadSessionCorruptFileFails(t *testing.T) { + home := withTempHome(t) + dir := filepath.Join(home, ".qmax-code", "sessions") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "corrupt.json"), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadSession("corrupt"); err == nil { + t.Fatal("corrupt session file must fail to load") + } +} + +func TestLoadSessionRehydratesMessagesFromTranscript(t *testing.T) { + withTempHome(t) + + history := []api.Message{{Role: "user", Content: "transcript only"}} + state := api.ConversationState{Transcript: history} + if err := SaveSession("rehydrate", history, 0, api.TokenUsage{}, "m", state); err != nil { + t.Fatalf("SaveSession: %v", err) + } + + loaded, err := LoadSession("rehydrate") + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + if len(loaded.Messages) != 1 || loaded.Messages[0].Role != "user" { + t.Fatalf("messages not rehydrated from transcript: %+v", loaded.Messages) + } + if len(loaded.Conversation.Transcript) != 1 { + t.Fatalf("transcript not preserved: %+v", loaded.Conversation) + } +} + +func TestListSessionsSkipsMalformedEntries(t *testing.T) { + home := withTempHome(t) + dir := filepath.Join(home, ".qmax-code", "sessions") + + if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "dir.json"), 0700); err != nil { + t.Fatal(err) // directory with a .json name must be skipped + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + good := `{"id":"good","updated_at":"2026-01-02T15:04:05Z","turns":3,"model":"opus","project_id":11}` + if err := os.WriteFile(filepath.Join(dir, "good.json"), []byte(good), 0600); err != nil { + t.Fatal(err) + } + + summaries, err := ListSessions(0) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("summaries = %+v, want only the good session", summaries) + } + s := summaries[0] + if s.ID != "good" || s.Turns != 3 || s.Model != "opus" || s.ProjectID != 11 { + t.Fatalf("summary = %+v, want good session fields", s) + } +} + +func TestListSessionsAndLastSessionFailWithoutDir(t *testing.T) { + withTempHome(t) + + if _, err := ListSessions(10); err == nil { + t.Fatal("missing sessions directory must be an error") + } + if _, err := LoadLastSession(); err == nil || !strings.Contains(err.Error(), "no sessions found") { + t.Fatalf("LoadLastSession error = %v, want no sessions found", err) + } +} + +func TestCleanupOldSessionsHonorsDurableWindow(t *testing.T) { + home := withTempHome(t) + dir := filepath.Join(home, ".qmax-code", "sessions") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + + write := func(name, content string, age time.Duration) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + stamp := time.Now().Add(-age) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + return path + } + + durableBody := `{"conversation":{"transcript":[{"role":"user","content":"hi"}]}}` + legacy := write("legacy.json", `{"id":"legacy"}`, 8*24*time.Hour) + durableMid := write("durable-mid.json", durableBody, 30*24*time.Hour) + durableAncient := write("durable-ancient.json", durableBody, 100*24*time.Hour) + recent := write("recent.json", `{"id":"recent"}`, time.Hour) + + removed := CleanupOldSessions() + if removed != 2 { + t.Fatalf("removed = %d, want 2 (legacy + ancient durable)", removed) + } + for _, path := range []string{legacy, durableAncient} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("%s should have been removed", filepath.Base(path)) + } + } + for _, path := range []string{durableMid, recent} { + if _, err := os.Stat(path); err != nil { + t.Errorf("%s should have been kept: %v", filepath.Base(path), err) + } + } +} + +func TestHasDurableTranscriptScansChunkBoundaries(t *testing.T) { + dir := t.TempDir() + + plain := filepath.Join(dir, "plain.json") + if err := os.WriteFile(plain, []byte(`{"id":"x"}`), 0600); err != nil { + t.Fatal(err) + } + if hasDurableTranscript(plain) { + t.Fatal("session without a transcript must not be durable") + } + + if hasDurableTranscript(filepath.Join(dir, "missing.json")) { + t.Fatal("missing file must not be durable") + } + + withKey := filepath.Join(dir, "with-key.json") + if err := os.WriteFile(withKey, []byte(`{"conversation":{"transcript":[`), 0600); err != nil { + t.Fatal(err) + } + if !hasDurableTranscript(withKey) { + t.Fatal("transcript key must be detected") + } + + // A key straddling the 64 KiB chunk boundary is only findable through the + // carried overlap between reads. + const chunk = 64 << 10 + var b []byte + b = append(b, bytes.Repeat([]byte("x"), chunk-5)...) + b = append(b, `"transcript":[`...) + b = append(b, bytes.Repeat([]byte("y"), 64)...) + straddle := filepath.Join(dir, "straddle.json") + if err := os.WriteFile(straddle, b, 0600); err != nil { + t.Fatal(err) + } + if !hasDurableTranscript(straddle) { + t.Fatal("key straddling the chunk boundary was not detected") + } +} + +func TestIsToolResultOnlyShapes(t *testing.T) { + block := func(blockType string) api.ContentBlock { return api.ContentBlock{Type: blockType} } + tests := []struct { + name string + content interface{} + want bool + }{ + {"live tool_result blocks", []api.ContentBlock{block("tool_result")}, true}, + {"live mixed blocks", []api.ContentBlock{block("tool_result"), block("text")}, false}, + {"live empty blocks", []api.ContentBlock{}, false}, + {"decoded tool_result blocks", []interface{}{map[string]interface{}{"type": "tool_result"}}, true}, + {"decoded non-tool block", []interface{}{map[string]interface{}{"type": "text"}}, false}, + {"decoded non-map entry", []interface{}{"tool_result"}, false}, + {"decoded empty blocks", []interface{}{}, false}, + {"plain string", "tool_result", false}, + {"nil", nil, false}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + if got := isToolResultOnly(tt.content); got != tt.want { + t.Fatalf("isToolResultOnly(%v) = %v, want %v", tt.content, got, tt.want) + } + }) + } +} + +func TestCountTurnsSkipsToolResultOnlyUserMessages(t *testing.T) { + history := []api.Message{ + {Role: "user", Content: "real prompt"}, + {Role: "user", Content: []api.ContentBlock{{Type: "tool_result", ToolUseID: "t1", Content: "ok"}}}, + {Role: "user", Content: []interface{}{map[string]interface{}{"type": "tool_result"}}}, + {Role: "assistant", Content: "done"}, + } + if got := CountTurns(history); got != 1 { + t.Fatalf("CountTurns = %d, want 1 (only the typed prompt)", got) + } +} + +func TestSummaryForBlockContentWithoutTextFallsBackToTurnCount(t *testing.T) { + history := []api.Message{{ + Role: "user", + Content: []interface{}{map[string]interface{}{"type": "image", "source": "..."}}, + }} + if got := SummaryFor(history); got != "1 turns" { + t.Fatalf("SummaryFor = %q, want %q", got, "1 turns") + } +} + +func TestMarshalRedactedRecursesIntoJSONStrings(t *testing.T) { + nested := `{"api_key":"synthetic-nested-secret","note":"keep me"}` + data, err := MarshalRedacted(nested) + if err != nil { + t.Fatalf("MarshalRedacted: %v", err) + } + out := string(data) + if !strings.Contains(out, "[REDACTED]") { + t.Fatalf("nested credential not redacted: %s", out) + } + if strings.Contains(out, "synthetic-nested-secret") { + t.Fatalf("nested credential leaked: %s", out) + } + if !strings.Contains(out, "keep me") { + t.Fatalf("ordinary nested content lost: %s", out) + } +} + +func TestMarshalRedactedRejectsUnencodableValue(t *testing.T) { + if _, err := MarshalRedacted(make(chan int)); err == nil { + t.Fatal("unencodable value must fail instead of producing output") + } +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 8f40496..0715319 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestGenerateSessionID(t *testing.T) { diff --git a/internal/session/stdin_queue_unix.go b/internal/session/stdin_queue_unix.go index 5b50b35..a1e4274 100644 --- a/internal/session/stdin_queue_unix.go +++ b/internal/session/stdin_queue_unix.go @@ -11,7 +11,7 @@ import ( "golang.org/x/sys/unix" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/tui" ) // StartQueueReader starts a background goroutine that reads chars from stdin diff --git a/internal/session/stdin_queue_unix_test.go b/internal/session/stdin_queue_unix_test.go new file mode 100644 index 0000000..398ab34 --- /dev/null +++ b/internal/session/stdin_queue_unix_test.go @@ -0,0 +1,212 @@ +//go:build darwin || linux + +package session + +import ( + "os" + "sync" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/tui" +) + +// withStdinPipeWriter swaps os.Stdin for a fresh pipe and returns the write +// end so tests can feed the queue reader incrementally. Cleanup restores the +// original stdin and closes both ends. Tests using it must not run parallel. +func withStdinPipeWriter(t *testing.T) *os.File { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = orig + r.Close() + w.Close() + }) + return w +} + +// queuedWithin polls the queue until an item arrives, failing the test after +// the deadline instead of hanging. +func queuedWithin(t *testing.T, q *PromptQueue) string { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if s, ok := q.Pop(); ok { + return s + } + select { + case <-deadline: + t.Fatal("timed out waiting for a queued prompt") + case <-time.After(5 * time.Millisecond): + } + } +} + +// stopQueueWithTimeout invokes the stop function under a deadline so a wedged +// reader goroutine fails the test instead of hanging it. +func stopQueueWithTimeout(t *testing.T, stop func() string) string { + t.Helper() + done := make(chan string, 1) + go func() { done <- stop() }() + select { + case s := <-done: + return s + case <-time.After(2 * time.Second): + t.Fatal("StartQueueReader stop function did not return within 2s") + return "" + } +} + +func startTestQueueReader(t *testing.T, q *PromptQueue, cancel func()) func() string { + t.Helper() + stop := sync.OnceValue(StartQueueReader(q, &tui.Terminal{}, cancel)) + // Stop before the stdin fixture is restored, including on assertion failure. + t.Cleanup(func() { stopQueueWithTimeout(t, stop) }) + return stop +} + +func TestStartQueueReaderQueuesLineAndCancelsAgent(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + cancelled := make(chan struct{}, 1) + stop := startTestQueueReader(t, q, func() { cancelled <- struct{}{} }) + + if _, err := w.WriteString("interrupt the agent\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + + if got := queuedWithin(t, q); got != "interrupt the agent" { + t.Fatalf("queued prompt = %q, want the typed line", got) + } + select { + case <-cancelled: + case <-time.After(2 * time.Second): + t.Fatal("cancelFn was not invoked when the prompt was queued") + } + + if partial := stopQueueWithTimeout(t, stop); partial != "" { + t.Fatalf("partial = %q, want empty after Enter", partial) + } +} + +func TestStartQueueReaderStopReturnsPartialLine(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + // Unterminated input stays buffered in the reader; stopping must return + // it rather than silently dropping it (QUA-577). + if _, err := w.WriteString("unfinishe"); err != nil { + t.Fatalf("write stdin: %v", err) + } + time.Sleep(300 * time.Millisecond) // let the 50ms select loop consume it + + if partial := stopQueueWithTimeout(t, stop); partial != "unfinishe" { + t.Fatalf("partial = %q, want unfinishe", partial) + } + if _, ok := q.Pop(); ok { + t.Fatal("unterminated input must never be pushed onto the queue") + } +} + +func TestStartQueueReaderStopBeforeAnyInput(t *testing.T) { + withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + if partial := stopQueueWithTimeout(t, stop); partial != "" { + t.Fatalf("partial = %q, want empty", partial) + } + if q.Len() != 0 { + t.Fatalf("queue len = %d, want 0", q.Len()) + } +} + +func TestStartQueueReaderHandlesBackspace(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + // Both DEL (127) and BS (8) erase the previous character. + if _, err := w.WriteString("abcd\x7f\x08\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + if got := queuedWithin(t, q); got != "ab" { + t.Fatalf("queued prompt = %q, want backspaced ab", got) + } + stopQueueWithTimeout(t, stop) +} + +func TestStartQueueReaderCtrlCResetsLine(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + if _, err := w.WriteString("discard me\x03fresh\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + if got := queuedWithin(t, q); got != "fresh" { + t.Fatalf("queued prompt = %q, want fresh (Ctrl+C wipes the draft)", got) + } + stopQueueWithTimeout(t, stop) +} + +func TestStartQueueReaderIgnoresBlankLines(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + // Whitespace-only lines queue nothing; the first real line still lands. + if _, err := w.WriteString("\n \nreal prompt\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + if got := queuedWithin(t, q); got != "real prompt" { + t.Fatalf("queued prompt = %q, want real prompt", got) + } + stopQueueWithTimeout(t, stop) +} + +func TestStartQueueReaderSwallowsEscapeSequences(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + // ESC+b (Option+word-jump) and full CSI sequences (arrow keys) must be + // consumed as units: the b in ESC+b and every CSI byte are dropped rather + // than inserted into the typed line. + if _, err := w.WriteString("a\x1bbc\x1b[Dd\x1b[?1049h\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + if got := queuedWithin(t, q); got != "acd" { + t.Fatalf("queued prompt = %q, want acd without escape noise", got) + } + stopQueueWithTimeout(t, stop) +} + +func TestStartQueueReaderDecodesUTF8AndDropsBadBytes(t *testing.T) { + w := withStdinPipeWriter(t) + + q := &PromptQueue{} + stop := startTestQueueReader(t, q, nil) + + // Multi-byte é must decode as one rune; the invalid 0xff byte is dropped. + if _, err := w.WriteString("héllo\xff!\n"); err != nil { + t.Fatalf("write stdin: %v", err) + } + if got := queuedWithin(t, q); got != "héllo!" { + t.Fatalf("queued prompt = %q, want héllo!", got) + } + stopQueueWithTimeout(t, stop) +} diff --git a/internal/session/stdin_queue_windows.go b/internal/session/stdin_queue_windows.go index 88af413..c26c854 100644 --- a/internal/session/stdin_queue_windows.go +++ b/internal/session/stdin_queue_windows.go @@ -2,7 +2,7 @@ package session -import "github.com/qualitymax/qmax-code/internal/tui" +import "github.com/Quality-Max/qmax-code/internal/tui" // StartQueueReader is a no-op on Windows; the prompt queue still works via // /queue but concurrent stdin reading while streaming is not supported. diff --git a/internal/session/usage_cost_test.go b/internal/session/usage_cost_test.go new file mode 100644 index 0000000..6aea3ef --- /dev/null +++ b/internal/session/usage_cost_test.go @@ -0,0 +1,29 @@ +package session + +import ( + "math" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func TestSessionPreservesRequestCostsOnResume(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + var usage api.TokenUsage + usage.RecordRequest(api.ModelHaiku55, api.APIUsage{InputTokens: 1, CacheReadInputTokens: 60_000, OutputTokens: 10}) + history := []api.Message{{Role: "user", Content: "hello"}} + if err := SaveSession("cached", history, 0, usage, api.ModelHaiku55); err != nil { + t.Fatal(err) + } + saved, err := LoadSession("cached") + if err != nil { + t.Fatal(err) + } + saved.Usage.RecordRequest(api.ModelHaiku55, api.APIUsage{InputTokens: 1, CacheReadInputTokens: 60_000, OutputTokens: 10}) + if saved.Usage.TotalInputTokens() != 120_002 || saved.Usage.TotalTokens() != 120_022 || saved.Usage.Requests != 2 { + t.Fatalf("resumed usage=%+v", saved.Usage) + } + if got := saved.Usage.EstimatedCost(api.ModelSonnet); math.Abs(got-0.0012102) > 1e-12 { + t.Fatalf("resume changed request pricing: %v", got) + } +} diff --git a/internal/setup/agy.go b/internal/setup/agy.go index 7d94220..ea7c361 100644 --- a/internal/setup/agy.go +++ b/internal/setup/agy.go @@ -8,7 +8,7 @@ import ( "strings" "sync/atomic" - "github.com/qualitymax/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/agent" "golang.org/x/term" ) diff --git a/internal/setup/agy_prompt_test.go b/internal/setup/agy_prompt_test.go new file mode 100644 index 0000000..99ed3d2 --- /dev/null +++ b/internal/setup/agy_prompt_test.go @@ -0,0 +1,62 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPromptAgyGoogleLoginSkipsWhenTokenFileExists(t *testing.T) { + home := withTempHome(t) + resetAgyGoogleLoginPrompt() + t.Cleanup(resetAgyGoogleLoginPrompt) + + // A file-backed OAuth token means the user is already signed in: the + // prompt must return before consuming the once-per-process offer. + tokenPath := filepath.Join(home, ".gemini", "antigravity-cli", "antigravity-oauth-token") + if err := os.MkdirAll(filepath.Dir(tokenPath), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tokenPath, []byte("cached-oauth-token"), 0600); err != nil { + t.Fatal(err) + } + + PromptAgyGoogleLogin("/usr/bin/true") + + if agyGoogleLoginOffered.Load() { + t.Fatal("present token file must not consume the login-prompt offer") + } +} + +func TestPromptAgyGoogleLoginNonTTYExplainsManualSignIn(t *testing.T) { + withTempHome(t) + resetAgyGoogleLoginPrompt() + t.Cleanup(resetAgyGoogleLoginPrompt) + + // os.Stdin is a pipe, not a terminal: the prompt claims the offer for the + // process but must not try to launch an interactive `agy`. + withStdinPipe(t, "") + + PromptAgyGoogleLogin("/usr/bin/true") + + if !agyGoogleLoginOffered.Load() { + t.Fatal("non-TTY run must still consume the once-per-process offer") + } +} + +func TestPromptAgyGoogleLoginSkipsWhenOfferAlreadyConsumed(t *testing.T) { + withTempHome(t) + resetAgyGoogleLoginPrompt() + t.Cleanup(resetAgyGoogleLoginPrompt) + + if !claimAgyGoogleLoginPrompt() { + t.Fatal("first claim must succeed") + } + + withStdinPipe(t, "") + PromptAgyGoogleLogin("/usr/bin/true") + + if !agyGoogleLoginOffered.Load() { + t.Fatal("the claim must remain consumed after the skipped prompt") + } +} diff --git a/internal/setup/consent.go b/internal/setup/consent.go index db1a406..5a9a5f7 100644 --- a/internal/setup/consent.go +++ b/internal/setup/consent.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // ConsentResult is what activateBackend gets back from the consent prompt. diff --git a/internal/setup/consent_prompt_test.go b/internal/setup/consent_prompt_test.go new file mode 100644 index 0000000..fdff1c3 --- /dev/null +++ b/internal/setup/consent_prompt_test.go @@ -0,0 +1,101 @@ +package setup + +import ( + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// PromptOrchConsent is driven entirely through piped stdin: it builds one +// bufio.Reader over os.Stdin and reuses it for every prompt, so a multi-line +// fixture answers each question in order. +func TestPromptOrchConsentFlows(t *testing.T) { + tests := []struct { + name string + backend string + input string + seedMode string + seedGlobal bool + preInstall bool // install the backend's MCP config before prompting + wantProceed bool + wantMode string + wantGlobal bool + }{ + { + name: "cc standard mode plus global install", backend: "cc", + input: "1\ny\n", wantProceed: true, wantMode: "standard", wantGlobal: true, + }, + { + name: "cc unattended confirmed then global install", backend: "cc", + input: "2\ny\ny\n", wantProceed: true, wantMode: "unattended", wantGlobal: true, + }, + { + name: "unattended confirmation declined cancels", backend: "cc", + input: "2\nn\n", wantProceed: false, + }, + { + name: "unknown answer cancels activation", backend: "cc", + input: "n\n", wantProceed: false, + }, + { + name: "empty line defaults to standard, EOF declines install", backend: "cc", + input: "\n", wantProceed: true, wantMode: "standard", wantGlobal: false, + }, + { + name: "eof everywhere still defaults to standard", backend: "cc", + input: "", wantProceed: true, wantMode: "standard", wantGlobal: false, + }, + { + name: "agy accepts the s alias for standard", backend: "agy", + input: "s\n", wantProceed: true, wantMode: "standard", wantGlobal: false, + }, + { + name: "codex skips the permission prompt entirely", backend: "codex", + input: "y\n", wantProceed: true, wantMode: "", wantGlobal: true, + }, + { + name: "opencode skips the global install prompt", backend: "opencode", + input: "1\n", wantProceed: true, wantMode: "standard", wantGlobal: false, + }, + { + name: "pre-consented config never prompts", backend: "cc", + input: "", seedMode: "standard", seedGlobal: true, + wantProceed: true, wantMode: "standard", wantGlobal: true, + }, + { + name: "already-installed backend skips the install prompt", backend: "cc", + input: "1\n", preInstall: true, + wantProceed: true, wantMode: "standard", wantGlobal: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + withTempHome(t) + if tt.preInstall { + if _, err := InstallCC(); err != nil { + t.Fatalf("pre-install cc MCP config: %v", err) + } + } + + cfg := &api.Config{ + OrchPermissionMode: tt.seedMode, + OrchGlobalInstall: tt.seedGlobal, + } + withStdinPipe(t, tt.input) + + res := PromptOrchConsent(cfg, tt.backend) + + if res.Proceed != tt.wantProceed { + t.Fatalf("Proceed = %v, want %v", res.Proceed, tt.wantProceed) + } + if res.PermissionMode != tt.wantMode { + t.Fatalf("PermissionMode = %q, want %q", res.PermissionMode, tt.wantMode) + } + if res.GlobalInstall != tt.wantGlobal { + t.Fatalf("GlobalInstall = %v, want %v", res.GlobalInstall, tt.wantGlobal) + } + }) + } +} diff --git a/internal/setup/framework_detect_test.go b/internal/setup/framework_detect_test.go index 26065ec..d08b5e3 100644 --- a/internal/setup/framework_detect_test.go +++ b/internal/setup/framework_detect_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // TestDetectProjectFramework exercises the first-run wizard's language diff --git a/internal/setup/interactive.go b/internal/setup/interactive.go index c9b0f4c..5750957 100644 --- a/internal/setup/interactive.go +++ b/internal/setup/interactive.go @@ -17,9 +17,9 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/tui" ) var ( diff --git a/internal/setup/interactive_helpers_test.go b/internal/setup/interactive_helpers_test.go new file mode 100644 index 0000000..2e50e69 --- /dev/null +++ b/internal/setup/interactive_helpers_test.go @@ -0,0 +1,220 @@ +package setup + +import ( + "bufio" + "errors" + "os" + "strings" + "testing" +) + +func TestMaskKey(t *testing.T) { + tests := []struct { + key string + want string + }{ + {"short", "••••"}, // short keys never reveal length + {"12345678", "••••"}, // exactly 8 chars still masked + {"qm-abcdefgh1234", "qm-a•••1234"}, // long keys show first/last 4 + {"sk-ant-0123456789", "sk-a•••6789"}, // anthropic-style key + } + for _, tt := range tests { + if got := maskKey(tt.key); got != tt.want { + t.Errorf("maskKey(%q) = %q, want %q", tt.key, got, tt.want) + } + } +} + +func TestReadSecretPipeFallbackTrimsAndReturns(t *testing.T) { + // Piped stdin: EnableRawMode fails, so ReadSecret uses the plain-reader + // fallback and must still return the trimmed secret. + withStdinPipe(t, " qm-secret-value-987 \n") + got := ReadSecret("prompt: ") + if got != "qm-secret-value-987" { + t.Fatalf("ReadSecret = %q, want trimmed key", got) + } +} + +func TestReadSecretPipeFallbackEmptyInput(t *testing.T) { + withStdinPipe(t, "\n") + if got := ReadSecret("prompt: "); got != "" { + t.Fatalf("ReadSecret = %q, want empty", got) + } +} + +func TestWaitForEnterConsumesALine(t *testing.T) { + withStdinPipe(t, "\n") + waitForEnter() // returns as soon as the line arrives +} + +func TestParseJSONAndMapValueHelpers(t *testing.T) { + var projects []map[string]interface{} + if err := parseJSON(`[{"id":1}]`, &projects); err != nil || len(projects) != 1 { + t.Fatalf("parseJSON valid = err:%v len:%d, want 1 entry", err, len(projects)) + } + if err := parseJSON(`{`, &projects); err == nil { + t.Fatal("parseJSON must reject invalid JSON") + } + + m := map[string]interface{}{ + "name": "alpha", + "float": 7.0, + "int": 3, + "null": nil, + "wrongtype": "7", + } + if got := strVal2(m, "name"); got != "alpha" { + t.Errorf("strVal2(name) = %q, want alpha", got) + } + if got := strVal2(m, "missing"); got != "" { + t.Errorf("strVal2(missing) = %q, want empty", got) + } + if got := strVal2(m, "null"); got != "" { + t.Errorf("strVal2(null) = %q, want empty", got) + } + if got := intVal2(m, "float"); got != 7 { + t.Errorf("intVal2(float) = %d, want 7 (JSON numbers decode as float64)", got) + } + if got := intVal2(m, "int"); got != 3 { + t.Errorf("intVal2(int) = %d, want 3", got) + } + if got := intVal2(m, "missing"); got != 0 { + t.Errorf("intVal2(missing) = %d, want 0", got) + } + if got := intVal2(m, "null"); got != 0 { + t.Errorf("intVal2(null) = %d, want 0", got) + } + if got := intVal2(m, "wrongtype"); got != 0 { + t.Errorf("intVal2(string) = %d, want 0", got) + } +} + +func TestChooseFromRawInputClampsAndFiltersDigits(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {name: "down clamps at last option", input: "jjjjj\n", want: 3}, + {name: "digit beyond range ignored", input: "9j\n", want: 1}, + {name: "multi-digit candidate ignored", input: "12\n", want: 0}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + redraws := 0 + got, confirmed := chooseFromRawInput( + bufio.NewReader(strings.NewReader(tt.input)), + 4, + func(int, string) { redraws++ }, + ) + if !confirmed { + t.Fatal("input did not confirm a selection") + } + if got != tt.want { + t.Fatalf("selection = %d, want %d", got, tt.want) + } + if redraws == 0 { + t.Fatal("navigation did not request a menu redraw") + } + }) + } +} + +func TestChooseFromRawInputUpClampsAtFirstOption(t *testing.T) { + // Up at the top is a no-op: the selection stays put and, by design, no + // redraw is requested. + got, confirmed := chooseFromRawInput( + bufio.NewReader(strings.NewReader("kkk\n")), + 4, + func(int, string) {}, + ) + if !confirmed || got != 0 { + t.Fatalf("selection = (%d, %v), want (0, true)", got, confirmed) + } +} + +func TestChooseFromRawInputCancel(t *testing.T) { + // Ctrl+C cancels but is still "confirmed" (a deliberate choice of option 0). + got, confirmed := chooseFromRawInput( + bufio.NewReader(strings.NewReader("\x03")), + 4, + func(int, string) {}, + ) + if !confirmed || got != 0 { + t.Fatalf("cancel = (%d, %v), want (0, true)", got, confirmed) + } +} + +func TestChooseFromRawInputEOFIsNotConfirmed(t *testing.T) { + if _, confirmed := chooseFromRawInput( + bufio.NewReader(strings.NewReader("")), + 4, + func(int, string) {}, + ); confirmed { + t.Fatal("EOF must not confirm a selection") + } +} + +func TestReadChooserEventTruncatedEscapeSequences(t *testing.T) { + // A lone ESC or a CSI cut off mid-sequence must never confirm or move the + // selection; the reader just runs out of bytes. + for _, input := range []string{"\x1b", "\x1b[", "\x1b[?"} { + if _, confirmed := chooseFromRawInput( + bufio.NewReader(strings.NewReader(input)), + 3, + func(int, string) {}, + ); confirmed { + t.Fatalf("truncated escape %q must not confirm", input) + } + } +} + +func TestPromptAnthropicKeyPasteEmptyKeySkipsKeychain(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + + origSave := saveAnthropicKey + saveAnthropicKey = func(string) error { + t.Error("empty paste must not write the keychain") + return nil + } + defer func() { saveAnthropicKey = origSave }() + + origPrompt := promptAPIKey + promptAPIKey = func(string) string { return "" } + defer func() { promptAPIKey = origPrompt }() + + // "1" selects "Paste Anthropic key" via the numeric fallback. + withStdinPipe(t, "1\n") + + if got := promptAnthropicKey(); got != "" { + t.Fatalf("promptAnthropicKey() = %q, want empty for empty paste", got) + } + if os.Getenv("ANTHROPIC_API_KEY") != "" { + t.Fatal("empty paste must not export ANTHROPIC_API_KEY") + } +} + +func TestPromptAnthropicKeyKeychainFailureStillSetsEnv(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ANTHROPIC_API_KEY", "") + + origSave := saveAnthropicKey + saveAnthropicKey = func(string) error { return errors.New("keychain locked") } + defer func() { saveAnthropicKey = origSave }() + + origPrompt := promptAPIKey + promptAPIKey = func(string) string { return "sk-ant-session-only" } + defer func() { promptAPIKey = origPrompt }() + + withStdinPipe(t, "1\n") + + if got := promptAnthropicKey(); got != "sk-ant-session-only" { + t.Fatalf("promptAnthropicKey() = %q, want pasted key despite keychain failure", got) + } + if os.Getenv("ANTHROPIC_API_KEY") != "sk-ant-session-only" { + t.Fatal("key must remain set for this session when keychain save fails") + } +} diff --git a/internal/setup/interactive_login_test.go b/internal/setup/interactive_login_test.go new file mode 100644 index 0000000..020d244 --- /dev/null +++ b/internal/setup/interactive_login_test.go @@ -0,0 +1,207 @@ +package setup + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// withNoPATH removes PATH for the duration of the test so openBrowser's +// exec.Command("open", ...) fails at LookPath and never spawns a process. +// Browser-based flows still run everything else (HTTP, polling, saving). +func withNoPATH(t *testing.T) { + t.Helper() + t.Setenv("PATH", "") +} + +func TestLoginInteractiveReadsKeyFromPipeAndValidates(t *testing.T) { + withTempHome(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/me" { + t.Errorf("path = %q, want /api/me", r.URL.Path) + } + // LoginWithAPIKey strips the qmax- prefix before sending the bearer. + if got := r.Header.Get("Authorization"); got != "Bearer piped-key-123" { + t.Errorf("Authorization = %q, want stripped piped key", got) + } + _, _ = w.Write([]byte(`{"email":"pipe@example.test","id":"u-pipe"}`)) + })) + defer server.Close() + t.Setenv("QUALITYMAX_URL", server.URL) + + // Piped stdin: ReadSecret cannot enter raw mode, so it takes the plain + // read + mask-after fallback. + withStdinPipe(t, " qm-piped-key-123 \n") + + auth, err := LoginInteractive() + if err != nil { + t.Fatalf("LoginInteractive: %v", err) + } + if auth.APIKey != "qm-piped-key-123" { + t.Fatalf("APIKey = %q, want trimmed pasted key", auth.APIKey) + } + if auth.Email != "pipe@example.test" || auth.UserID != "u-pipe" { + t.Fatalf("auth metadata = %+v", auth) + } + if auth.CloudURL != server.URL { + t.Fatalf("CloudURL = %q, want %q", auth.CloudURL, server.URL) + } +} + +func TestLoginInteractiveEmptyKeyReturnsSentinel(t *testing.T) { + withStdinPipe(t, "\n") + if _, err := LoginInteractive(); !errors.Is(err, ErrEmptyAPIKey) { + t.Fatalf("LoginInteractive() error = %v, want ErrEmptyAPIKey", err) + } +} + +func TestLoginViaBrowserPendingThenAuthorizedSavesRegisteredToken(t *testing.T) { + home := withTempHome(t) + withNoPATH(t) + + polls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/auth/cli-login": + if r.Method != http.MethodPost { + t.Errorf("cli-login method = %q, want POST", r.Method) + } + _, _ = w.Write([]byte(`{"code":"c&1","expires_at":"soon","auth_url":""}`)) + case "/api/auth/cli-poll": + polls++ + // The code is server-supplied and URL-reserved; it must arrive + // percent-encoded so the request is well-formed. + if got := r.URL.Query().Get("code"); got != "c&1" { + t.Errorf("poll code = %q, want c&1", got) + } + if polls == 1 { + _, _ = w.Write([]byte(`{"status":"pending"}`)) + return + } + _, _ = w.Write([]byte(`{"status":"authorized","token":"qm-user-access","email":"browser@example.test","user_id":"u-9"}`)) + case "/api/auth/api-token": + if got := r.Header.Get("Authorization"); got != "Bearer qm-user-access" { + t.Errorf("api-token Authorization = %q", got) + } + _, _ = w.Write([]byte(`{"token":"qm-registered-token"}`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + } + })) + defer server.Close() + t.Setenv("QUALITYMAX_URL", server.URL) + + auth, err := LoginViaBrowser() + if err != nil { + t.Fatalf("LoginViaBrowser: %v", err) + } + if polls != 2 { + t.Fatalf("polls = %d, want 2 (pending then authorized)", polls) + } + if auth.APIKey != "qm-registered-token" { + t.Fatalf("APIKey = %q, want minted registered token", auth.APIKey) + } + if auth.Email != "browser@example.test" || auth.UserID != "u-9" { + t.Fatalf("auth metadata = %+v", auth) + } + + // The browser access token is an exchange input, never the persisted key. + data, err := os.ReadFile(filepath.Join(home, ".qmax-code", "auth.json")) + if err != nil { + t.Fatalf("read saved auth: %v", err) + } + var saved api.AuthConfig + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("decode saved auth: %v", err) + } + if saved.APIKey != "qm-registered-token" { + t.Fatalf("saved APIKey = %q, want registered token", saved.APIKey) + } + if saved.Email != "browser@example.test" { + t.Fatalf("saved email = %q", saved.Email) + } +} + +func TestLoginViaBrowserExpiredCodeFailsFast(t *testing.T) { + withTempHome(t) + withNoPATH(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/auth/cli-login" { + _, _ = w.Write([]byte(`{"code":"expired-code","auth_url":""}`)) + return + } + _, _ = w.Write([]byte(`{"status":"expired"}`)) + })) + defer server.Close() + t.Setenv("QUALITYMAX_URL", server.URL) + + _, err := LoginViaBrowser() + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("LoginViaBrowser() error = %v, want expired failure", err) + } +} + +func TestLoginViaBrowserRejectsBadHandshake(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantErr string + }{ + { + name: "non-200 login endpoint", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }, + wantErr: "CLI login failed", + }, + { + name: "invalid json login response", + handler: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not-json")) + }, + wantErr: "invalid response", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + withTempHome(t) + server := httptest.NewServer(tt.handler) + defer server.Close() + t.Setenv("QUALITYMAX_URL", server.URL) + + _, err := LoginViaBrowser() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("LoginViaBrowser() error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +func TestLoginViaBrowserUnreachableCloud(t *testing.T) { + withTempHome(t) + withNoPATH(t) + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := server.URL + server.Close() + t.Setenv("QUALITYMAX_URL", url) + + _, err := LoginViaBrowser() + if err == nil || !strings.Contains(err.Error(), "cannot reach QualityMax") { + t.Fatalf("LoginViaBrowser() error = %v, want unreachable failure", err) + } +} diff --git a/internal/setup/interactive_test.go b/internal/setup/interactive_test.go index b7dd38d..b196376 100644 --- a/internal/setup/interactive_test.go +++ b/internal/setup/interactive_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestMintCLIAPIToken(t *testing.T) { diff --git a/internal/setup/orch.go b/internal/setup/orch.go index 99d23a8..b3519ae 100644 --- a/internal/setup/orch.go +++ b/internal/setup/orch.go @@ -7,9 +7,9 @@ import ( "path/filepath" "strings" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/skills" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/skills" + "github.com/Quality-Max/qmax-code/internal/tui" ) // InstallResult describes what autosetup did so the caller can display it. diff --git a/internal/setup/orch_install_test.go b/internal/setup/orch_install_test.go new file mode 100644 index 0000000..87fd124 --- /dev/null +++ b/internal/setup/orch_install_test.go @@ -0,0 +1,269 @@ +package setup + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/skills" + "github.com/Quality-Max/qmax-code/internal/tui" +) + +// captureStdout uses a file so output cannot fill a pipe, and all cleanup runs +// even when fn fails the test. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + f, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + os.Stdout = f + defer func() { + os.Stdout = orig + f.Close() + }() + fn() + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +// withFileHome points HOME at an existing regular file so every MkdirAll under +// it fails with ENOTDIR — a cheap way to exercise install error paths. +func withFileHome(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), "home-is-a-file") + if err := os.WriteFile(path, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + orig := os.Getenv("HOME") + os.Setenv("HOME", path) + t.Cleanup(func() { os.Setenv("HOME", orig) }) +} + +func TestInstallCCWritesSettingsJSON(t *testing.T) { + home := withTempHome(t) + + res, err := InstallCC() + if err != nil { + t.Fatalf("InstallCC: %v", err) + } + if want := filepath.Join(home, ".claude", "settings.json"); res.MCPPath != want { + t.Fatalf("MCPPath = %q, want %q", res.MCPPath, want) + } + if res.AlreadyHadMCP { + t.Fatal("fresh install must not report an existing MCP entry") + } + if !res.MCPWritten { + t.Fatal("fresh install must report a write") + } + + data, err := os.ReadFile(res.MCPPath) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("decode settings.json: %v (raw: %s)", err, data) + } + servers, ok := cfg["mcpServers"].(map[string]interface{}) + if !ok { + t.Fatalf("missing mcpServers object: %s", data) + } + qmax, ok := servers["qmax"].(map[string]interface{}) + if !ok { + t.Fatalf("missing qmax entry: %s", data) + } + if qmax["command"] != "qmax-code" { + t.Fatalf("command = %v, want qmax-code", qmax["command"]) + } + args, ok := qmax["args"].([]interface{}) + if !ok || len(args) != 2 || args[0] != "serve" || args[1] != "--mcp" { + t.Fatalf("args = %v, want [serve --mcp]", qmax["args"]) + } + if !IsOrchInstalled("cc") { + t.Fatal("IsOrchInstalled(cc) must detect the written entry") + } +} + +func TestInstallCCPreservesExistingSettings(t *testing.T) { + home := withTempHome(t) + path := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + existing := `{"model":"opus","mcpServers":{"other":{"command":"keep-me"},"qmax":{"command":"stale"}}}` + if err := os.WriteFile(path, []byte(existing), 0600); err != nil { + t.Fatal(err) + } + + res, err := InstallCC() + if err != nil { + t.Fatalf("InstallCC: %v", err) + } + if !res.AlreadyHadMCP { + t.Fatal("pre-existing qmax entry must set AlreadyHadMCP") + } + + data, _ := os.ReadFile(path) + var cfg map[string]interface{} + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("decode settings.json: %v", err) + } + if cfg["model"] != "opus" { + t.Fatalf("unrelated top-level key lost: %s", data) + } + servers := cfg["mcpServers"].(map[string]interface{}) + if _, ok := servers["other"]; !ok { + t.Fatalf("sibling MCP server lost: %s", data) + } + if servers["qmax"].(map[string]interface{})["command"] != "qmax-code" { + t.Fatalf("stale qmax entry not refreshed: %s", data) + } +} + +func TestRunOrchInstallsEveryBackend(t *testing.T) { + for _, backend := range []string{"cc", "codex", "agy"} { + t.Run(backend, func(t *testing.T) { + withTempHome(t) + RunOrch(backend, &tui.Terminal{}) + if !IsOrchInstalled(backend) { + t.Fatalf("RunOrch(%q) did not install the MCP entry", backend) + } + }) + } +} + +func TestRunOrchReportsInstallFailureWithoutPanicking(t *testing.T) { + withFileHome(t) + + for _, backend := range []string{"cc", "codex", "agy"} { + RunOrch(backend, &tui.Terminal{}) // prints the failure, must not panic + } + // Unsupported backends are a silent no-op. + RunOrch("unknown-backend", &tui.Terminal{}) +} + +func TestIsOrchInstalledEdgeCases(t *testing.T) { + home := withTempHome(t) + + if !IsOrchInstalled("unknown") { + t.Fatal("unknown backend must default to installed so setup is skipped") + } + if IsOrchInstalled("cc") { + t.Fatal("fresh home must report nothing installed") + } + + ccPath := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(ccPath), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ccPath, []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if IsOrchInstalled("cc") { + t.Fatal("invalid JSON must not count as installed") + } + if err := os.WriteFile(ccPath, []byte(`{"mcpServers":{"qmax":{}}}`), 0600); err != nil { + t.Fatal(err) + } + if !IsOrchInstalled("cc") { + t.Fatal("qmax entry in settings.json must count as installed") + } + + cxPath := filepath.Join(home, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(cxPath), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cxPath, []byte("[mcp_servers.other]\ncommand = \"x\"\n"), 0600); err != nil { + t.Fatal(err) + } + if IsOrchInstalled("codex") { + t.Fatal("codex config without the qmax table must not count as installed") + } +} + +func TestInstallSkillsMaterializesCatalog(t *testing.T) { + home := withTempHome(t) + + res, err := InstallSkills("cc") + if err != nil { + t.Fatalf("InstallSkills: %v", err) + } + catalog := skills.SortedCatalog() + if len(catalog) == 0 { + t.Fatal("catalog is empty; test cannot assert anything") + } + if len(res.Written) != len(catalog) { + t.Fatalf("wrote %d skills, catalog has %d", len(res.Written), len(catalog)) + } + if res.Dir != filepath.Join(home, ".claude", "skills") { + t.Fatalf("Dir = %q, want cc skills dir under temp home", res.Dir) + } + skillMD := filepath.Join(res.Dir, catalog[0].Name, "SKILL.md") + if _, err := os.Stat(skillMD); err != nil { + t.Fatalf("catalog skill not materialized at %s: %v", skillMD, err) + } +} + +func TestInstallSkillsUnknownBackendFails(t *testing.T) { + withTempHome(t) + if _, err := InstallSkills("not-a-backend"); err == nil { + t.Fatal("unknown backend must fail Materialize") + } +} + +func TestInstallSkillsReportSurfacesFailure(t *testing.T) { + withFileHome(t) + // Non-fatal by design: prints the error and returns without panicking. + InstallSkillsReport("cc", &tui.Terminal{}) +} + +func TestInstallSkillsAllMaterializesEveryBackend(t *testing.T) { + home := withTempHome(t) + + InstallSkillsAll(&tui.Terminal{}) + + for _, dir := range []string{ + filepath.Join(home, ".claude", "skills"), + filepath.Join(home, ".codex", "skills"), + filepath.Join(home, ".config", "opencode", "skills"), + filepath.Join(home, ".gemini", "antigravity-cli", "skills"), + } { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("skills dir missing for %s: %v", dir, err) + } + if len(entries) == 0 { + t.Fatalf("no skills written under %s", dir) + } + } +} + +func TestPrintSkillsStatusMarksInstalledBackends(t *testing.T) { + withTempHome(t) + if _, err := InstallSkills("cc"); err != nil { + t.Fatalf("seed cc skills: %v", err) + } + + out := captureStdout(t, func() { PrintSkillsStatus(&tui.Terminal{}) }) + + catalog := skills.SortedCatalog() + if len(catalog) == 0 { + t.Fatal("catalog is empty; test cannot assert anything") + } + if !strings.Contains(out, catalog[0].Name) { + t.Fatalf("status output missing catalog skill %q:\n%s", catalog[0].Name, out) + } + if !strings.Contains(out, "✓") { + t.Fatalf("installed cc skills not check-marked:\n%s", out) + } + if !strings.Contains(out, "·") { + t.Fatalf("uninstalled backends not dot-marked:\n%s", out) + } +} diff --git a/internal/setup/orch_test.go b/internal/setup/orch_test.go index 8cc2529..3fde404 100644 --- a/internal/setup/orch_test.go +++ b/internal/setup/orch_test.go @@ -7,14 +7,13 @@ import ( "testing" ) -// withTempHome points $HOME at a fresh temp dir for the test and restores it -// in cleanup. Local copy of the helper used by other test files in the repo. +// withTempHome isolates preferences and prevents access to OS credentials. func withTempHome(t *testing.T) string { t.Helper() - orig := os.Getenv("HOME") tmp := t.TempDir() - os.Setenv("HOME", tmp) - t.Cleanup(func() { os.Setenv("HOME", orig) }) + t.Setenv("HOME", tmp) + t.Setenv("PATH", "") + t.Setenv("CEREBRAS_API_KEY", "") return tmp } diff --git a/internal/setup/run_interactive_test.go b/internal/setup/run_interactive_test.go new file mode 100644 index 0000000..1884e45 --- /dev/null +++ b/internal/setup/run_interactive_test.go @@ -0,0 +1,122 @@ +package setup + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// withStdinPipe replaces os.Stdin with a pipe preloaded with input and the +// write end already closed, so trailing prompts observe EOF. Restores the +// original stdin in cleanup. Tests using it must not run in parallel. +func withStdinPipe(t *testing.T, input string) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.WriteString(input); err != nil { + t.Fatalf("write stdin fixture: %v", err) + } + w.Close() + + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { + os.Stdin = orig + r.Close() + }) +} + +func TestRunInteractiveStandaloneChoiceSkipsAccount(t *testing.T) { + withTempHome(t) + // "4" = "Skip — use standalone local mode (no account needed)". + withStdinPipe(t, "4\n") + + auth, projectID, err := RunInteractive() + if !errors.Is(err, ErrStandaloneSkip) { + t.Fatalf("RunInteractive() error = %v, want ErrStandaloneSkip", err) + } + if auth != nil || projectID != 0 { + t.Fatalf("standalone exit returned auth=%v projectID=%d, want nil/0", auth, projectID) + } + if cfg := api.LoadQMaxCodeConfig(); !cfg.LocalOnly { + t.Fatal("standalone choice did not persist local_only=true") + } +} + +func TestRunInteractiveAPIKeyFlowEndsLoggedInWithProject(t *testing.T) { + withTempHome(t) + t.Setenv("ANTHROPIC_API_KEY", "env-provided-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/me": + if got := r.Header.Get("Authorization"); got != "Bearer interactive-flow" { + t.Errorf("Authorization = %q, want stripped flow key", got) + } + _, _ = w.Write([]byte(`{"email":"flow@example.test","id":"u-flow"}`)) + case "/api/projects": + _, _ = w.Write([]byte(`[{"id":7,"name":"Alpha"},{"id":8,"name":"Beta"}]`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + _, _ = w.Write([]byte(`{}`)) + } + })) + defer server.Close() + t.Setenv("QUALITYMAX_URL", server.URL) + + // The key paste is stubbed; only the menu choice comes from stdin. + origPrompt := promptAPIKey + promptAPIKey = func(string) string { return "qm-interactive-flow" } + defer func() { promptAPIKey = origPrompt }() + + // "3" = "I have an API key already". Every later menu hits EOF and takes + // its default: the project chooser picks the first listed project. + withStdinPipe(t, "3\n") + + auth, projectID, err := RunInteractive() + if err != nil { + t.Fatalf("RunInteractive: %v", err) + } + if auth == nil || auth.Email != "flow@example.test" { + t.Fatalf("auth = %+v, want logged-in config", auth) + } + if projectID != 7 { + t.Fatalf("projectID = %d, want 7 (first project is the EOF default)", projectID) + } + if cfg := api.LoadQMaxCodeConfig(); cfg.DefaultProject != 7 { + t.Fatalf("DefaultProject = %d, want 7 persisted", cfg.DefaultProject) + } + if got := os.Getenv("ANTHROPIC_API_KEY"); got != "env-provided-key" { + t.Fatalf("ANTHROPIC_API_KEY = %q, want the pre-existing env key kept", got) + } +} + +func TestRunInteractiveEmptyKeyTwiceFailsWithWrappedSentinel(t *testing.T) { + withTempHome(t) + + // Both the initial paste and the retry-after-recovery paste come back + // empty; the recovery menu then hits EOF and defaults to "try again". + origPrompt := promptAPIKey + promptAPIKey = func(string) string { return "" } + defer func() { promptAPIKey = origPrompt }() + + withStdinPipe(t, "3\n") + + auth, _, err := RunInteractive() + if err == nil || !errors.Is(err, ErrEmptyAPIKey) { + t.Fatalf("RunInteractive() error = %v, want wrapped ErrEmptyAPIKey", err) + } + if !strings.Contains(err.Error(), "interactive login") { + t.Fatalf("error = %v, want interactive login wrapper", err) + } + if auth != nil { + t.Fatalf("auth = %+v, want nil on failure", auth) + } +} diff --git a/internal/sysutil/error_reporting.go b/internal/sysutil/error_reporting.go index 2f65a07..d7acdf4 100644 --- a/internal/sysutil/error_reporting.go +++ b/internal/sysutil/error_reporting.go @@ -8,8 +8,8 @@ import ( "github.com/getsentry/sentry-go" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/security" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/security" ) const ( diff --git a/internal/sysutil/live_url_channel_test.go b/internal/sysutil/live_url_channel_test.go new file mode 100644 index 0000000..a041789 --- /dev/null +++ b/internal/sysutil/live_url_channel_test.go @@ -0,0 +1,248 @@ +package sysutil + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// resetLiveURLFile isolates the process-wide singleton path cache so each test +// computes a fresh path under its own HOME override, and re-resets afterwards +// so later tests start clean. No test using this may run in parallel because +// it mutates HOME. +func resetLiveURLFile(t *testing.T) { + t.Helper() + ResetLiveURLFileForTest() + t.Cleanup(ResetLiveURLFileForTest) +} + +// resetExecIDFile is the execID equivalent of resetLiveURLFile. +func resetExecIDFile(t *testing.T) { + t.Helper() + ResetExecIDFileForTest() + t.Cleanup(ResetExecIDFileForTest) +} + +func TestLiveURLFilePathUnderHome(t *testing.T) { + resetLiveURLFile(t) + home := t.TempDir() + t.Setenv("HOME", home) + + got := LiveURLFilePath() + if got == "" { + t.Fatal("LiveURLFilePath() = empty with HOME set") + } + if dir := filepath.Dir(got); dir != filepath.Join(home, ".qmax-code") { + t.Errorf("path dir = %q, want %q", dir, filepath.Join(home, ".qmax-code")) + } + wantBase := ".live-url-" + strconv.Itoa(os.Getpid()) + if base := filepath.Base(got); base != wantBase { + t.Errorf("path base = %q, want %q", base, wantBase) + } + if fi, err := os.Stat(filepath.Join(home, ".qmax-code")); err != nil || !fi.IsDir() { + t.Errorf(".qmax-code directory not created: %v", err) + } + + // The path is computed once per process: changing HOME must not change it. + t.Setenv("HOME", t.TempDir()) + if again := LiveURLFilePath(); again != got { + t.Errorf("LiveURLFilePath() = %q after HOME change, want cached %q", again, got) + } +} + +func TestLiveURLFilePathNoHome(t *testing.T) { + resetLiveURLFile(t) + t.Setenv("HOME", "") + if got := LiveURLFilePath(); got != "" { + t.Errorf("LiveURLFilePath() = %q with no HOME, want empty", got) + } +} + +func TestPersistLiveURLForParent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "live-url") + t.Setenv("QMAX_LIVE_URL_FILE", path) + + // Empty URL is a no-op even with the env var set. + PersistLiveURLForParent("") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("file created for empty URL: %v", err) + } + + PersistLiveURLForParent("https://6080-sandbox.example/#") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("side-channel file not written: %v", err) + } + if string(data) != "https://6080-sandbox.example/#" { + t.Errorf("file content = %q", string(data)) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Error("atomic-write tmp file left behind") + } + + // Without the env var (empty after TrimSpace) nothing is persisted. + t.Setenv("QMAX_LIVE_URL_FILE", "") + if err := os.Remove(path); err != nil { + t.Fatalf("Remove: %v", err) + } + PersistLiveURLForParent("https://other.example/") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("persisted without QMAX_LIVE_URL_FILE") + } +} + +func TestPersistLiveURLForParentWriteFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "live-url") + t.Setenv("QMAX_LIVE_URL_FILE", path) + + // Block the atomic-write tmp path with a directory: the tmp write fails + // and the error must be swallowed (best-effort channel). + if err := os.Mkdir(path+".tmp", 0o700); err != nil { + t.Fatalf("Mkdir: %v", err) + } + PersistLiveURLForParent("https://sandbox.example/") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("target file created despite write failure") + } +} + +func TestDrainLiveURLFromChild(t *testing.T) { + resetLiveURLFile(t) + home := t.TempDir() + t.Setenv("HOME", home) + + if got := DrainLiveURLFromChild(); got != "" { + t.Errorf("drain with no file = %q, want empty", got) + } + + path := LiveURLFilePath() + if err := os.WriteFile(path, []byte(" https://sandbox.example/feed \n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if got := DrainLiveURLFromChild(); got != "https://sandbox.example/feed" { + t.Errorf("drain = %q, want trimmed URL", got) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("side-channel file not removed after drain") + } + if got := DrainLiveURLFromChild(); got != "" { + t.Errorf("second drain = %q, want empty", got) + } +} + +func TestDrainLiveURLFromChildNoHome(t *testing.T) { + resetLiveURLFile(t) + t.Setenv("HOME", "") + if got := DrainLiveURLFromChild(); got != "" { + t.Errorf("drain with no HOME = %q, want empty", got) + } +} + +func TestExecIDFilePathUnderHome(t *testing.T) { + resetExecIDFile(t) + home := t.TempDir() + t.Setenv("HOME", home) + + got := ExecIDFilePath() + if got == "" { + t.Fatal("ExecIDFilePath() = empty with HOME set") + } + wantBase := ".exec-id-" + strconv.Itoa(os.Getpid()) + if base := filepath.Base(got); base != wantBase { + t.Errorf("path base = %q, want %q", base, wantBase) + } + + t.Setenv("HOME", t.TempDir()) + if again := ExecIDFilePath(); again != got { + t.Errorf("ExecIDFilePath() = %q after HOME change, want cached %q", again, got) + } +} + +func TestExecIDFilePathNoHome(t *testing.T) { + resetExecIDFile(t) + t.Setenv("HOME", "") + if got := ExecIDFilePath(); got != "" { + t.Errorf("ExecIDFilePath() = %q with no HOME, want empty", got) + } +} + +func TestPersistExecIDForParent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exec-id") + t.Setenv("QMAX_EXEC_ID_FILE", path) + + PersistExecIDForParent("") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("file created for empty exec ID: %v", err) + } + + PersistExecIDForParent("exec_12345") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("side-channel file not written: %v", err) + } + if string(data) != "exec_12345" { + t.Errorf("file content = %q", string(data)) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Error("atomic-write tmp file left behind") + } + + t.Setenv("QMAX_EXEC_ID_FILE", "") + if err := os.Remove(path); err != nil { + t.Fatalf("Remove: %v", err) + } + PersistExecIDForParent("exec_67890") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("persisted without QMAX_EXEC_ID_FILE") + } +} + +func TestPersistExecIDForParentWriteFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "exec-id") + t.Setenv("QMAX_EXEC_ID_FILE", path) + + if err := os.Mkdir(path+".tmp", 0o700); err != nil { + t.Fatalf("Mkdir: %v", err) + } + PersistExecIDForParent("exec_12345") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("target file created despite write failure") + } +} + +func TestDrainExecIDFromChild(t *testing.T) { + resetExecIDFile(t) + home := t.TempDir() + t.Setenv("HOME", home) + + if got := DrainExecIDFromChild(); got != "" { + t.Errorf("drain with no file = %q, want empty", got) + } + + path := ExecIDFilePath() + if err := os.WriteFile(path, []byte("exec_abc123\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if got := DrainExecIDFromChild(); got != "exec_abc123" { + t.Errorf("drain = %q, want exec_abc123", got) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("side-channel file not removed after drain") + } + if got := DrainExecIDFromChild(); got != "" { + t.Errorf("second drain = %q, want empty", got) + } +} + +func TestDrainExecIDFromChildNoHome(t *testing.T) { + resetExecIDFile(t) + t.Setenv("HOME", "") + if got := DrainExecIDFromChild(); got != "" { + t.Errorf("drain with no HOME = %q, want empty", got) + } +} diff --git a/internal/sysutil/logging_test.go b/internal/sysutil/logging_test.go new file mode 100644 index 0000000..1183f66 --- /dev/null +++ b/internal/sysutil/logging_test.go @@ -0,0 +1,92 @@ +package sysutil + +import ( + "bufio" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestLoggerWritesConcurrentStructuredEntries(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + l := NewLogger("fixture") + defer l.Close() + if trace, err := hex.DecodeString(l.TraceID()); err != nil || len(trace) != 8 { + t.Fatal("trace ID must contain eight random bytes") + } + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { defer wg.Done(); l.Info("test", "entry", map[string]interface{}{"index": i}) }(i) + } + wg.Wait() + l.Error("test", "failure") + l.Info("test", "complete") + f, err := os.Open(filepath.Join(home, ".qmax-code", "logs", "session-fixture.log")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + scanner := bufio.NewScanner(f) + seen := make(map[int]bool) + count := 0 + for scanner.Scan() { + var entry map[string]interface{} + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + t.Fatalf("concurrent log writes produced invalid JSON: %v", err) + } + if _, err := time.Parse(time.RFC3339Nano, entry["ts"].(string)); err != nil || entry["trace_id"] != l.TraceID() || entry["component"] != "test" { + t.Fatal("structured log metadata missing") + } + if n, ok := entry["index"].(float64); ok { + seen[int(n)] = true + } + if entry["msg"] == "failure" && entry["level"] != "error" { + t.Fatal("Error did not set error level") + } + count++ + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if count != 22 || len(seen) != 20 { + t.Fatalf("log contains %d entries and %d unique concurrent entries", count, len(seen)) + } +} + +func TestLoggerDegradesWhenHomeUnavailable(t *testing.T) { + for _, home := range []string{"", filepath.Join(t.TempDir(), "blocked")} { + t.Run(home, func(t *testing.T) { + if home != "" { + if err := os.WriteFile(home, nil, 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("HOME", home) + l := NewLogger("fixture") + l.Info("test", "discard") + l.Error("test", "discard") + l.Close() + if l.file != nil || l.TraceID() == "" { + t.Fatal("unavailable log directory must produce a usable no-op logger") + } + }) + } +} + +func TestMaskURLHidesPasswordAndPreservesAddress(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"https://alice:fixture-password@example.test:8443/path?mode=1#top", "https://alice:%2A%2A%2A%2A@example.test:8443/path?mode=1#top"}, + {"https://example.test/path", "https://example.test/path"}, + {"http://%invalid", "http://%invalid"}, + } { + if got := MaskURL(tc.input); got != tc.want { + t.Fatalf("masked URL = %q, want %q", got, tc.want) + } + } +} diff --git a/internal/sysutil/telemetry_privacy_test.go b/internal/sysutil/telemetry_privacy_test.go new file mode 100644 index 0000000..02d90bc --- /dev/null +++ b/internal/sysutil/telemetry_privacy_test.go @@ -0,0 +1,151 @@ +package sysutil + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/getsentry/sentry-go" +) + +func TestTelemetryRequiresBothOptInAndDSN(t *testing.T) { + old := initSentry + t.Cleanup(func() { initSentry = old }) + for _, tc := range []struct { + name, enabled, dsn string + wantCalls int + }{ + {"disabled", "0", "https://public@example.test/1", 0}, + {"missing DSN", "yes", "", 0}, + {"enabled", "TRUE", "https://public@example.test/1", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(telemetryEnabledEnv, tc.enabled) + t.Setenv(telemetryDSNEnv, tc.dsn) + calls := 0 + initSentry = func(opts sentry.ClientOptions) error { + calls++ + if opts.Release != "qmax-code@test" || opts.Environment != "production" || !opts.AttachStacktrace || opts.BeforeSend == nil { + t.Error("telemetry release, stacktrace, or privacy configuration missing") + } + return errors.New("fixture initialization failure") + } + InitErrorReporting("test") // initialization failures must remain best-effort + if calls != tc.wantCalls { + t.Fatalf("initialization calls = %d, want %d", calls, tc.wantCalls) + } + }) + } +} + +func TestEnvEnabledRecognizesAffirmativeValues(t *testing.T) { + for _, value := range []string{"1", "true", "TRUE", "yes", "on", "enabled", "0", "false", "", "maybe"} { + t.Run(value, func(t *testing.T) { + t.Setenv("QMAX_TEST_FLAG", value) + want := value == "1" || value == "true" || value == "TRUE" || value == "yes" || value == "on" || value == "enabled" + if EnvEnabled("QMAX_TEST_FLAG") != want { + t.Fatalf("incorrect flag result for %q", value) + } + }) + } +} + +func TestTelemetrySanitizerDropsContentAndBoundsMetadata(t *testing.T) { + if sanitizeEvent(nil, nil) != nil { + t.Fatal("nil event must remain nil") + } + event := &sentry.Event{ + Tags: map[string]string{ + "Prompt": "fixture content", "input.text": "fixture content", "file_path": "fixture path", + "response": "fixture response", "backend": "ollama", "input_len": "25", "status_code": "500", + "model": strings.Repeat("m", 300), + }, + Exception: []sentry.Exception{{Value: strings.Repeat("e", 1100)}, {Value: "short"}}, + Breadcrumbs: []*sentry.Breadcrumb{ + {Message: strings.Repeat("b", 300), Data: map[string]interface{}{"content": "fixture"}}, + {Message: "short", Data: map[string]interface{}{"body": "fixture"}}, + }, + } + got := sanitizeEvent(event, nil) + if got != event || len(got.Tags) != 3 || got.Tags["backend"] != "ollama" || got.Tags["status_code"] != "500" { + t.Fatal("content tags were retained or structural metadata was lost") + } + if got.Tags["model"] != strings.Repeat("m", 256)+"…" || got.Exception[0].Value != strings.Repeat("e", 1024)+" …[truncated]" || got.Exception[1].Value != "short" { + t.Fatal("metadata limits were not applied") + } + if got.Breadcrumbs[0].Message != strings.Repeat("b", 256)+"…" || got.Breadcrumbs[1].Message != "short" { + t.Fatal("breadcrumb message limits were not applied") + } + for _, bc := range got.Breadcrumbs { + if bc.Data != nil { + t.Fatal("breadcrumb content was retained") + } + } +} + +// This transport records events synchronously and can never send network traffic. +type recordingTelemetryTransport struct{ events []*sentry.Event } + +func (t *recordingTelemetryTransport) Configure(sentry.ClientOptions) {} +func (t *recordingTelemetryTransport) SendEvent(event *sentry.Event) { + t.events = append(t.events, event) +} +func (t *recordingTelemetryTransport) Flush(time.Duration) bool { return true } +func (t *recordingTelemetryTransport) FlushWithContext(context.Context) bool { return true } +func (t *recordingTelemetryTransport) Close() {} + +func TestCaptureRedactsCredentialsAndScopesTags(t *testing.T) { + transport := &recordingTelemetryTransport{} + client, err := sentry.NewClient(sentry.ClientOptions{ + Dsn: "https://public@example.test/1", Transport: transport, BeforeSend: sanitizeEvent, + Integrations: func([]sentry.Integration) []sentry.Integration { return nil }, + }) + if err != nil { + t.Fatal(err) + } + hub := sentry.CurrentHub() + oldClient := hub.Client() + hub.PushScope() + hub.BindClient(client) + t.Cleanup(func() { hub.BindClient(oldClient); hub.PopScope() }) + const fixtureURL = "https://fixture-user:fixture-password@example.test" + CaptureError(errors.New("failed at "+fixtureURL), map[string]interface{}{"backend": "ollama", "endpoint": fixtureURL, "prompt": "fixture content"}) + CaptureMessage("retry " + fixtureURL) + CaptureError(errors.New("without context"), nil) + CaptureError(nil, nil) + FlushErrorReporting() + if len(transport.events) != 4 { + t.Fatalf("captured %d events, want 4 including Sentry's nil-error diagnostic", len(transport.events)) + } + first := transport.events[0] + if first.Tags["backend"] != "ollama" || first.Tags["prompt"] != "" || !strings.Contains(first.Tags["endpoint"], "REDACTED") { + t.Fatal("capture failed to sanitize context tags") + } + if len(first.Exception) != 1 || strings.Contains(first.Exception[0].Value, "fixture-password") || !strings.Contains(first.Exception[0].Value, "REDACTED") { + t.Fatal("captured error retained fixture credentials") + } + second := transport.events[1] + if strings.Contains(second.Message, "fixture-password") || !strings.Contains(second.Message, "REDACTED") || len(second.Tags) != 0 { + t.Fatal("message retained credentials or tags leaked out of their scope") + } + for _, value := range []interface{}{errors.New("fixture panic"), "fixture panic"} { + func() { + defer func() { + if recovered := recover(); recovered != value { + t.Errorf("RecoverPanic changed the original panic") + } + }() + defer RecoverPanic() + panic(value) + }() + } + if len(transport.events) != 6 { + t.Fatal("panic events were not captured") + } + func() { defer RecoverPanic() }() // normal completion must not generate an event + if len(transport.events) != 6 { + t.Fatal("normal completion generated a panic event") + } +} diff --git a/internal/tui/haiku55_test.go b/internal/tui/haiku55_test.go new file mode 100644 index 0000000..fffd279 --- /dev/null +++ b/internal/tui/haiku55_test.go @@ -0,0 +1,49 @@ +package tui + +import ( + "regexp" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func TestHaiku55PickerIsExplicit(t *testing.T) { + for name, entries := range map[string][]pickerEntry{"claude code": ccModels, "api": apiModels} { + t.Run(name, func(t *testing.T) { + var oldFound, newFound bool + for _, entry := range entries { + if entry.modelID == api.ModelHaiku { + oldFound = true + } + if entry.modelID == api.ModelHaiku55 { + newFound = true + if entry.isFav || entry.label != "Haiku 5.5" { + t.Fatalf("new model must be an explicit option: %+v", entry) + } + } + } + if !oldFound || !newFound { + t.Fatal("both Haiku versions must remain available") + } + }) + } +} + +func TestUsageDisplaysIncludeCachedPromptTokens(t *testing.T) { + term := &Terminal{} + usage := api.TokenUsage{InputTokens: 1, CacheReadInputTokens: 80, CacheCreationInputTokens: 20, OutputTokens: 10} + for name, tt := range map[string]struct { + print func() + want string + }{ + "turn": {func() { term.PrintTokenUsage(usage) }, `tokens: 101 in / 10 out \(session: 111 total`}, + "cost": {func() { term.PrintCostSummary(usage, api.ModelHaiku55, nil) }, `Input tokens:\s+101`}, + "status": {func() { term.PrintStatusInfo(&api.SessionContext{LocalOnly: true}, usage, api.ModelHaiku55, nil) }, `Session tokens:\s+101 in / 10 out`}, + } { + t.Run(name, func(t *testing.T) { + if out := captureTermOutput(t, tt.print); !regexp.MustCompile(tt.want).MatchString(out) { + t.Fatalf("cache tokens missing from %s: %s", name, out) + } + }) + } +} diff --git a/internal/tui/input.go b/internal/tui/input.go index 60eb670..b5d3065 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -706,7 +706,7 @@ type InputResult struct { func ReadInput(prompt string, history []string, outputVerbose bool, status *StatusInfo) InputResult { m := newInputModelWithOutputMode(prompt, history, outputVerbose) m.status = status - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return InputResult{} diff --git a/internal/tui/input_viewport_extra_test.go b/internal/tui/input_viewport_extra_test.go new file mode 100644 index 0000000..4bd9f76 --- /dev/null +++ b/internal/tui/input_viewport_extra_test.go @@ -0,0 +1,171 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func km(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } + +func alt(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t, Alt: true} } + +func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +func TestInputEditingKeys(t *testing.T) { + m := newInputModel("qmax > ", []string{"first", "second"}) + m.status = &StatusInfo{Backend: "cc", Model: "opus", Effort: "high", PermissionMode: "standard", TokensIn: 1200, TokensOut: 300, + ContextUsed: 50_000, ContextWindow: 200_000, LastTurnDur: 3 * time.Second, SessionStarted: time.Now().Add(-time.Minute), + Task: "a long running task description that keeps going", PlanActive: true, PlanRemaining: time.Hour, PlanTurns: 3, OutputVerbose: true} + if m.Init() == nil { + t.Fatal("status with a start time should tick") + } + steps := []tea.KeyMsg{ + runes("hello"), km(tea.KeySpace), runes("world"), + km(tea.KeyLeft), km(tea.KeyLeft), km(tea.KeyRight), km(tea.KeyBackspace), km(tea.KeyDelete), + km(tea.KeyCtrlA), km(tea.KeyCtrlE), km(tea.KeyCtrlW), km(tea.KeyCtrlK), km(tea.KeyCtrlU), + runes("abc def"), alt(tea.KeyRunes), {Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}, {Type: tea.KeyRunes, Runes: []rune("f"), Alt: true}, + km(tea.KeyHome), km(tea.KeyEnd), km(tea.KeyCtrlX), km(tea.KeyCtrlX), km(tea.KeyCtrlX), + km(tea.KeyUp), km(tea.KeyUp), km(tea.KeyUp), km(tea.KeyDown), km(tea.KeyDown), km(tea.KeyDown), + {Type: tea.KeyRunes, Runes: []rune("pasted\ntext"), Paste: true}, + } + var model tea.Model = m + for _, k := range steps { + model, _ = model.Update(k) + if model.View() == "" { + t.Fatalf("empty view after %v", k) + } + } + model, _ = model.Update(tea.WindowSizeMsg{Width: 40, Height: 10}) + model, cmd := model.Update(statusTickMsg(time.Now())) + if cmd == nil { + t.Fatal("status tick should reschedule") + } + _ = model.View() + model, _ = model.Update("unknown msg") + if _, cmd := model.Update(km(tea.KeyEnter)); cmd == nil { + t.Fatal("enter should submit") + } + if next, _ := model.Update(km(tea.KeyCtrlO)); !next.(inputModel).outputToggle { + t.Fatal("ctrl+o should request an output toggle") + } + empty := newInputModel("> ", nil) + if next, cmd := empty.Update(km(tea.KeyCtrlC)); cmd == nil || !next.(inputModel).ctrlC { + t.Fatal("ctrl+c should quit") + } +} + +func TestInputSlashMenu(t *testing.T) { + var model tea.Model = newInputModel("> ", nil) + model, _ = model.Update(runes("/")) + if model.(inputModel).mode != modeMenu { + t.Fatal("slash did not open the menu") + } + for _, k := range []tea.KeyMsg{km(tea.KeyUp), km(tea.KeyDown), km(tea.KeyDown), runes("he"), km(tea.KeyBackspace), km(tea.KeyBackspace), km(tea.KeyBackspace)} { + model, _ = model.Update(k) + _ = model.View() + } + if model.(inputModel).mode != modeTyping { + t.Fatal("backspace past the filter should close the menu") + } + model, _ = model.Update(runes("/")) + model, _ = model.Update(km(tea.KeyEsc)) + if model.(inputModel).mode != modeTyping { + t.Fatal("esc should close the menu") + } + model, _ = model.Update(runes("/")) + model, _ = model.Update(runes("update")) + next, cmd := model.Update(km(tea.KeyEnter)) + if cmd == nil || next.(inputModel).result != "/update" { + t.Fatalf("exact command = %q", next.(inputModel).result) + } + model, _ = newInputModel("> ", nil).Update(runes("/")) + model, _ = model.Update(runes("cos")) + if next, _ := model.Update(km(tea.KeyEnter)); next.(inputModel).result != "/cost" { + t.Fatalf("filtered selection = %q", next.(inputModel).result) + } + model, _ = newInputModel("> ", nil).Update(runes("/")) + model, _ = model.Update(runes("zzzz")) + if _, cmd := model.Update(km(tea.KeyEnter)); cmd != nil { + t.Fatal("enter with no matches should not submit") + } +} + +func TestReadInputScripted(t *testing.T) { + scriptedPrograms(t, "hi there\r") + if got := ReadInput("> ", nil, false, nil); got.Text != "hi there" { + t.Fatalf("ReadInput = %+v", got) + } + scriptedPrograms(t, "\x0f") + if got := ReadInput("> ", nil, true, &StatusInfo{}); !got.OutputToggle { + t.Fatalf("ctrl+o = %+v", got) + } +} + +func TestTurnViewportKeysAndMarks(t *testing.T) { + canceled := 0 + m := newTurnViewportModel("> ", &StatusInfo{Backend: "api", Model: "sonnet"}, func() { canceled++ }) + var model tea.Model = m + model, _ = model.Update(tea.WindowSizeMsg{Width: 60, Height: 12}) + for i := 0; i < 40; i++ { + model, _ = model.Update(turnOutputMsg("line " + strings.Repeat("x", i%7) + "\n")) + if i%10 == 0 { + model, _ = model.Update(turnMarkMsg{}) + } + } + model, _ = model.Update(turnOutputMsg("progress 10%\rprogress 90%\n")) + model, _ = model.Update(turnThinkingMsg(true)) + model, _ = model.Update(turnActivityMsg("Running test... 50%")) + model, _ = model.Update(turnReplaceTailMsg{rawLen: 3, rendered: "RENDERED\n"}) + keys := []tea.KeyMsg{ + km(tea.KeyUp), km(tea.KeyUp), km(tea.KeyDown), km(tea.KeyPgUp), km(tea.KeyPgDown), km(tea.KeyPgDown), + alt(tea.KeyUp), alt(tea.KeyUp), alt(tea.KeyUp), alt(tea.KeyUp), alt(tea.KeyDown), alt(tea.KeyDown), alt(tea.KeyDown), alt(tea.KeyDown), + runes("next prompt"), km(tea.KeySpace), runes("x"), km(tea.KeyLeft), alt(tea.KeyLeft), km(tea.KeyRight), alt(tea.KeyRight), + km(tea.KeyCtrlLeft), km(tea.KeyCtrlRight), km(tea.KeyCtrlA), km(tea.KeyCtrlE), km(tea.KeyHome), km(tea.KeyEnd), + {Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}, {Type: tea.KeyRunes, Runes: []rune("f"), Alt: true}, + km(tea.KeyBackspace), km(tea.KeyDelete), km(tea.KeyCtrlW), km(tea.KeyCtrlK), km(tea.KeyCtrlU), runes("queued"), + } + for _, k := range keys { + model, _ = model.Update(k) + if model.View() == "" { + t.Fatalf("empty view after %v", k) + } + } + model, _ = model.Update(turnThinkingMsg(false)) + next, cmd := model.Update(km(tea.KeyEnter)) + if cmd == nil || next.(turnViewportModel).result.Text != "queued" { + t.Fatalf("enter result = %+v", next.(turnViewportModel).result) + } + if _, cmd := newTurnViewportModel("> ", nil, nil).Update(km(tea.KeyCtrlC)); cmd == nil { + t.Fatal("ctrl+c should cancel the turn") + } + if _, cmd := newTurnViewportModel("> ", nil, nil).Update(km(tea.KeyEnter)); cmd != nil { + t.Fatal("enter with empty input should do nothing") + } +} + +func TestRunTurnViewportScripted(t *testing.T) { + scriptedPrograms(t, "") + term := &Terminal{} + out := captureTermOutput(t, func() { + res := RunTurnViewport(term, "> ", &StatusInfo{}, nil, func() { + term.StartThinking() + term.PrintSystem("working") + term.StreamText("**done**") + term.FinishMarkdown("**done**") + term.PrintFileDiff("f.go", "a\n", "b\n") + if !term.SetTurnActivity("busy") { + t.Error("activity should route to the live viewport") + } + term.StopThinking() + }) + if res.Canceled { + t.Errorf("unexpected cancel: %+v", res) + } + }) + if !strings.Contains(out, "working") { + t.Fatalf("turn output was not re-emitted: %q", out) + } +} diff --git a/internal/tui/media.go b/internal/tui/media.go index b707a16..80a8460 100644 --- a/internal/tui/media.go +++ b/internal/tui/media.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) const largePastedTextThreshold = 8 * 1024 diff --git a/internal/tui/media_image_extra_test.go b/internal/tui/media_image_extra_test.go new file mode 100644 index 0000000..01e4a68 --- /dev/null +++ b/internal/tui/media_image_extra_test.go @@ -0,0 +1,184 @@ +package tui + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +func writePNG(t *testing.T, path string, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{uint8(x * 8), uint8(y * 8), 128, 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + if path != "" { + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + } + return buf.Bytes() +} + +// plainTerminalEnv makes rasterm fall back to half-block rendering. +func plainTerminalEnv(t *testing.T) { + t.Helper() + for _, k := range []string{"TERM_PROGRAM", "LC_TERMINAL", "KITTY_WINDOW_ID", "TERMINAL_EMULATOR", "KONSOLE_VERSION"} { + t.Setenv(k, "") + } + t.Setenv("TERM", "dumb") +} + +func TestDisplayImageHalfBlocks(t *testing.T) { + plainTerminalEnv(t) + dir := t.TempDir() + path := filepath.Join(dir, "shot.png") + data := writePNG(t, path, 30, 9) + + out := captureTermOutput(t, func() { + if err := DisplayImage(path, 10); err != nil { + t.Errorf("DisplayImage: %v", err) + } + if err := DisplayImageFromBase64(base64.StdEncoding.EncodeToString(data), 0); err != nil { + t.Errorf("DisplayImageFromBase64: %v", err) + } + }) + if !strings.Contains(out, "▀") { + t.Fatalf("no half-block output: %q", out) + } + if err := DisplayImage(filepath.Join(dir, "missing.png"), 10); err == nil { + t.Fatal("missing file should error") + } + notImage := filepath.Join(dir, "x.png") + _ = os.WriteFile(notImage, []byte("nope"), 0o644) + if err := DisplayImage(notImage, 10); err == nil { + t.Fatal("non-image should error") + } + if err := DisplayImageFromBase64("%%%", 10); err == nil { + t.Fatal("bad base64 should error") + } + if p := toPaletted(image.NewRGBA(image.Rect(0, 0, 2, 2))); p.Bounds().Dx() != 2 { + t.Fatal("toPaletted bounds") + } + out = captureTermOutput(t, func() { RenderScreenshotCompact("Final state", "https://cdn.example/"+strings.Repeat("p", 60)) }) + if !strings.Contains(out, "📸 Final state") { + t.Fatalf("compact screenshot = %q", out) + } +} + +func TestMediaHelpersWithStubbedTools(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX stubs") + } + dir := t.TempDir() + pngPath := filepath.Join(dir, "fixture.png") + writePNG(t, pngPath, 4, 4) + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + stub := func(name, body string) { + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + stub("screencapture", `cp "`+pngPath+`" "$2"`) + stub("pngpaste", `cp "`+pngPath+`" "$1"`) + stub("pbpaste", `printf 'clipboard text'`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+"/usr/bin:/bin") + + if img, err := CaptureScreenshot(); err != nil || img.MediaType != "image/png" { + t.Fatalf("CaptureScreenshot = %+v, %v", img, err) + } + if img, err := PasteImageFromClipboard(); err != nil || img.FileName == "" { + t.Fatalf("PasteImageFromClipboard = %+v, %v", img, err) + } + if text, err := PasteTextFromClipboard(); err != nil || text != "clipboard text" { + t.Fatalf("PasteTextFromClipboard = %q, %v", text, err) + } + + stub("screencapture", `exit 0`) + if _, err := CaptureScreenshot(); err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Fatalf("cancelled screenshot err = %v", err) + } + stub("screencapture", `exit 1`) + if _, err := CaptureScreenshot(); err == nil { + t.Fatal("failing screencapture should error") + } + stub("pngpaste", `exit 0`) + if _, err := PasteImageFromClipboard(); err == nil { + t.Fatal("empty clipboard should error") + } + stub("pngpaste", `exit 1`) + if _, err := PasteImageFromClipboard(); err == nil { + t.Fatal("pngpaste failure should error") + } + stub("pbpaste", `exit 1`) + if _, err := PasteTextFromClipboard(); err == nil { + t.Fatal("pbpaste failure should error") + } + + text, imgs := DetectAndLoadImages(`look at '` + pngPath + `' and ` + filepath.Join(dir, "missing.png")) + if len(imgs) != 1 || !strings.Contains(text, "missing.png") || strings.Contains(text, "fixture.png") { + t.Fatalf("DetectAndLoadImages = %q, %d images", text, len(imgs)) + } + if _, err := LoadImageFromFile(filepath.Join(dir, "nope.gif")); err == nil { + t.Fatal("missing file should error") + } +} + +func TestSaveThemeAndProgress(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := SaveTheme(nil, "x"); err == nil { + t.Fatal("nil config should error") + } + cfg := api.DefaultConfig() + if err := SaveTheme(cfg, "not-a-theme"); err == nil { + t.Fatal("invalid theme should error") + } + if err := SaveTheme(cfg, ThemeNames()[0]); err != nil || cfg.Theme != ThemeNames()[0] { + t.Fatalf("SaveTheme = %v (%q)", err, cfg.Theme) + } + if err := SaveTheme(cfg, ""); err != nil || cfg.Theme != "" { + t.Fatalf("clear theme = %v", err) + } + t.Setenv("HOME", "/dev/null/nope") + if err := SaveTheme(cfg, ThemeNames()[0]); err == nil || cfg.Theme != "" { + t.Fatalf("unsavable config should roll back: %v %q", err, cfg.Theme) + } + + out := captureTermOutput(t, func() { + p := NewProgressBar("Running", 0) + p.Update(40, "") + p.Update(150, "almost there") + p.Finish(false, "failed") + p.Update(10, "ignored after finish") + NewProgressBar("ok", 10).Finish(true, "done") + ShowBrowserAnimation(0) + ShowBrowserAnimation(3) + ClearBrowserAnimation() + AnimateMax(MoodHappy, "hi") + AnimateMaxTransition(MoodNeutral, MoodHappy, "connected") + }) + expectAll(t, out, "Running", "almost there", "failed", "done", "connected") + for _, s := range []string{GetMaxGreeting(), GetMaxThought(), GetMaxSuccess(), GetMaxError(), GetMaxArt(MoodSad, "")} { + if s == "" { + t.Fatal("empty Max art") + } + } +} diff --git a/internal/tui/pickers_test.go b/internal/tui/pickers_test.go new file mode 100644 index 0000000..77f9878 --- /dev/null +++ b/internal/tui/pickers_test.go @@ -0,0 +1,292 @@ +package tui + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// keyMsg builds a Bubble Tea key event from a readable name. +func keyMsg(name string) tea.KeyMsg { + switch name { + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "ctrl+c": + return tea.KeyMsg{Type: tea.KeyCtrlC} + case "space": + return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + } + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(name)} +} + +// drive sends keys to a model, rendering after every step, and returns the +// final model plus whether any step requested a command. +func drive(t *testing.T, m tea.Model, keys ...string) (tea.Model, bool) { + t.Helper() + _ = m.View() + quit := false + for _, k := range keys { + var cmd tea.Cmd + m, cmd = m.Update(keyMsg(k)) + if cmd != nil { + quit = true + } + if v := m.View(); v == "" { + t.Fatalf("empty view after %q", k) + } + } + return m, quit +} + +// scriptedPrograms makes every picker program read keys from input and render +// nowhere, for the duration of the test. +func scriptedPrograms(t *testing.T, input string) { + t.Helper() + orig := programOptions + programOptions = []tea.ProgramOption{tea.WithInput(strings.NewReader(input)), tea.WithOutput(io.Discard)} + t.Cleanup(func() { programOptions = orig }) +} + +func repeat(k string, n int) []string { + out := make([]string, n) + for i := range out { + out[i] = k + } + return out +} + +func TestModelPickerWalksEveryRow(t *testing.T) { + for _, installed := range []bool{true, false} { + oc := []OpenCodeModelEntry{ + {ProviderID: "groq", ProviderName: "Groq", ModelID: "groq/llama", Label: "llama"}, + {ProviderID: "openrouter", ProviderName: "Enter to enable", ModelID: "enable:openrouter", Label: "OpenRouter"}, + } + m := newModelPickerModel("cerebras", "gemma-4-31b", "", "http://u:p@ollama.local", "llama3.2", installed, installed, installed, installed, installed, oc) + if m.effort != "high" { + t.Fatalf("default effort = %q", m.effort) + } + next, _ := m.Update(ollamaProbeMsg{reachable: installed}) + m = next.(modelPickerModel) + keys := append(repeat("up", len(m.allEntries)+2), repeat("down", len(m.allEntries)+2)...) + keys = append(keys, "tab", "left", "left", "left", "right", "right", "right", "h", "l", "tab", "k", "j") + final, _ := drive(t, m, keys...) + fm := final.(modelPickerModel) + if fm.cursor != len(fm.allEntries)-1 { + t.Fatalf("cursor = %d, want last row", fm.cursor) + } + final, quit := drive(t, fm, "enter") + if !quit || final.(modelPickerModel).chosen == nil || final.(modelPickerModel).chosen.backend != "ollama" { + t.Fatalf("enter did not choose the last (ollama) row") + } + } + + m := newModelPickerModel("cc", "", "low", "", "", true, true, false, false, false, nil) + final, quit := drive(t, m, "3") + if !quit || final.(modelPickerModel).chosen == nil { + t.Fatal("shortcut 3 did not choose a model") + } + final, quit = drive(t, m, "q") + if !quit || !final.(modelPickerModel).cancelled { + t.Fatal("q did not cancel") + } + if _, quit := drive(t, m, "z"); quit { + t.Fatal("unbound key quit the picker") + } + empty := modelPickerModel{} + if next, _ := empty.Update(keyMsg("enter")); !next.(modelPickerModel).cancelled { + t.Fatal("enter on an empty picker should cancel") + } + if m.Init() != nil { + t.Fatal("no ollama URL should mean no probe") + } +} + +func TestModelPickerProbesOllama(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + t.Errorf("probe path = %s", r.URL.Path) + } + _, _ = io.WriteString(w, "{}") + })) + defer srv.Close() + m := newModelPickerModel("ollama", "m", "", srv.URL, "m", false, false, false, false, false, nil) + msg := m.Init()() + if probe, ok := msg.(ollamaProbeMsg); !ok || !probe.reachable { + t.Fatalf("probe = %#v", msg) + } + if probeOllamaReachable("") || probeOllamaReachable("http://127.0.0.1:1") || probeOllamaReachable("://bad") { + t.Fatal("unreachable endpoints reported reachable") + } +} + +func TestShowModelPickerScripted(t *testing.T) { + scriptedPrograms(t, "\x1b[B\r") + res := ShowModelPicker(ModelPickerOpts{CurrentBackend: "cc", CCInstalled: true, Effort: "medium"}) + if !res.Confirmed || res.Effort != "medium" { + t.Fatalf("result = %+v", res) + } + scriptedPrograms(t, "q") + if res := ShowModelPicker(ModelPickerOpts{}); res.Confirmed { + t.Fatalf("q should cancel: %+v", res) + } +} + +func TestThemePickerPreviewAndRestore(t *testing.T) { + t.Cleanup(func() { ApplyTheme(ThemeByName("")) }) + names := ThemeNames() + m := newThemePickerModel(names[len(names)-1]) + keys := append(repeat("up", len(names)+1), repeat("down", len(names)+1)...) + final, _ := drive(t, m, keys...) + if _, quit := drive(t, final, "esc"); !quit { + t.Fatal("esc did not quit") + } + if final, quit := drive(t, final, "enter"); !quit || !final.(themePickerModel).confirmed { + t.Fatal("enter did not confirm") + } + if m.Init() != nil { + t.Fatal("theme picker Init should be nil") + } + + scriptedPrograms(t, "\r") + if got, ok := ShowThemePicker(""); !ok || got == "" { + t.Fatalf("ShowThemePicker = %q %v", got, ok) + } + scriptedPrograms(t, "\x1b[B\x1b[Bq") + if got, ok := ShowThemePicker(names[0]); ok || got != names[0] { + t.Fatalf("cancelled ShowThemePicker = %q %v", got, ok) + } +} + +func TestSessionPicker(t *testing.T) { + now := time.Now() + sessions := []SessionPickerItem{ + {ID: "a", UpdatedAt: now.Add(-10 * time.Second), Turns: 2, Tokens: 900, Model: ""}, + {ID: "b", UpdatedAt: now.Add(-5 * time.Minute), Turns: 4, Tokens: 12_000, ProjectID: 7, Model: "claude-sonnet-4-6"}, + {ID: "c", UpdatedAt: now.Add(-3 * time.Hour), Turns: 9, Tokens: 2_500_000, Model: "claude-haiku-4-5-20251001"}, + {ID: "d", UpdatedAt: now.Add(-72 * time.Hour), Turns: 1, Tokens: 5, Model: "gpt"}, + } + m := newSessionPickerModel(sessions, "c") + if m.cursor != 2 { + t.Fatalf("cursor = %d, want the active session", m.cursor) + } + final, _ := drive(t, m, "up", "up", "up", "down", "down", "down", "down", "j", "k") + if final.(sessionPickerModel).cursor != 2 { + t.Fatalf("cursor after walk = %d", final.(sessionPickerModel).cursor) + } + if _, quit := drive(t, m, "esc"); !quit { + t.Fatal("esc did not quit") + } + if formatModelShort("claude-haiku-4-5-20251001") != "haiku-4.5" || formatModelShort("") != "cc" || formatModelShort("gpt") != "gpt" { + t.Fatal("formatModelShort") + } + if m.Init() != nil { + t.Fatal("session Init") + } + + scriptedPrograms(t, "\x1b[B\r") + if id, ok := ShowSessionPicker(sessions, "a"); !ok || id != "b" { + t.Fatalf("ShowSessionPicker = %q %v", id, ok) + } + scriptedPrograms(t, "q") + if _, ok := ShowSessionPicker(sessions, "a"); ok { + t.Fatal("q should cancel") + } +} + +func TestProviderPicker(t *testing.T) { + items := []ProviderPickerItem{ + {ID: "groq", DisplayName: "Groq", Allowed: true, Enabled: true, Status: "enabled"}, + {ID: "blocked", DisplayName: "Blocked", Allowed: false, Status: "not entitled"}, + {ID: "openrouter", DisplayName: "OpenRouter", Allowed: true, Status: "available"}, + } + m := newProviderPickerModel(items) + final, _ := drive(t, m, "down", "down", "down", "up", "up", "up", "2") + if final.(providerPickerModel).cursor != 1 { + t.Fatalf("number shortcut cursor = %d", final.(providerPickerModel).cursor) + } + if _, quit := drive(t, final, "enter"); quit { + t.Fatal("enter on a non-entitled provider must not confirm") + } + if _, quit := drive(t, final, "9", "ctrl+c"); !quit { + t.Fatal("ctrl+c did not quit") + } + empty := newProviderPickerModel(nil) + if next, _ := empty.Update(keyMsg("enter")); !next.(providerPickerModel).cancelled { + t.Fatal("enter with no providers should cancel") + } + _ = empty.View() + if m.Init() != nil { + t.Fatal("provider Init") + } + + scriptedPrograms(t, "3\r") + if res := ShowProviderPicker(items); !res.Confirmed || res.ID != "openrouter" { + t.Fatalf("ShowProviderPicker = %+v", res) + } + scriptedPrograms(t, "q") + if res := ShowProviderPicker(items); res.Confirmed { + t.Fatal("q should cancel") + } +} + +func TestCloudSyncAndLiveFeedPickers(t *testing.T) { + on, off := true, false + for _, current := range []*bool{nil, &on, &off} { + m := newCloudSyncPickerModel(current) + final, _ := drive(t, m, "up", "down", "down", "up", "k", "j") + if _, quit := drive(t, final, "enter"); !quit { + t.Fatal("cloud sync enter did not quit") + } + if _, quit := drive(t, m, "q"); !quit { + t.Fatal("cloud sync q did not quit") + } + _ = m.Init() + } + for _, current := range []bool{true, false} { + m := newLiveFeedPickerModel(current) + final, _ := drive(t, m, "up", "down", "down", "up") + if _, quit := drive(t, final, "space"); !quit { + t.Fatal("live feed space did not quit") + } + if _, quit := drive(t, m, "esc"); !quit { + t.Fatal("live feed esc did not quit") + } + _ = m.Init() + } + + scriptedPrograms(t, "\x1b[B\r") + if v, ok := ShowCloudSyncPicker(nil); !ok || v { + t.Fatalf("ShowCloudSyncPicker = %v %v", v, ok) + } + scriptedPrograms(t, "q") + if _, ok := ShowCloudSyncPicker(&on); ok { + t.Fatal("cloud sync q should cancel") + } + scriptedPrograms(t, "\r") + if v, ok := ShowLiveFeedPicker(true); !ok || !v { + t.Fatalf("ShowLiveFeedPicker = %v %v", v, ok) + } + scriptedPrograms(t, "q") + if _, ok := ShowLiveFeedPicker(false); ok { + t.Fatal("live feed q should cancel") + } +} diff --git a/internal/tui/settings_picker.go b/internal/tui/settings_picker.go index a1cf6c0..e67b714 100644 --- a/internal/tui/settings_picker.go +++ b/internal/tui/settings_picker.go @@ -231,7 +231,7 @@ func (m settingsPickerModel) View() string { // error is reported as a plain cancellation. func ShowSettingsPicker(rows []SettingsRow) SettingsPickerResult { m := newSettingsPickerModel(rows) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return SettingsPickerResult{} diff --git a/internal/tui/settings_picker_test.go b/internal/tui/settings_picker_test.go index 227911e..5855b22 100644 --- a/internal/tui/settings_picker_test.go +++ b/internal/tui/settings_picker_test.go @@ -125,3 +125,41 @@ func TestNextCycleOptionFallsBackToFirst(t *testing.T) { t.Fatalf("empty options should keep current, got %q", got) } } + +func TestSettingsPickerViewAndShow(t *testing.T) { + rows := pickerRows() + rows[2].Hint = "0 = unlimited" + rows[1].Display = func(v string) string { return "[" + v + "]" } + var model tea.Model = newSettingsPickerModel(rows) + if model.Init() != nil { + t.Fatal("settings Init should be nil") + } + steps := []tea.KeyMsg{ + {Type: tea.KeyUp}, {Type: tea.KeyDown}, {Type: tea.KeyDown}, {Type: tea.KeyDown}, + {Type: tea.KeyEnter}, {Type: tea.KeyBackspace}, {Type: tea.KeyRunes, Runes: []rune("5")}, {Type: tea.KeyEsc}, + {Type: tea.KeyEnter}, {Type: tea.KeyRunes, Runes: []rune("9")}, {Type: tea.KeyEnter}, + {Type: tea.KeyUp}, {Type: tea.KeyEnter}, {Type: tea.KeyRunes, Runes: []rune("x")}, + } + for _, k := range steps { + model, _ = model.Update(k) + if model.View() == "" { + t.Fatalf("empty view after %v", k) + } + } + if _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}); cmd == nil { + t.Fatal("q should quit") + } + if _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil { + t.Fatal("esc should quit") + } + + scriptedPrograms(t, "\x1b[B\r\x1b[B\r12\rs") + res := ShowSettingsPicker(pickerRows()) + if !res.Confirmed || res.Changes["theme"] != "forest" || res.Changes["budget"] != "100012" { + t.Fatalf("ShowSettingsPicker = %+v", res) + } + scriptedPrograms(t, "q") + if res := ShowSettingsPicker(pickerRows()); res.Confirmed { + t.Fatalf("q should not confirm: %+v", res) + } +} diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index 2cf3165..8f3d807 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -16,7 +16,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/chzyer/readline" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // ANSI color codes (kept for prompt and readline which don't use lipgloss) @@ -785,7 +785,7 @@ func (t *Terminal) PrintError(msg string) { func (t *Terminal) PrintTokenUsage(usage api.TokenUsage) { t.emit(fmt.Sprintf("\n%s\n", styleUsage.Render( fmt.Sprintf(" tokens: %d in / %d out (session: %d total, %d requests)", - usage.InputTokens, usage.OutputTokens, usage.TotalTokens(), usage.Requests)))) + usage.TotalInputTokens(), usage.OutputTokens, usage.TotalTokens(), usage.Requests)))) } // PrintCostSummary shows detailed cost info for /cost command. When plan is @@ -794,7 +794,7 @@ func (t *Terminal) PrintCostSummary(usage api.TokenUsage, model string, plan *ap cost := usage.EstimatedCost(model) fmt.Printf("\n") fmt.Printf(" %s\n", styleSystem.Render("Session Token Usage")) - fmt.Printf(" %-20s %d\n", "Input tokens:", usage.InputTokens) + fmt.Printf(" %-20s %d\n", "Input tokens:", usage.TotalInputTokens()) fmt.Printf(" %-20s %d\n", "Output tokens:", usage.OutputTokens) fmt.Printf(" %-20s %d\n", "Total tokens:", usage.TotalTokens()) fmt.Printf(" %-20s %d\n", "API requests:", usage.Requests) @@ -863,7 +863,7 @@ func (t *Terminal) PrintStatusInfo(ctx *api.SessionContext, usage api.TokenUsage fmt.Printf(" %-20s #%d\n", "Active project:", ctx.ProjectID) } fmt.Printf(" %-20s %s\n", "Model:", model) - fmt.Printf(" %-20s %d in / %d out\n", "Session tokens:", usage.InputTokens, usage.OutputTokens) + fmt.Printf(" %-20s %d in / %d out\n", "Session tokens:", usage.TotalInputTokens(), usage.OutputTokens) fmt.Printf(" %-20s $%.4f\n", "Est. cost:", usage.EstimatedCost(model)) if plan != nil && plan.Started() { fmt.Printf(" %-20s resets in %s (%d turns)\n", "Plan window:", diff --git a/internal/tui/terminal_print_test.go b/internal/tui/terminal_print_test.go new file mode 100644 index 0000000..53c893b --- /dev/null +++ b/internal/tui/terminal_print_test.go @@ -0,0 +1,170 @@ +package tui + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/Quality-Max/qmax-code/internal/api" +) + +// captureTermOutput returns everything fn writes to stdout and stderr. +func captureTermOutput(t *testing.T, fn func()) string { + t.Helper() + origOut, origErr := os.Stdout, os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout, os.Stderr = w, w + done := make(chan string) + go func() { + var b bytes.Buffer + _, _ = io.Copy(&b, r) + done <- b.String() + }() + defer func() { os.Stdout, os.Stderr = origOut, origErr }() + fn() + _ = w.Close() + os.Stdout, os.Stderr = origOut, origErr + return <-done +} + +func expectAll(t *testing.T, out string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("output missing %q\n---\n%s", w, out) + } + } +} + +func TestPrintBannerMoods(t *testing.T) { + term := &Terminal{} + out := captureTermOutput(t, func() { + term.PrintBanner("1.2.3", &api.SessionContext{}) + term.PrintBanner("1.2.3", &api.SessionContext{ + QMaxBin: "/usr/bin/qmax", + ProjectID: 9, + Backend: "cc", + Auth: &api.AuthConfig{APIKey: "k", Email: "qa@example.com"}, + GitInfo: &api.GitInfo{Branch: "main", ChangedFiles: []string{"a", "b"}}, + }) + term.PrintBanner("1.2.3", &api.SessionContext{LocalOnly: true}) + }) + expectAll(t, out, "1.2.3", "/help") +} + +func TestPrintToolResultFormats(t *testing.T) { + term := &Terminal{} + out := captureTermOutput(t, func() { + term.PrintToolResult("x", `{"error":"bad thing happened"}`) + term.PrintToolResult("check_test_status", `{"execution_id":"e1","status":"passed","message":"ok","execution_time":2.5,"screenshot_paths":["https://cdn/s1.png",""],"video_path":"v.webm"}`) + term.PrintToolResult("run_test", `{"execution_id":"e2","status":"failed","test_errors":"assert","console_logs":[{"text":"Error: 500"},{"text":"fine"},"x"]}`) + term.PrintToolResult("run_test", `{"execution_id":"e3","status":"queued"}`) + term.PrintToolResult("run_test", `execution_id but not json`) + term.PrintToolResult("list", "one\ntwo") + term.PrintToolResult("list", "1\n2\n3\n4\n5") + term.StopThinking() + }) + expectAll(t, out, "✗ Error", "Execution: e1", "✓ PASSED", "Message: ok", "Duration: 2.5s", "Screenshots: 2 captured", "Video:", + "✗ FAILED", "Errors:", "Console errors:", "Error: 500", "queued", "one", "✓ 5 lines of output") +} + +func TestPrintPlanStatusCostAndTests(t *testing.T) { + term := &Terminal{streaming: true} + w := api.NewPlanWindow(5 * time.Hour) + now := time.Now() + w.Record(now.Add(-time.Hour), 100, 20) + w.MarkExhausted(now, now.Add(time.Hour)) + idle := api.NewPlanWindow(5 * time.Hour) + idle.Record(now, 0, 0) + + out := captureTermOutput(t, func() { + term.PrintPlan([]PlanStep{{Title: "read", Status: "done"}, {Title: "edit", Status: "in_progress"}, {Title: "test", Status: "pending"}}) + term.StopThinking() + term.PrintCostSummary(api.TokenUsage{InputTokens: 1000, OutputTokens: 200, Requests: 3}, api.ModelSonnet, w) + term.PrintPlanWindow(idle) + term.PrintPlanWindow(nil) + term.PrintStatusInfo(&api.SessionContext{LocalOnly: true}, api.TokenUsage{}, api.ModelSonnet, nil) + term.PrintStatusInfo(&api.SessionContext{API: &api.APIClient{}, Auth: &api.AuthConfig{APIKey: "k", Email: "me@x"}}, api.TokenUsage{}, api.ModelHaiku, w) + cliCtx := &api.SessionContext{QMaxBin: "/bin/qmax"} + cliCtx.QMaxCfg.Email = "cli@x" + term.PrintStatusInfo(cliCtx, api.TokenUsage{}, "", nil) + term.PrintStatusInfo(&api.SessionContext{}, api.TokenUsage{}, "", nil) + term.PrintTestResults(nil) + term.PrintTestResults([]TestResult{{Name: "ok", Passed: true, Duration: 1}, {Name: "bad", Error: strings.Repeat("e", 120), Duration: 2}}) + term.PrintTestResults([]TestResult{{Name: "ok", Passed: true}}) + term.PrintTokenUsage(api.TokenUsage{InputTokens: 1, OutputTokens: 2}) + }) + expectAll(t, out, "(1/3 done)", "Session Token Usage", "Coding-plan Usage Window", "limit reached", "backend reports no token usage", + "Standalone local-only", "Logged in as:", "me@x", "Connected (via CLI)", "cli@x", "Not connected", "Plan window:", + "❌ 1 FAILED", "✅ ALL PASSED", "...", "tokens: 1 in / 2 out") +} + +func TestStreamingAndMarkdown(t *testing.T) { + term := &Terminal{} + term.setMarkdownStyle(true) + out := captureTermOutput(t, func() { + term.FinishMarkdown("ignored when not streaming") + term.StreamText("# Title\n") + term.StreamText("**bold** text") + term.FinishMarkdown("# Title\n**bold** text") + term.PrintAssistant("plain *answer*") + term.PrintToolIcon("read_file") + term.PrintToolStart("read_file", map[string]interface{}{"path": "README.md", "long": strings.Repeat("x", 60)}) + term.PrintToolIcon("unknown_tool") + term.PrintToolStart("unknown_tool", "not a map") + term.StreamText("partial") + term.PrintToolIcon("run_command") + term.StreamText("diff next") + term.PrintFileDiff("a.go", "package a\n", "package b\n") + term.PrintFileDiff("a.go", "same", "same") + term.PrintSystem("system") + term.PrintError("oops") + term.EndLine() + _, _ = term.Write([]byte("raw write\n")) + }) + expectAll(t, out, "Title", "bold", "answer", "read file", "path=README.md", "...", "unknown tool", "system", "✗ oops", "raw write") + + plain := &Terminal{} + out = captureTermOutput(t, func() { + plain.StreamText("no renderer") + plain.FinishMarkdown("no renderer") + plain.PrintAssistant("no renderer either") + }) + expectAll(t, out, "no renderer", "no renderer either") + if TruncateStr("abcdef", 5) != "ab..." || TruncateStr("abc", 5) != "abc" { + t.Fatal("TruncateStr") + } +} + +func TestSpinnerLifecycle(t *testing.T) { + term := &Terminal{} + out := captureTermOutput(t, func() { + term.StartThinking() + time.Sleep(200 * time.Millisecond) + term.SetUserTyping(true) + time.Sleep(100 * time.Millisecond) + term.SetUserTyping(false) + term.StartThinking() // replaces the running spinner + term.StopThinking() + term.StopThinking() + }) + if !strings.Contains(out, "...") { + t.Fatalf("spinner never rendered: %q", out) + } + if term.Stderr() == nil { + t.Fatal("Stderr writer is nil") + } + if term.SetTurnActivity("x") { + t.Fatal("no turn program, activity should not be accepted") + } + var nilTerm *Terminal + if nilTerm.Prompt() != "" { + t.Fatal("nil terminal prompt") + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 8b324eb..4830b29 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) // Theme defines the full color palette for the terminal UI. diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index 17a3008..b6aac04 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -9,9 +9,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/httpx" - "github.com/qualitymax/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/sysutil" ) // ─── Styles ─────────────────────────────────────────────────────────────────── @@ -148,6 +148,7 @@ var ccModels = []pickerEntry{ {backend: "cc", modelID: api.ModelOpus47, label: "Opus 4.7", subLabel: "1M ctx"}, {backend: "cc", modelID: api.ModelSonnet, label: "Sonnet 4.6"}, {backend: "cc", modelID: api.ModelHaiku, label: "Haiku 4.5"}, + {backend: "cc", modelID: api.ModelHaiku55, label: "Haiku 5.5", isNew: true}, } var codexModels = []pickerEntry{ @@ -176,6 +177,7 @@ var apiModels = []pickerEntry{ {backend: "", modelID: api.ModelOpus55, label: "Opus 5.5", isNew: true}, {backend: "", modelID: api.ModelOpus, label: "Opus 4.8"}, {backend: "", modelID: api.ModelHaiku, label: "Haiku 4.5"}, + {backend: "", modelID: api.ModelHaiku55, label: "Haiku 5.5", isNew: true}, } // cerebrasModels are run through Cerebras's OpenAI-compatible API (native @@ -201,6 +203,11 @@ type OpenCodeModelEntry struct { var effortLevels = []string{"low", "medium", "high"} +// programOptions is applied to every interactive Bubble Tea program in this +// package. It is empty in production; tests use it to supply scripted input +// and discard rendering. +var programOptions []tea.ProgramOption + // probeOllamaReachable returns true if the Ollama base URL responds within 2s. func probeOllamaReachable(rawURL string) bool { if rawURL == "" { @@ -859,7 +866,7 @@ func ShowThemePicker(currentTheme string) (string, bool) { currentTheme = "historic" } m := newThemePickerModel(currentTheme) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return currentTheme, false @@ -896,7 +903,7 @@ type ModelPickerOpts struct { // Returns the result; Confirmed=false means the user cancelled. func ShowModelPicker(opts ModelPickerOpts) ModelPickerResult { m := newModelPickerModel(opts.CurrentBackend, opts.CurrentModelID, opts.Effort, opts.OllamaURL, opts.OllamaModel, opts.CCInstalled, opts.CodexInstalled, opts.CerebrasKeySet, opts.OpenCodeInstalled, opts.AgyInstalled, opts.OpenCodeModels) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return ModelPickerResult{Confirmed: false} @@ -1086,7 +1093,7 @@ func ShowSessionPicker(sessions []SessionPickerItem, activeID string) (string, b return "", false } m := newSessionPickerModel(sessions, activeID) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return "", false @@ -1236,7 +1243,7 @@ func ShowProviderPicker(items []ProviderPickerItem) ProviderPickerResult { return ProviderPickerResult{} } m := newProviderPickerModel(items) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return ProviderPickerResult{} @@ -1354,7 +1361,7 @@ func (m cloudSyncPickerModel) View() string { // means the user cancelled and the current value should be preserved. func ShowCloudSyncPicker(current *bool) (bool, bool) { m := newCloudSyncPickerModel(current) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return false, false @@ -1467,7 +1474,7 @@ func (m liveFeedPickerModel) View() string { // value should be preserved. func ShowLiveFeedPicker(current bool) (bool, bool) { m := newLiveFeedPickerModel(current) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) result, err := p.Run() if err != nil { return false, false diff --git a/internal/tui/tui_backend_cc_test.go b/internal/tui/tui_backend_cc_test.go index 12eb3cc..9949d39 100644 --- a/internal/tui/tui_backend_cc_test.go +++ b/internal/tui/tui_backend_cc_test.go @@ -3,8 +3,8 @@ package tui import ( "testing" - "github.com/qualitymax/qmax-code/codexrunner" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/codexrunner" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestPickerIncludesClaudeCodeFableSonnet5AndOpus55(t *testing.T) { diff --git a/internal/tui/turn_viewport.go b/internal/tui/turn_viewport.go index 998d1ee..9f450f5 100644 --- a/internal/tui/turn_viewport.go +++ b/internal/tui/turn_viewport.go @@ -643,7 +643,7 @@ func (m *turnViewportModel) jumpToMark(dir int) { // run emits streaming output through Terminal. It returns only after run exits. func RunTurnViewport(term *Terminal, prompt string, status *StatusInfo, cancelFn func(), run func()) TurnInputResult { m := newTurnViewportModel(prompt, status, cancelFn) - p := tea.NewProgram(m) + p := tea.NewProgram(m, programOptions...) term.attachTurnProgram(p) done := make(chan struct{}) go func() { diff --git a/internal/update/update.go b/internal/update/update.go index 1cdeecf..95fcfd4 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -19,15 +19,15 @@ import ( "strings" "time" - "github.com/qualitymax/qmax-code/internal/httpx" + "github.com/Quality-Max/qmax-code/internal/httpx" ) func bytesReader(b []byte) io.Reader { return bytes.NewReader(b) } const ( - releasesRepo = "Quality-Max/qmax-code-releases" - checkInterval = 24 * time.Hour - downloadLimit = 128 << 20 // 128 MiB cap for the asset download + releasesRepo = "Quality-Max/qmax-code-releases" + checkInterval = 24 * time.Hour + downloadLimit = 128 << 20 // 128 MiB cap for the asset download requestTimeout = 10 * time.Second ) diff --git a/internal/vnc/framebuffer_test.go b/internal/vnc/framebuffer_test.go new file mode 100644 index 0000000..95acb5d --- /dev/null +++ b/internal/vnc/framebuffer_test.go @@ -0,0 +1,299 @@ +package vnc + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "net" + "reflect" + "strings" + "testing" +) + +// Memory-backed connection keeps parser tests independent of network timing. +// Embedding net.Conn leaves unused connection operations outside the fixture. +type rfbMemoryConn struct { + net.Conn + input *bytes.Reader + output bytes.Buffer +} + +func (c *rfbMemoryConn) Read(p []byte) (int, error) { return c.input.Read(p) } +func (c *rfbMemoryConn) Write(p []byte) (int, error) { return c.output.Write(p) } + +func framebufferStream(data []byte, width, height int) *VNCStream { + return &VNCStream{ + ctx: context.Background(), + netCxn: &rfbMemoryConn{input: bytes.NewReader(data)}, + Frames: make(chan *VNCFrame, 1), + Err: make(chan error, 1), + width: width, height: height, fb: make([]byte, width*height*4), + } +} + +func rfbRect(x, y, width, height uint16, encoding int32, payload []byte) []byte { + var b []byte + for _, v := range []uint16{x, y, width, height} { + b = binary.BigEndian.AppendUint16(b, v) + } + b = binary.BigEndian.AppendUint32(b, uint32(encoding)) + return append(b, payload...) +} + +func TestFramebufferResizeRawAndSnapshotOwnership(t *testing.T) { + pixels := []byte{1, 2, 3, 0, 4, 5, 6, 0} + data := []byte{0, 0, 2} // padding, two rectangles + data = append(data, rfbRect(0, 0, 2, 1, -223, nil)...) + data = append(data, rfbRect(0, 0, 2, 1, 0, pixels)...) + s := framebufferStream(data, 1, 1) + if err := s.readFramebufferUpdate(); err != nil { + t.Fatal(err) + } + frame := <-s.Frames + if frame.Width != 2 || frame.Height != 1 || !bytes.Equal(frame.Pixels, pixels) { + t.Fatalf("decoded frame = %+v", frame) + } + s.fb[0] = 99 + if frame.Pixels[0] != 1 { + t.Fatal("published frame aliases the live framebuffer") + } + clone := frame.Clone() + frame.Pixels[1] = 88 + if clone.Pixels[1] != 2 { + t.Fatal("Clone shares the original pixel buffer") + } +} + +func TestFramebufferReplacesQueuedStaleFrame(t *testing.T) { + s := framebufferStream([]byte{0, 0, 0}, 1, 1) + s.Frames <- &VNCFrame{Width: 99} + s.fb[0] = 7 + if err := s.readFramebufferUpdate(); err != nil { + t.Fatal(err) + } + if frame := <-s.Frames; frame.Width != 1 || frame.Pixels[0] != 7 { + t.Fatalf("slow consumer received stale frame: %+v", frame) + } + // A consumer with no queue must not block the protocol reader either. + s = framebufferStream([]byte{0, 0, 0}, 1, 1) + s.Frames = make(chan *VNCFrame) + if err := s.readFramebufferUpdate(); err != nil { + t.Fatal(err) + } +} + +func TestCopyRectPreservesOverlappingSource(t *testing.T) { + for _, tc := range []struct { + name string + sx, sy, dx, dy, w, h uint16 + want []byte + }{ + {"down", 0, 0, 0, 1, 2, 2, []byte{1, 2, 1, 2, 3, 4}}, + {"up", 0, 1, 0, 0, 2, 2, []byte{3, 4, 5, 6, 5, 6}}, + {"right", 0, 0, 1, 0, 1, 3, []byte{1, 1, 3, 3, 5, 5}}, + } { + t.Run(tc.name, func(t *testing.T) { + payload := binary.BigEndian.AppendUint16(nil, tc.sx) + payload = binary.BigEndian.AppendUint16(payload, tc.sy) + data := append([]byte{0, 0, 1}, rfbRect(tc.dx, tc.dy, tc.w, tc.h, 1, payload)...) + s := framebufferStream(data, 2, 3) + for i := 0; i < 6; i++ { + s.fb[i*4] = byte(i + 1) + } + if err := s.readFramebufferUpdate(); err != nil { + t.Fatal(err) + } + var got []byte + for i := 0; i < 6; i++ { + got = append(got, s.fb[i*4]) + } + if !bytes.Equal(got, tc.want) { + t.Fatalf("pixels = %v, want %v", got, tc.want) + } + }) + } +} + +func TestRawRectangleClipsAndDrainsPayload(t *testing.T) { + for _, tc := range []struct { + name string + x, y, w, h int + want []byte + }{ + {"right edge", 1, 0, 2, 1, []byte{0, 1, 0, 0}}, + {"bottom edge", 0, 1, 1, 2, []byte{0, 0, 1, 0}}, + {"beyond right edge", 5, 0, 1, 1, []byte{0, 0, 0, 0}}, + {"beyond bottom edge", 0, 5, 1, 1, []byte{0, 0, 0, 0}}, + {"zero width", 0, 0, 0, 1, []byte{0, 0, 0, 0}}, + {"zero height", 0, 0, 1, 0, []byte{0, 0, 0, 0}}, + } { + t.Run(tc.name, func(t *testing.T) { + payload := bytes.Repeat([]byte{1, 2, 3, 0}, tc.w*tc.h) + s := framebufferStream(append(payload, 99), 2, 2) + if err := s.readRaw(tc.x, tc.y, tc.w, tc.h); err != nil { + t.Fatal(err) + } + for i, want := range tc.want { + if s.fb[i*4] != want { + t.Fatalf("pixel %d = %d, want %d", i, s.fb[i*4], want) + } + } + var next [1]byte + if _, err := io.ReadFull(s.netCxn, next[:]); err != nil || next[0] != 99 { + t.Fatalf("rectangle did not consume its payload: next=%v err=%v", next, err) + } + }) + } +} + +func TestCopyRowClipsInvalidCoordinates(t *testing.T) { + for _, tc := range []struct { + name string + sx, sy, dx, dy, w int + want []byte + }{ + {"source row outside", 0, 2, 0, 0, 1, []byte{1, 2, 3, 4}}, + {"destination row outside", 0, 0, 0, 2, 1, []byte{1, 2, 3, 4}}, + {"negative source", -1, 0, 0, 1, 1, []byte{1, 2, 3, 4}}, + {"negative destination", 0, 0, -1, 1, 1, []byte{1, 2, 3, 4}}, + {"source clipped", 1, 0, 0, 1, 2, []byte{1, 2, 2, 4}}, + {"destination clipped", 0, 0, 1, 1, 2, []byte{1, 2, 3, 1}}, + {"source beyond width", 3, 0, 0, 1, 1, []byte{1, 2, 3, 4}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := framebufferStream(nil, 2, 2) + for i := 0; i < 4; i++ { + s.fb[i*4] = byte(i + 1) + } + s.copyRow(tc.sx, tc.sy, tc.dx, tc.dy, tc.w) + for i, want := range tc.want { + if s.fb[i*4] != want { + t.Fatalf("pixel %d = %d, want %d", i, s.fb[i*4], want) + } + } + }) + } +} + +func TestFramebufferRejectsTruncatedAndUnsupportedMessages(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + want string + }{ + {"header", []byte{0}, "read fb update hdr"}, + {"rectangle", []byte{0, 0, 1, 0}, "read rect hdr"}, + {"raw pixels", append([]byte{0, 0, 1}, rfbRect(0, 0, 1, 1, 0, []byte{1})...), "read raw row"}, + {"copy source", append([]byte{0, 0, 1}, rfbRect(0, 0, 1, 1, 1, []byte{1})...), "read copyrect src"}, + {"encoding", append([]byte{0, 0, 1}, rfbRect(0, 0, 1, 1, 99, nil)...), "unsupported encoding 99"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := framebufferStream(tc.data, 1, 1) + err := s.readFramebufferUpdate() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + if len(s.Frames) != 0 { + t.Fatal("published a partial frame after parse failure") + } + }) + } +} + +func TestReadLoopDrainsSideMessagesAndClosesChannels(t *testing.T) { + data := []byte{2} // Bell + data = append(data, 1, 0, 0, 0, 0, 1) // one colour-map entry + data = append(data, 0, 1, 0, 2, 0, 3) + data = append(data, 3, 0, 0, 0, 0, 0, 0, 3, 'a', 'b', 'c') // clipboard + data = append(data, 0, 0, 0, 1) // framebuffer update, one rectangle + data = append(data, rfbRect(0, 0, 1, 1, 0, []byte{7, 8, 9, 0})...) + s := framebufferStream(data, 1, 1) + s.readLoop() + frame, ok := <-s.Frames + if !ok || !bytes.Equal(frame.Pixels, []byte{7, 8, 9, 0}) { + t.Fatalf("decoded frame = %+v, open=%v", frame, ok) + } + if _, ok := <-s.Frames; ok { + t.Fatal("Frames channel was not closed") + } + if err := <-s.Err; !errors.Is(err, io.EOF) { + t.Fatalf("terminal error = %v, want EOF", err) + } + if _, ok := <-s.Err; ok { + t.Fatal("Err channel was not closed") + } +} + +func TestReadLoopReportsProtocolErrors(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + want string + }{ + {"unknown type", []byte{99}, "unknown server msg type 99"}, + {"truncated frame", []byte{0}, "read fb update hdr"}, + {"colour header", []byte{1}, "EOF"}, + {"colour payload", []byte{1, 0, 0, 0, 0, 1}, "EOF"}, + {"clipboard header", []byte{3}, "EOF"}, + {"clipboard payload", []byte{3, 0, 0, 0, 0, 0, 0, 2}, "EOF"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := framebufferStream(tc.data, 1, 1) + s.readLoop() + err := <-s.Err + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + // Cancellation must close both channels without inventing a protocol error. + s := framebufferStream(nil, 1, 1) + ctx, cancel := context.WithCancel(context.Background()) + s.ctx = ctx + cancel() + s.readLoop() + if err, ok := <-s.Err; ok || err != nil { + t.Fatalf("cancelled stream reported %v", err) + } + if _, ok := <-s.Frames; ok { + t.Fatal("cancelled stream left Frames open") + } + // Reporting a second error cannot block the reader when Err is full. + s = framebufferStream(nil, 1, 1) + s.emitErr(io.EOF) + s.emitErr(io.ErrUnexpectedEOF) + if err := <-s.Err; err != io.EOF { + t.Fatalf("first error replaced: %v", err) + } +} + +func TestInputEventsEncodeKeysAndClampPointers(t *testing.T) { + s := framebufferStream(nil, 10, 20) + if err := s.SendKey(0xff0d, true); err != nil { + t.Fatal(err) + } + if err := s.SendKey(0xff0d, false); err != nil { + t.Fatal(err) + } + if err := s.SendPointer(-1, -5, 1); err != nil { + t.Fatal(err) + } + if err := s.SendPointer(100, 200, 0); err != nil { + t.Fatal(err) + } + if err := s.SendPointer(3, 4, 2); err != nil { + t.Fatal(err) + } + want := []byte{ + 4, 1, 0, 0, 0, 0, 0xff, 0x0d, + 4, 0, 0, 0, 0, 0, 0xff, 0x0d, + 5, 1, 0, 0, 0, 0, + 5, 0, 0, 9, 0, 19, + 5, 2, 0, 3, 0, 4, + } + if got := s.netCxn.(*rfbMemoryConn).output.Bytes(); !reflect.DeepEqual(got, want) { + t.Fatalf("wire events = %v, want %v", got, want) + } +} diff --git a/internal/vnc/stream.go b/internal/vnc/stream.go index 99bd4da..716ebd4 100644 --- a/internal/vnc/stream.go +++ b/internal/vnc/stream.go @@ -23,8 +23,8 @@ import ( "sync" "time" + "github.com/Quality-Max/qmax-code/internal/httpx" "github.com/coder/websocket" - "github.com/qualitymax/qmax-code/internal/httpx" ) // VNCFrame is a snapshot of the framebuffer after one or more updates. @@ -513,7 +513,6 @@ func (s *VNCStream) readRaw(x, y, w, h int) error { if dy < 0 || dy >= s.height { continue } - dst := s.fb[(dy*s.width+x)*4:] // Clip horizontally if the rect overflows (shouldn't, but cheap). n := w * 4 if x+w > s.width { @@ -522,6 +521,7 @@ func (s *VNCStream) readRaw(x, y, w, h int) error { continue } } + dst := s.fb[(dy*s.width+x)*4:] copy(dst[:n], row[:n]) } return nil diff --git a/main.go b/main.go index c70a84e..4939ea5 100644 --- a/main.go +++ b/main.go @@ -11,14 +11,14 @@ import ( xterm "golang.org/x/term" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/mcp" - "github.com/qualitymax/qmax-code/internal/repl" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/setup" - "github.com/qualitymax/qmax-code/internal/sysutil" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/mcp" + "github.com/Quality-Max/qmax-code/internal/repl" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/setup" + "github.com/Quality-Max/qmax-code/internal/sysutil" + "github.com/Quality-Max/qmax-code/internal/tui" ) // Version is set at build time via -ldflags "-X main.Version=x.y.z" diff --git a/main_models.go b/main_models.go index 1bf9d76..bc51a64 100644 --- a/main_models.go +++ b/main_models.go @@ -3,8 +3,8 @@ package main import ( "fmt" - "github.com/qualitymax/qmax-code/codexrunner" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/codexrunner" + "github.com/Quality-Max/qmax-code/internal/api" ) // resolveSessionModel applies CLI selection to the active harness and returns diff --git a/main_models_test.go b/main_models_test.go index fadc047..6de95e2 100644 --- a/main_models_test.go +++ b/main_models_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestResolveSessionModel(t *testing.T) { diff --git a/main_oneshot_test.go b/main_oneshot_test.go index a7e1554..7104670 100644 --- a/main_oneshot_test.go +++ b/main_oneshot_test.go @@ -5,10 +5,10 @@ import ( "os" "testing" - "github.com/qualitymax/qmax-code/internal/agent" - "github.com/qualitymax/qmax-code/internal/api" - "github.com/qualitymax/qmax-code/internal/session" - "github.com/qualitymax/qmax-code/internal/tui" + "github.com/Quality-Max/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/session" + "github.com/Quality-Max/qmax-code/internal/tui" ) func TestSaveSessionFlagOverridesAutoSaveWithoutChangingOtherConfig(t *testing.T) { diff --git a/main_validation_test.go b/main_validation_test.go index df98b62..c8097b9 100644 --- a/main_validation_test.go +++ b/main_validation_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/qualitymax/qmax-code/internal/agent" + "github.com/Quality-Max/qmax-code/internal/agent" ) // TestIsValidModelName is the regression test for QUA-579: pre-fix, the diff --git a/receipt_test.go b/receipt_test.go new file mode 100644 index 0000000..9218842 --- /dev/null +++ b/receipt_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + receipt "github.com/Quality-Max/qmax-receipt" +) + +func TestReceiptKind(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {nil, "interactive"}, + {[]string{"qmax-code"}, "interactive"}, + {[]string{"qmax-code", "serve"}, "mcp"}, + {[]string{"qmax-code", "login"}, "login"}, + {[]string{"qmax-code", "cc"}, "cc"}, + {[]string{"qmax-code", "codex"}, "codex"}, + {[]string{"qmax-code", "agy"}, "agy"}, + {[]string{"qmax-code", "config"}, "config"}, + {[]string{"qmax-code", "gate"}, "interactive"}, + {[]string{"qmax-code", "receipt"}, "interactive"}, + {[]string{"qmax-code", "-p", "hi"}, "interactive"}, + } + for _, tc := range cases { + if got := receiptKind(tc.args); got != tc.want { + t.Errorf("receiptKind(%v) = %q, want %q", tc.args, got, tc.want) + } + } +} + +func TestInitReceiptPathsPointsAtQMaxCodeState(t *testing.T) { + home := withTempHome(t) + oldBase := receipt.BaseDir + oldVersion := receipt.AgentVersion + t.Cleanup(func() { + receipt.BaseDir = oldBase + receipt.AgentVersion = oldVersion + }) + + initReceiptPaths() + + if receipt.BaseDir != filepath.Join(home, ".qmax-code") { + t.Fatalf("BaseDir = %q, want %q", receipt.BaseDir, filepath.Join(home, ".qmax-code")) + } + if receipt.AgentVersion != Version { + t.Fatalf("AgentVersion = %q, want %q", receipt.AgentVersion, Version) + } +} + +func TestBeginSessionReceipt(t *testing.T) { + useTempReceipts(t) + + r := beginSessionReceipt("mcp") + + if r == nil { + t.Fatal("beginSessionReceipt returned nil") + } + if r.RunKind != "session:mcp" { + t.Fatalf("RunKind = %q, want session:mcp", r.RunKind) + } + if r.RunID == "" { + t.Fatal("RunID is empty") + } + if r.EntryCount() != 0 { + t.Fatalf("EntryCount = %d, want 0 for a fresh session", r.EntryCount()) + } +} + +func TestFinalizeSessionReceiptSkipsNoEgressRuns(t *testing.T) { + base := useTempReceipts(t) + + // A nil receipt and a zero-entry run must both be no-ops that leave the + // receipts directory clean (no file, no directory created). + _, _ = withCapturedOutput(t, func() { + finalizeSessionReceipt(nil) + finalizeSessionReceipt(beginSessionReceipt("interactive")) + }) + + if _, err := os.Stat(filepath.Join(base, "receipts")); !os.IsNotExist(err) { + t.Fatalf("receipts dir created for a no-egress run (stat err = %v)", err) + } +} + +func TestFinalizeSessionReceiptWritesSignedFile(t *testing.T) { + base := useTempReceipts(t) + r := beginSessionReceipt("interactive") + r.Record(receipt.Entry{Method: "POST", Host: "api.anthropic.com", Category: "llm", Allowed: true}) + + _, stderr := withCapturedOutput(t, func() { finalizeSessionReceipt(r) }) + + wantPath := filepath.Join(base, "receipts", r.RunID+".json") + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("receipt file not written: %v", err) + } + if !strings.Contains(stderr, "Exposure receipt: "+wantPath) { + t.Fatalf("stderr = %q, want receipt path announcement", stderr) + } +} + +func TestFinalizeSessionReceiptWarnsWhenWriteFails(t *testing.T) { + base := useTempReceipts(t) + // Occupy the receipts directory path with a regular file so Finalize's + // MkdirAll fails. + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "receipts"), []byte("not a dir"), 0o600); err != nil { + t.Fatal(err) + } + r := beginSessionReceipt("cc") + r.Record(receipt.Entry{Method: "POST", Host: "api.anthropic.com", Category: "llm", Allowed: true}) + + _, stderr := withCapturedOutput(t, func() { finalizeSessionReceipt(r) }) + + if !strings.Contains(stderr, "WARN: failed to write exposure receipt") { + t.Fatalf("stderr = %q, want write-failure warning", stderr) + } +} diff --git a/resolve_model_test.go b/resolve_model_test.go index 435cd20..256e2e9 100644 --- a/resolve_model_test.go +++ b/resolve_model_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/qualitymax/qmax-code/internal/api" + "github.com/Quality-Max/qmax-code/internal/api" ) func TestResolveModelUsesCentralModelConstants(t *testing.T) { diff --git a/testing_support_test.go b/testing_support_test.go new file mode 100644 index 0000000..838367e --- /dev/null +++ b/testing_support_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// exitSentinel is the panic value used by runWithStubbedExit to stop execution +// at the point where exitWithReceipt would terminate the process. +type exitSentinel struct{ code int } + +// runWithStubbedExit runs fn with processExit replaced by a recorder. The stub +// panics with exitSentinel so code after the exit site does not run — mirroring +// the real os.Exit semantics — and the panic is recovered here. It returns the +// recorded exit codes (empty when fn returned without exiting). +func runWithStubbedExit(t *testing.T, fn func()) (codes []int) { + t.Helper() + + exitMu.Lock() + oldFinalize := finalizeBeforeExit + oldExit := processExit + finalizeBeforeExit = nil + processExit = func(code int) { + codes = append(codes, code) + panic(exitSentinel{code: code}) + } + exitMu.Unlock() + t.Cleanup(func() { + exitMu.Lock() + processExit = oldExit + finalizeBeforeExit = oldFinalize + exitMu.Unlock() + }) + + defer func() { + if r := recover(); r != nil { + if _, ok := r.(exitSentinel); !ok { + panic(r) + } + } + }() + fn() + return codes +} + +// captureOutput captures synchronous command output in files so assertions see +// every write without racing a pipe-draining goroutine. Callers must not run in +// parallel because stdout and stderr are process globals. +func captureOutput(t *testing.T) (stdout, stderr func() string) { + t.Helper() + oldOut, oldErr := os.Stdout, os.Stderr + capture := func(name string) (*os.File, func() string) { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f, func() string { + t.Helper() + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + return string(data) + } + } + os.Stdout, stdout = capture("stdout") + os.Stderr, stderr = capture("stderr") + t.Cleanup(func() { os.Stdout, os.Stderr = oldOut, oldErr }) + return stdout, stderr +} + +func withCapturedOutput(t *testing.T, fn func()) (stdout, stderr string) { + t.Helper() + oldOut, oldErr := os.Stdout, os.Stderr + defer func() { os.Stdout, os.Stderr = oldOut, oldErr }() + out, errOut := captureOutput(t) + fn() + return out(), errOut() +} + +// prependFakeBinaries writes tiny no-op executable scripts for each name into +// a fresh temp dir and prepends that dir to PATH, so exec.LookPath finds them. +// It returns the temp dir. Deterministic and cleaned up with the test. +func prependFakeBinaries(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, name := range names { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake %s: %v", name, err) + } + } + orig := os.Getenv("PATH") + os.Setenv("PATH", dir+string(os.PathListSeparator)+orig) + t.Cleanup(func() { os.Setenv("PATH", orig) }) + return dir +} + +// isolatePathWithFailingSecurity replaces PATH with a temp dir whose only +// binary is a `security` stub that always fails. On darwin this makes macOS +// keychain lookups (used by loadClaudeCodeFromKeychain and api.LoadFromKeychain) +// miss deterministically instead of depending on the developer's real keychain; +// on other platforms `security` is never consulted anyway. Any other binary +// lookup through PATH fails, which also isolates the test from the machine. +func isolatePathWithFailingSecurity(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "security"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("write security stub: %v", err) + } + orig := os.Getenv("PATH") + os.Setenv("PATH", dir) + t.Cleanup(func() { os.Setenv("PATH", orig) }) +} + +// setenv sets an environment variable for the duration of the test. +func setenv(t *testing.T, key, value string) { + t.Helper() + orig, had := os.LookupEnv(key) + os.Setenv(key, value) + t.Cleanup(func() { + if had { + os.Setenv(key, orig) + } else { + os.Unsetenv(key) + } + }) +}