Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

406 lines
15 KiB
Go

package main
import (
"bytes"
"encoding/json"
"io"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
)
// capturedEdit records the (tool, args) the edit verb lowered to.
type capturedEdit struct {
repo string
tool string
args map[string]any
}
// runEdit drives the real command tree (rootCmd → edit → subcommand) with the
// given subcommand argv, stubbing the daemon seam so the call never leaves the
// process. It returns the captured call (or nil if none was made), the combined
// out/err buffer, and any error from RunE. A canned JSON result is returned from
// the stub so the renderer path runs end-to-end.
//
// argv is the edit-subcommand argv (e.g. "context", "internal/foo.go"); the
// helper prepends "edit" so cobra routes through the real edit parent — calling
// editCmd.Execute() directly would re-root at rootCmd and collide with the
// unrelated top-level `context` command.
func runEdit(t *testing.T, stdin io.Reader, argv ...string) (*capturedEdit, *bytes.Buffer, error) {
t.Helper()
resetEditFlags()
orig := editDaemonTool
t.Cleanup(func() { editDaemonTool = orig })
var cap *capturedEdit
editDaemonTool = func(repo, tool string, args map[string]any) (json.RawMessage, error) {
cap = &capturedEdit{repo: repo, tool: tool, args: args}
return json.RawMessage(`{"status":"ok"}`), nil
}
buf := &bytes.Buffer{}
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
if stdin != nil {
rootCmd.SetIn(stdin)
} else {
rootCmd.SetIn(strings.NewReader(""))
}
rootCmd.SetArgs(append([]string{"edit"}, argv...))
err := rootCmd.Execute()
return cap, buf, err
}
// resetEditFlags restores every edit-group flag global to its zero/default so
// tests don't leak state into each other (cobra persists parsed values on the
// shared command vars). It also clears each flag's cobra-internal Changed bit,
// which Execute() leaves set across runs — Changed() is load-bearing for the
// --old/--new "required" check, so a stale bit would mask a missing flag.
func resetEditFlags() {
resetCobraFlags(editCmd)
editIndex = "."
editFormat = "text"
editContextDetail, editContextCompress = "", false
editChanges = nil
editVerifyChangesInline, editVerifyChangesFile, editVerifyCompact = "", "", false
editPlanIDs, editPlanDepth = "", 3
editPreviewWEInline, editPreviewWEFile = "", ""
editPreviewNoDiag, editPreviewInheritOver = false, false
editSimulateStepsInline, editSimulateStepsFile = "", ""
editSimulateKeep, editSimulateNoStop, editSimulateInheritOver = false, false, false
editBatchInline, editBatchFile, editBatchDryRun, editBatchCompact = "", "", false, false
editApplyOld, editApplyNew = "", ""
editApplyReplaceAll, editApplyDryRun, editApplyAllowParse, editApplyExpected = false, false, false, 0
editSymbolOld, editSymbolNew, editSymbolDryRun = "", "", false
editRenameTo = ""
editGuardsIDs, editGuardsCompact = "", false
editTestsIDs, editTestsDepth = "", 3
editContractSource, editContractLens = "", ""
editContractRiskGate, editContractAck = false, false
editContractBase, editContractWEInline, editContractWEFile = "", "", ""
editContractSymbols, editContractRanges, editContractRangeFile = "", "", ""
editContractPath, editContractStartLine, editContractEndLine = "", 0, 0
editSafeDeleteApply, editSafeDeleteForce = false, false
editSafeDeleteCascade, editSafeDeleteIntoTests, editSafeDeletePropagate = "", false, false
}
// resetCobraFlags clears the Changed bit on every flag of cmd and its
// subcommands, recursively. cobra sets Changed in place during Execute() and
// never clears it, so without this a flag set in one test stays "Changed" in
// the next — which would mask the --old/--new "required" Changed() check. The
// flag VALUES are restored by the explicit Go-variable resets above (the flag
// vars are package globals bound via *Var), so this only touches Changed; in
// particular it avoids re-Set-ing a StringArray default, which would append.
func resetCobraFlags(cmd *cobra.Command) {
clear := func(f *pflag.Flag) { f.Changed = false }
cmd.Flags().VisitAll(clear)
cmd.PersistentFlags().VisitAll(clear)
for _, c := range cmd.Commands() {
resetCobraFlags(c)
}
}
// --- per-subcommand lowering assertions ------------------------------------
func TestEditContext_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "context", "internal/foo.go", "--detail", "full", "--compress")
require.NoError(t, err)
require.NotNil(t, cap)
require.Equal(t, "get_editing_context", cap.tool)
require.Equal(t, "internal/foo.go", cap.args["path"])
require.Equal(t, "full", cap.args["detail"])
require.Equal(t, true, cap.args["compress_bodies"])
}
func TestEditContext_OmitsUnsetFlags(t *testing.T) {
cap, _, err := runEdit(t, nil, "context", "internal/foo.go")
require.NoError(t, err)
require.Equal(t, "get_editing_context", cap.tool)
require.NotContains(t, cap.args, "detail")
require.NotContains(t, cap.args, "compress_bodies")
}
func TestEditVerify_ChangeSugarBuildsArray(t *testing.T) {
cap, _, err := runEdit(t, nil, "verify",
"--change", "pkg/a.go::A=sig1",
"--change", "pkg/b.go::B=func(x int) error")
require.NoError(t, err)
require.Equal(t, "verify_change", cap.tool)
changesJSON, ok := cap.args["changes"].(string)
require.True(t, ok, "changes must be a JSON string")
var got []map[string]string
require.NoError(t, json.Unmarshal([]byte(changesJSON), &got))
require.Equal(t, []map[string]string{
{"symbol_id": "pkg/a.go::A", "new_signature": "sig1"},
{"symbol_id": "pkg/b.go::B", "new_signature": "func(x int) error"},
}, got)
}
func TestEditVerify_SplitsOnFirstEquals(t *testing.T) {
cap, _, err := runEdit(t, nil, "verify", "--change", "id=func() (a=b, error)")
require.NoError(t, err)
changesJSON := cap.args["changes"].(string)
var got []map[string]string
require.NoError(t, json.Unmarshal([]byte(changesJSON), &got))
require.Equal(t, "id", got[0]["symbol_id"])
require.Equal(t, "func() (a=b, error)", got[0]["new_signature"])
}
func TestEditVerify_ChangesInlineJSON(t *testing.T) {
cap, _, err := runEdit(t, nil, "verify",
"--changes", `[{"symbol_id":"x","new_signature":"y"}]`, "--compact")
require.NoError(t, err)
require.Equal(t, `[{"symbol_id":"x","new_signature":"y"}]`, cap.args["changes"])
require.Equal(t, true, cap.args["compact"])
}
func TestEditVerify_ChangesStdin(t *testing.T) {
cap, _, err := runEdit(t, strings.NewReader(`[{"symbol_id":"s","new_signature":"sig"}]`),
"verify", "--changes", "-")
require.NoError(t, err)
require.Equal(t, `[{"symbol_id":"s","new_signature":"sig"}]`, cap.args["changes"])
}
func TestEditVerify_RequiresChanges(t *testing.T) {
_, _, err := runEdit(t, nil, "verify")
require.Error(t, err)
}
func TestEditPlan_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "plan", "--ids", "a,b", "--depth", "5")
require.NoError(t, err)
require.Equal(t, "get_edit_plan", cap.tool)
require.Equal(t, "a,b", cap.args["ids"])
require.Equal(t, 5, cap.args["depth"])
}
func TestEditPlan_DefaultDepth(t *testing.T) {
cap, _, err := runEdit(t, nil, "plan", "--ids", "a")
require.NoError(t, err)
require.Equal(t, 3, cap.args["depth"])
}
func TestEditPlan_RequiresIDs(t *testing.T) {
_, _, err := runEdit(t, nil, "plan")
require.Error(t, err)
}
func TestEditPreview_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "preview",
"--workspace-edit", `{"changes":{}}`, "--no-diagnostics", "--inherit-overlay")
require.NoError(t, err)
require.Equal(t, "preview_edit", cap.tool)
require.Equal(t, `{"changes":{}}`, cap.args["workspace_edit"])
require.Equal(t, false, cap.args["diagnostics"])
require.Equal(t, true, cap.args["inherit_overlay"])
}
func TestEditPreview_DiagnosticsDefaultOmitted(t *testing.T) {
cap, _, err := runEdit(t, nil, "preview", "--workspace-edit", `{"changes":{}}`)
require.NoError(t, err)
// diagnostics defaults to true on the daemon — the CLI omits it unless
// --no-diagnostics turns it off.
require.NotContains(t, cap.args, "diagnostics")
}
func TestEditSimulate_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "simulate",
"--steps", `[{"changes":{}}]`, "--keep", "--no-stop-on-error")
require.NoError(t, err)
require.Equal(t, "simulate_chain", cap.tool)
require.Equal(t, `[{"changes":{}}]`, cap.args["steps"])
require.Equal(t, true, cap.args["keep"])
require.Equal(t, false, cap.args["stop_on_error"])
}
func TestEditBatch_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "batch",
"--edits", `[{"id":"x","old_source":"a","new_source":"b"}]`, "--dry-run", "--compact")
require.NoError(t, err)
require.Equal(t, "batch_edit", cap.tool)
require.Equal(t, `[{"id":"x","old_source":"a","new_source":"b"}]`, cap.args["edits"])
require.Equal(t, true, cap.args["dry_run"])
require.Equal(t, true, cap.args["compact"])
}
func TestEditBatch_EditsFromFile(t *testing.T) {
path := writeTempJSON(t, `[{"id":"x","old_source":"a","new_source":"b"}]`)
cap, _, err := runEdit(t, nil, "batch", "--edits-file", path)
require.NoError(t, err)
require.Equal(t, `[{"id":"x","old_source":"a","new_source":"b"}]`, cap.args["edits"])
}
func TestEditApply_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "apply", "internal/foo.go",
"--old", "before", "--new", "after",
"--replace-all", "--dry-run", "--allow-parse-errors", "--expected", "3")
require.NoError(t, err)
require.Equal(t, "edit_file", cap.tool)
require.Equal(t, "internal/foo.go", cap.args["path"])
require.Equal(t, "before", cap.args["old_string"])
require.Equal(t, "after", cap.args["new_string"])
require.Equal(t, true, cap.args["replace_all"])
require.Equal(t, true, cap.args["dry_run"])
require.Equal(t, true, cap.args["allow_parse_errors"])
require.Equal(t, 3, cap.args["expected_occurrences"])
}
func TestEditApply_RequiresOldAndNew(t *testing.T) {
_, _, err := runEdit(t, nil, "apply", "internal/foo.go", "--old", "x")
require.Error(t, err)
}
func TestEditApply_EmptyNewIsAllowed(t *testing.T) {
// An empty --new (deletion) is a legitimate edit — the Changed() check,
// not a non-empty value, gates it.
cap, _, err := runEdit(t, nil, "apply", "internal/foo.go", "--old", "x", "--new", "")
require.NoError(t, err)
require.Equal(t, "", cap.args["new_string"])
}
func TestEditSymbol_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "symbol", "pkg/a.go::Foo",
"--old", "oldbody", "--new", "newbody", "--dry-run")
require.NoError(t, err)
require.Equal(t, "edit_symbol", cap.tool)
require.Equal(t, "pkg/a.go::Foo", cap.args["id"])
require.Equal(t, "oldbody", cap.args["old_source"])
require.Equal(t, "newbody", cap.args["new_source"])
require.Equal(t, true, cap.args["dry_run"])
}
func TestEditRename_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "rename", "pkg/a.go::Foo", "--to", "Bar")
require.NoError(t, err)
require.Equal(t, "rename_symbol", cap.tool)
require.Equal(t, "pkg/a.go::Foo", cap.args["id"])
require.Equal(t, "Bar", cap.args["new_name"])
}
func TestEditRename_RequiresTo(t *testing.T) {
_, _, err := runEdit(t, nil, "rename", "pkg/a.go::Foo")
require.Error(t, err)
}
func TestEditGuards_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "guards", "--ids", "a,b,c", "--compact")
require.NoError(t, err)
require.Equal(t, "check_guards", cap.tool)
require.Equal(t, "a,b,c", cap.args["ids"])
require.Equal(t, true, cap.args["compact"])
}
func TestEditTests_Lowers(t *testing.T) {
cap, _, err := runEdit(t, nil, "tests", "--ids", "a,b", "--depth", "4")
require.NoError(t, err)
require.Equal(t, "get_test_targets", cap.tool)
require.Equal(t, "a,b", cap.args["ids"])
require.Equal(t, 4, cap.args["depth"])
}
func TestEditContract_SymbolsSource(t *testing.T) {
cap, _, err := runEdit(t, nil, "contract",
"--source", "symbols", "--symbols", "pkg/a.go::A,pkg/b.go::B",
"--lens", "api", "--risk-gate")
require.NoError(t, err)
require.Equal(t, "change_contract", cap.tool)
require.Equal(t, "symbols", cap.args["source"])
require.Equal(t, "pkg/a.go::A,pkg/b.go::B", cap.args["symbols"])
require.Equal(t, "api", cap.args["lens"])
require.Equal(t, true, cap.args["risk_gate"])
}
func TestEditContract_RangesSingleFile(t *testing.T) {
cap, _, err := runEdit(t, nil, "contract",
"--source", "ranges", "--path", "internal/foo.go",
"--start-line", "10", "--end-line", "20")
require.NoError(t, err)
require.Equal(t, "internal/foo.go", cap.args["path"])
require.Equal(t, 10, cap.args["start_line"])
require.Equal(t, 20, cap.args["end_line"])
}
func TestEditContract_EditSource(t *testing.T) {
cap, _, err := runEdit(t, nil, "contract",
"--source", "edit", "--workspace-edit", `{"changes":{}}`)
require.NoError(t, err)
require.Equal(t, `{"changes":{}}`, cap.args["workspace_edit"])
}
func TestEditContract_NoRequiredFlags(t *testing.T) {
// change_contract has no required flags — an argument-free call (source=auto)
// must lower cleanly.
cap, _, err := runEdit(t, nil, "contract")
require.NoError(t, err)
require.Equal(t, "change_contract", cap.tool)
require.NotContains(t, cap.args, "source")
}
func TestEditSafeDelete_DryRunByDefault(t *testing.T) {
cap, buf, err := runEdit(t, nil, "safe-delete", "pkg/a.go::Foo")
require.NoError(t, err)
require.Equal(t, "safe_delete_symbol", cap.tool)
require.Equal(t, "pkg/a.go::Foo", cap.args["id"])
require.Equal(t, true, cap.args["dry_run"], "default must be a dry run")
require.Contains(t, buf.String(), "dry run")
}
func TestEditSafeDelete_ApplyFlipsDryRun(t *testing.T) {
cap, _, err := runEdit(t, nil, "safe-delete", "pkg/a.go::Foo", "--apply")
require.NoError(t, err)
require.Equal(t, false, cap.args["dry_run"], "--apply must commit (dry_run=false)")
}
func TestEditSafeDelete_CascadeAndPropagate(t *testing.T) {
cap, _, err := runEdit(t, nil, "safe-delete", "pkg/a.go::Foo",
"--cascade", "preview", "--cascade-into-tests", "--propagate", "--force")
require.NoError(t, err)
require.Equal(t, "preview", cap.args["cascade"])
require.Equal(t, true, cap.args["cascade_into_tests"])
require.Equal(t, true, cap.args["propagate"])
require.Equal(t, true, cap.args["force"])
}
// TestEdit_FormatJSONRenders asserts --format json pretty-prints the daemon
// result through emitDaemonJSON.
func TestEdit_FormatJSONRenders(t *testing.T) {
_, buf, err := runEdit(t, nil, "guards", "--ids", "a", "--format", "json")
require.NoError(t, err)
require.Contains(t, buf.String(), `"status": "ok"`)
}
// TestEditDaemonRequired asserts that, with no daemon and no stub, a subcommand
// returns the actionable daemon-required error (mirrors
// query_daemon_required_test.go / call_test.go).
func TestEditDaemonRequired(t *testing.T) {
resetEditFlags()
// No stub: the real editDaemonTool runs against a temp dir no daemon tracks.
buf := &bytes.Buffer{}
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetIn(strings.NewReader(""))
rootCmd.SetArgs([]string{"edit", "plan", "--ids", "a", "--index", t.TempDir()})
err := rootCmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "gortex track")
}