Files
zzet--gortex/cmd/gortex/tools_receipt_test.go
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

290 lines
11 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/persistence"
"gopkg.in/yaml.v3"
)
// cannedToolProfileCountsJSON mirrors the full-mode tool_profile response shape
// the daemon returns: top-level preset / preset_mode / live_count /
// deferred_count fields (handleToolProfile).
const cannedToolProfileCountsJSON = `{
"lazy_enabled": true,
"preset": "core",
"preset_mode": "defer",
"total": 175,
"live_count": 34,
"deferred_count": 141,
"live": ["search_symbols", "get_callers"],
"deferred": ["edit_file", "rename_symbol"]
}`
// newToolsReceiptTestCmd resets receipt flag state and binds a buffer. It also
// redirects Part B's CLI-verb ledger to an isolated temp sidecar and clears the
// session-id seam, so a receipt build in a test never reads the developer's
// real ~/.gortex (and Part B is empty unless the test seeds it).
func newToolsReceiptTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) {
t.Helper()
toolsIndex = "."
toolsReceiptFormat = "yaml"
toolsReceiptSince = time.Hour
path := filepath.Join(t.TempDir(), "sidecar.sqlite")
origPath := receiptSidecarPath
origSession := receiptSessionID
receiptSidecarPath = func() string { return path }
receiptSessionID = func() string { return "" }
t.Cleanup(func() {
receiptSidecarPath = origPath
receiptSessionID = origSession
})
buf := &bytes.Buffer{}
cmd := &cobra.Command{Use: "receipt", RunE: runToolsReceipt}
cmd.SetOut(buf)
cmd.SetErr(buf)
return cmd, buf
}
// TestToolsReceipt_DaemonReachable_YAML asserts a daemon-reachable receipt
// reports skill_cli transport and the live / deferred counts from tool_profile.
func TestToolsReceipt_DaemonReachable_YAML(t *testing.T) {
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
var gotTool string
toolsDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
gotTool = tool
return json.RawMessage(cannedToolProfileCountsJSON), nil
}
cmd, buf := newToolsReceiptTestCmd(t)
require.NoError(t, runToolsReceipt(cmd, nil))
require.Equal(t, "tool_profile", gotTool)
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.Equal(t, "skill_cli", r.Transport)
require.Equal(t, "core", r.DaemonPreset)
require.Equal(t, "defer", r.DaemonPresetMode)
require.Equal(t, 34, r.AdvertisedTools)
require.Equal(t, 0, r.RegisteredToolSchemas)
require.Equal(t, 141, r.DeferredCapabilities.Count)
require.Equal(t, "full_surface_not_mounted", r.Decision)
// repo is the absolute path of the index.
require.NotEmpty(t, r.Repo)
// The top-level key is present in the raw YAML text.
require.Contains(t, buf.String(), "gortex_context_budget:")
}
// TestToolsReceipt_DaemonReachable_JSON asserts the JSON rendering carries the
// same structure under the gortex_context_budget key.
func TestToolsReceipt_DaemonReachable_JSON(t *testing.T) {
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
return json.RawMessage(cannedToolProfileCountsJSON), nil
}
cmd, buf := newToolsReceiptTestCmd(t)
toolsReceiptFormat = "json"
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, json.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.Equal(t, "skill_cli", r.Transport)
require.Equal(t, "core", r.DaemonPreset)
require.Equal(t, "defer", r.DaemonPresetMode)
require.Equal(t, 34, r.AdvertisedTools)
require.Equal(t, 0, r.RegisteredToolSchemas)
require.Equal(t, 141, r.DeferredCapabilities.Count)
require.Equal(t, "full_surface_not_mounted", r.Decision)
}
// TestToolsReceipt_NoDaemon asserts that when the daemon call fails, the receipt
// degrades to cli_only / no_surface_mounted without a non-zero error exit.
func TestToolsReceipt_NoDaemon(t *testing.T) {
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
// The shape of the no-daemon signal the real path returns.
return nil, errors.New("no gortex daemon is running — start it with `gortex daemon start --detach`")
}
cmd, buf := newToolsReceiptTestCmd(t)
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.Equal(t, "cli_only", r.Transport)
require.Equal(t, 0, r.AdvertisedTools)
require.Equal(t, 0, r.RegisteredToolSchemas)
require.Equal(t, 0, r.DeferredCapabilities.Count)
require.Equal(t, "no_surface_mounted", r.Decision)
// daemon preset / mode are omitted when nothing is mounted.
require.Empty(t, r.DaemonPreset)
require.Empty(t, r.DaemonPresetMode)
}
// TestToolsReceipt_CountFallback asserts a profile that omits the *_count
// fields still yields faithful counts from the live / deferred arrays.
func TestToolsReceipt_CountFallback(t *testing.T) {
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
// No live_count / deferred_count — only the arrays.
return json.RawMessage(`{"preset":"nav","preset_mode":"hide","live":["a","b","c"],"deferred":["d"]}`), nil
}
cmd, buf := newToolsReceiptTestCmd(t)
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.Equal(t, "skill_cli", r.Transport)
require.Equal(t, 3, r.AdvertisedTools)
require.Equal(t, 1, r.DeferredCapabilities.Count)
}
// TestToolsReceipt_BadFormat asserts an unknown --format value is a clean error.
func TestToolsReceipt_BadFormat(t *testing.T) {
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
return json.RawMessage(cannedToolProfileCountsJSON), nil
}
cmd, _ := newToolsReceiptTestCmd(t)
toolsReceiptFormat = "toml"
err := runToolsReceipt(cmd, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown --format")
}
// stubDaemonReachable points toolsDaemonTool at the canned tool_profile so a
// Part B test gets a normal Part A without a live daemon.
func stubDaemonReachable(t *testing.T) {
t.Helper()
orig := toolsDaemonTool
t.Cleanup(func() { toolsDaemonTool = orig })
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
return json.RawMessage(cannedToolProfileCountsJSON), nil
}
}
// seedCLIEvents writes verbs for a session id into the receipt's redirected
// sidecar, so Part B has something to read back.
func seedCLIEvents(t *testing.T, sessionID string, verbs ...string) {
t.Helper()
sc, err := persistence.OpenSidecar(receiptSidecarPath())
require.NoError(t, err)
defer sc.Close()
base := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
for i, v := range verbs {
require.NoError(t, sc.AddCLIEvent(base.Add(time.Duration(i)*time.Minute), sessionID, v))
}
}
// TestToolsReceipt_PartB_BySession: with GORTEX_SESSION_ID set, the receipt
// reports that session's distinct verbs and the safety steps derived from them,
// while Part A is unchanged.
func TestToolsReceipt_PartB_BySession(t *testing.T) {
stubDaemonReachable(t)
cmd, buf := newToolsReceiptTestCmd(t)
// Scope to a session and seed verbs (with a duplicate to prove dedup, and a
// non-safety verb to prove it contributes no safety step).
receiptSessionID = func() string { return "agent-sess" }
seedCLIEvents(t, "agent-sess", "edit.verify", "edit.guards", "edit.verify", "query.stats")
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
// Part A unchanged.
require.Equal(t, "skill_cli", r.Transport)
require.Equal(t, 34, r.AdvertisedTools)
// Part B: distinct verbs in first-seen order; safety steps sorted + deduped.
require.Equal(t, []string{"edit.verify", "edit.guards", "query.stats"}, r.CLIVerbsUsed)
require.Equal(t, []string{"check_guards", "verify_change"}, r.SafetyStepsRun)
// With a session id set there is no "set GORTEX_SESSION_ID" note.
require.Empty(t, r.Note)
}
// TestToolsReceipt_PartB_NoSession: with no session id, Part B falls back to the
// --since window and carries the note. Events outside the window are excluded.
func TestToolsReceipt_PartB_NoSession(t *testing.T) {
stubDaemonReachable(t)
cmd, buf := newToolsReceiptTestCmd(t)
// No session id (helper already cleared it). Seed under any id — the Since
// read ignores the session dimension. Pin the clock so the seeded events
// (base 2026-06-01 09:00) fall inside the window.
origNow := receiptNow
t.Cleanup(func() { receiptNow = origNow })
receiptNow = func() time.Time { return time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC) }
toolsReceiptSince = time.Hour
seedCLIEvents(t, "whoever", "edit.tests", "edit.contract")
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.Equal(t, []string{"edit.tests", "edit.contract"}, r.CLIVerbsUsed)
require.Equal(t, []string{"change_contract", "get_test_targets"}, r.SafetyStepsRun)
require.Contains(t, r.Note, "GORTEX_SESSION_ID")
}
// TestToolsReceipt_PartB_EmptyWhenNoEvents: with nothing recorded the two
// fields are present as empty lists (never nil/omitted), and Part A still
// renders. The redirected sidecar is created on read but holds no rows.
func TestToolsReceipt_PartB_EmptyWhenNoEvents(t *testing.T) {
stubDaemonReachable(t)
cmd, buf := newToolsReceiptTestCmd(t)
require.NoError(t, runToolsReceipt(cmd, nil))
var env receiptEnvelope
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
r := env.GortexContextBudget
require.NotNil(t, r.CLIVerbsUsed)
require.NotNil(t, r.SafetyStepsRun)
require.Empty(t, r.CLIVerbsUsed)
require.Empty(t, r.SafetyStepsRun)
// The raw YAML carries both keys explicitly.
require.Contains(t, buf.String(), "cli_verbs_used:")
require.Contains(t, buf.String(), "safety_steps_run:")
}
// TestSafetyStepsForVerbs unit-tests the verb→safety-tool map: it dedups
// (two verbs that map to feedback yield one), preserves the documented
// mappings, and a non-safety verb contributes nothing.
func TestSafetyStepsForVerbs(t *testing.T) {
require.Equal(t,
[]string{"change_contract", "check_guards", "get_test_targets", "verify_change"},
safetyStepsForVerbs([]string{"edit.verify", "edit.guards", "edit.tests", "edit.contract"}),
)
// feedback.record + feedback.query both map to the same tool — dedup.
require.Equal(t, []string{"feedback"}, safetyStepsForVerbs([]string{"feedback.record", "feedback.query"}))
// A non-safety verb maps to nothing.
require.Empty(t, safetyStepsForVerbs([]string{"query.stats", "daemon.start"}))
// Empty input is empty output.
require.Empty(t, safetyStepsForVerbs(nil))
}