WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
// Package doctor implements `weknora doctor` - 4-item self-check.
|
||||
//
|
||||
// Status semantics (4-tier):
|
||||
//
|
||||
// ok - passed
|
||||
// warn - soft problem; non-blocking (e.g. server minor older than CLI,
|
||||
// keychain unavailable so falling back to file store)
|
||||
// fail - failed; "hint" actionable
|
||||
// skip - cascade-skipped (prereq failed) or --offline mode
|
||||
//
|
||||
// JSON output emits the Result object directly (bare data). Exit-code
|
||||
// signal:
|
||||
// - any check is fail → exit 1 (RunE returns SilentError so the data
|
||||
// object is still emitted)
|
||||
// - warn only / all ok → exit 0
|
||||
//
|
||||
// summary.all_passed gives the agent a one-line short-circuit; it is true
|
||||
// ONLY when no warn / fail / skip checks are present. Agents SHOULD also
|
||||
// inspect checks[].status to distinguish warn from ok.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/build"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// doctorFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `doctor`. Items here refer to data.checks[*] entries (Check struct).
|
||||
var doctorFields = []string{"name", "status", "details", "hint"}
|
||||
|
||||
type Options struct {
|
||||
NoCache bool
|
||||
Offline bool
|
||||
}
|
||||
|
||||
// Status is the per-check outcome on the wire (JSON Marshal still emits the
|
||||
// underlying string). Typed so cascade comparisons can't typo against bare
|
||||
// "ok"/"fail"/"skip" string literals.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusWarn Status = "warn"
|
||||
StatusFail Status = "fail"
|
||||
StatusSkip Status = "skip"
|
||||
)
|
||||
|
||||
// Check is one row in the report.
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Status Status `json:"status"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Summary is the agent-friendly short-circuit payload.
|
||||
//
|
||||
// AllPassed is true only when there are zero warn/fail/skip rows; warn does
|
||||
// not block exit-0 but it does flip AllPassed so agents reading just the
|
||||
// boolean still notice the soft issue.
|
||||
type Summary struct {
|
||||
AllPassed bool `json:"all_passed"`
|
||||
Passed int `json:"passed"`
|
||||
Warned int `json:"warned,omitempty"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// Result is the bare JSON payload.
|
||||
type Result struct {
|
||||
Summary Summary `json:"summary"`
|
||||
Checks []Check `json:"checks"`
|
||||
}
|
||||
|
||||
// Services groups the narrow interfaces doctor needs. Implemented by
|
||||
// realServices (production) and fakeServices (tests).
|
||||
type Services interface {
|
||||
PingBaseURL(ctx context.Context) error
|
||||
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
|
||||
GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error)
|
||||
}
|
||||
|
||||
// NewCmd builds `weknora doctor`.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Run 4 self-checks: base URL, auth, server version, credential storage",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
svc, err := buildServices(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cliVer, _, _ := build.Info()
|
||||
r := runChecks(c.Context(), opts, svc, cliVer)
|
||||
emit(fopts, r)
|
||||
// Exit-code policy: fail → exit 1; warn / ok / skip → exit 0.
|
||||
// SilentError suppresses both the human "error: ..." line and
|
||||
// the stderr error formatter, so the JSON already written by
|
||||
// emit() is the only stdout content.
|
||||
if r.Summary.Failed > 0 {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache (located at $XDG_CACHE_HOME/weknora/server-info.yaml); force re-probe")
|
||||
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage (credential_storage check still runs)")
|
||||
cmdutil.AddFormatFlag(cmd, doctorFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "run self-checks: base-url reachability, auth credential, server version, credential storage",
|
||||
Examples: []string{
|
||||
"weknora doctor",
|
||||
"weknora doctor --offline",
|
||||
"weknora doctor --no-cache --format json",
|
||||
},
|
||||
Output: "envelope.data is {summary:{all_passed,passed,warned,failed,skipped}, checks:[{name,status,details,hint?}]}",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// cascade implements the two short-circuits every gated check shares:
|
||||
// offline-mode skip and prereq-failed skip. Returns true when the check has
|
||||
// been completed (Status set on c) and the caller should NOT run its body.
|
||||
//
|
||||
// A prereq is considered "passing" if its Status is OK or Warn - warn signals
|
||||
// a soft problem that does not block downstream functionality. Only fail/skip
|
||||
// cascades into a downstream skip.
|
||||
func cascade(c *Check, offline bool, prereqs ...*Check) bool {
|
||||
if offline {
|
||||
c.Status, c.Details = StatusSkip, "offline mode"
|
||||
return true
|
||||
}
|
||||
for _, p := range prereqs {
|
||||
if p.Status != StatusOK && p.Status != StatusWarn {
|
||||
c.Status, c.Details = StatusSkip, "prereq failed: "+p.Name
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runChecks executes the 4-item check matrix with cascade-skip semantics.
|
||||
// Pure function over Services so tests can drive it directly.
|
||||
func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string) Result {
|
||||
checks := []Check{
|
||||
{Name: "base_url_reachable"},
|
||||
{Name: "auth_credential"},
|
||||
{Name: "server_version"},
|
||||
{Name: "credential_storage"},
|
||||
}
|
||||
|
||||
// 1. base_url_reachable - gated by offline only.
|
||||
if !cascade(&checks[0], opts.Offline) {
|
||||
t0 := time.Now()
|
||||
if err := svc.PingBaseURL(ctx); err != nil {
|
||||
checks[0].Status = StatusFail
|
||||
checks[0].Hint = "verify the host configured for the active profile (run `weknora profile list` / `weknora profile add <n> --host=...`) and network reachability"
|
||||
checks[0].Details = err.Error()
|
||||
} else {
|
||||
checks[0].Status = StatusOK
|
||||
checks[0].Details = fmt.Sprintf("reachable in %s", time.Since(t0).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. auth_credential - needs base_url.
|
||||
if !cascade(&checks[1], opts.Offline, &checks[0]) {
|
||||
if _, err := svc.GetCurrentUser(ctx); err != nil {
|
||||
checks[1].Status = StatusFail
|
||||
checks[1].Hint = "run `weknora auth login`"
|
||||
checks[1].Details = err.Error()
|
||||
} else {
|
||||
checks[1].Status = StatusOK
|
||||
}
|
||||
}
|
||||
|
||||
// 3. server_version - needs auth_credential.
|
||||
if !cascade(&checks[2], opts.Offline, &checks[1]) {
|
||||
info, fromCache, err := loadOrProbeServerInfo(ctx, opts, svc)
|
||||
if err != nil {
|
||||
checks[2].Status = StatusFail
|
||||
checks[2].Details = err.Error()
|
||||
} else {
|
||||
fillVersionCheck(&checks[2], info, cliVer, fromCache)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. credential_storage - independent of network; never gated by offline.
|
||||
fillCredentialStorageCheck(&checks[3])
|
||||
|
||||
return Result{Summary: summarize(checks), Checks: checks}
|
||||
}
|
||||
|
||||
// fillVersionCheck applies compat.Compat to (server, cli) version pair and
|
||||
// sets Status/Details/Hint on c. fromCache toggles the "cached" suffix -
|
||||
// the loader knows authoritatively which branch it took, time-based
|
||||
// derivation from ProbedAt is unreliable since SaveCache uses time.Now().
|
||||
//
|
||||
// Mapping:
|
||||
//
|
||||
// compat.OK → StatusOK
|
||||
// compat.SoftWarn → StatusWarn (server older but in-range; soft skew)
|
||||
// compat.HardError → StatusFail (incompatible major; upgrade required)
|
||||
func fillVersionCheck(c *Check, info *compat.Info, cliVer string, fromCache bool) {
|
||||
level, hint := compat.Compat(info.ServerVersion, cliVer)
|
||||
suffix := ""
|
||||
if fromCache {
|
||||
suffix = " (cached, pass --no-cache to refresh)"
|
||||
}
|
||||
switch level {
|
||||
case compat.HardError:
|
||||
c.Status = StatusFail
|
||||
c.Hint = hint
|
||||
c.Details = "server " + info.ServerVersion + suffix
|
||||
case compat.SoftWarn:
|
||||
c.Status = StatusWarn
|
||||
// Lowercase, no trailing punctuation: matches existing details style
|
||||
// ("reachable in N", "keyring or file storage available"). The hint
|
||||
// from compat.Compat already says "server is older (server X, client Y)…"
|
||||
c.Details = hint + suffix
|
||||
c.Hint = "some new commands may degrade gracefully; upgrade server when convenient"
|
||||
default:
|
||||
c.Status = StatusOK
|
||||
if hint != "" {
|
||||
c.Details = hint + suffix
|
||||
} else {
|
||||
c.Details = fmt.Sprintf("server %s%s", info.ServerVersion, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// credStoreFactory is the seam tests use to inject a fake-store outcome -
|
||||
// keyring success, file-store fallback, or hard failure - without touching
|
||||
// the lazy-resolve buildServices contract (round-4 fix). Production stays
|
||||
// at secrets.NewBestEffortStore.
|
||||
var credStoreFactory = func() (secrets.Store, error) { return secrets.NewBestEffortStore() }
|
||||
|
||||
// SetCredStoreFactoryForTest overrides the credential-storage factory used by
|
||||
// runChecks and returns a cleanup function that restores the previous value.
|
||||
// Exported so out-of-package tests (notably cli/acceptance/contract) can
|
||||
// pin the credential_storage outcome - otherwise the check probes the real
|
||||
// OS keyring, which is present on macOS dev machines but not on Linux CI
|
||||
// runners without libsecret, producing host-dependent test flakes.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// restore := doctor.SetCredStoreFactoryForTest(func() (secrets.Store, error) {
|
||||
// return secrets.NewMemStore(), nil // not *FileStore → StatusOK
|
||||
// })
|
||||
// defer restore()
|
||||
func SetCredStoreFactoryForTest(fn func() (secrets.Store, error)) (restore func()) {
|
||||
saved := credStoreFactory
|
||||
credStoreFactory = fn
|
||||
return func() { credStoreFactory = saved }
|
||||
}
|
||||
|
||||
// fillCredentialStorageCheck distinguishes the three terminal states the
|
||||
// secrets layer can produce:
|
||||
//
|
||||
// keyring usable → StatusOK (preferred path)
|
||||
// file fallback → StatusWarn (keyring unavailable, secrets still persist
|
||||
// with 0600 file perms - agent containers / WSL hit this)
|
||||
// construction fails → StatusFail
|
||||
//
|
||||
// Detection of "fallback to file store" relies on the type returned by
|
||||
// secrets.NewBestEffortStore: a *FileStore concrete value means keyring was
|
||||
// unavailable and the layer degraded. The Ref() URI scheme would also work
|
||||
// but type-assertion is structurally more robust to scheme renames.
|
||||
func fillCredentialStorageCheck(c *Check) {
|
||||
store, err := credStoreFactory()
|
||||
if err != nil {
|
||||
c.Status = StatusFail
|
||||
c.Details = err.Error()
|
||||
c.Hint = "verify keyring access; falls back to file store"
|
||||
return
|
||||
}
|
||||
if _, isFile := store.(*secrets.FileStore); isFile {
|
||||
c.Status = StatusWarn
|
||||
c.Details = "falling back to file store: keychain unavailable"
|
||||
c.Hint = "secrets persist at 0600 under $XDG_CONFIG_HOME/weknora/secrets/; install / unlock keyring for OS-backed storage"
|
||||
return
|
||||
}
|
||||
c.Status = StatusOK
|
||||
c.Details = "keyring or file storage available"
|
||||
}
|
||||
|
||||
// loadOrProbeServerInfo respects --no-cache: load fresh cache when allowed,
|
||||
// else call compat.Probe (which wraps svc.GetSystemInfo) and persist. Cache
|
||||
// write is best-effort. Returns fromCache so the caller can render the
|
||||
// "cached" presentation hint without a brittle ProbedAt heuristic.
|
||||
func loadOrProbeServerInfo(ctx context.Context, opts *Options, svc Services) (info *compat.Info, fromCache bool, err error) {
|
||||
if !opts.NoCache {
|
||||
if cached, fresh, _ := compat.LoadCache(); fresh && cached != nil {
|
||||
return cached, true, nil
|
||||
}
|
||||
}
|
||||
probed, err := compat.Probe(ctx, svc)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
_ = compat.SaveCache(probed)
|
||||
return probed, false, nil
|
||||
}
|
||||
|
||||
func summarize(cs []Check) Summary {
|
||||
s := Summary{}
|
||||
for _, c := range cs {
|
||||
switch c.Status {
|
||||
case StatusOK:
|
||||
s.Passed++
|
||||
case StatusWarn:
|
||||
s.Warned++
|
||||
case StatusFail:
|
||||
s.Failed++
|
||||
case StatusSkip:
|
||||
s.Skipped++
|
||||
}
|
||||
}
|
||||
s.AllPassed = s.Failed == 0 && s.Skipped == 0 && s.Warned == 0
|
||||
return s
|
||||
}
|
||||
|
||||
// emit renders the doctor result. The JSON path emits the Result directly;
|
||||
// pass/fail signaling is conveyed by summary.failed (and the process exit
|
||||
// code, set by the caller).
|
||||
func emit(fopts *cmdutil.FormatOptions, r Result) {
|
||||
if fopts.WantsJSON() {
|
||||
_ = fopts.Emit(iostreams.IO.Out, r, nil)
|
||||
return
|
||||
}
|
||||
for _, c := range r.Checks {
|
||||
// %-2s for the glyph: most are 1 column, leaves room for one trailing
|
||||
// space. Status word follows so screen-readers / non-glyph terminals
|
||||
// still get the textual classification.
|
||||
line := fmt.Sprintf("%-2s %-20s %s", marker(c.Status), c.Name, c.Status)
|
||||
if c.Details != "" {
|
||||
line += " (" + c.Details + ")"
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Out, line)
|
||||
if c.Hint != "" {
|
||||
fmt.Fprintf(iostreams.IO.Out, " hint: %s\n", c.Hint)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "\nsummary: %d passed, %d warned, %d failed, %d skipped\n",
|
||||
r.Summary.Passed, r.Summary.Warned, r.Summary.Failed, r.Summary.Skipped)
|
||||
}
|
||||
|
||||
// marker returns the text-mode prefix glyph for a check status.
|
||||
//
|
||||
// Agent / JSON consumers read the stable status string from
|
||||
// data.checks[].status; the glyphs are presentation-only and never appear
|
||||
// in --format json output.
|
||||
func marker(s Status) string {
|
||||
switch s {
|
||||
case StatusFail:
|
||||
return "✗"
|
||||
case StatusWarn:
|
||||
return "⚠"
|
||||
case StatusSkip:
|
||||
return "⊘"
|
||||
default:
|
||||
return "✓"
|
||||
}
|
||||
}
|
||||
|
||||
// buildServices wires the Factory closures into the doctor.Services interface.
|
||||
// Reads the active profile's host so PingBaseURL targets the user's actual
|
||||
// server, not localhost.
|
||||
//
|
||||
// Critically: this does NOT pre-resolve f.Client(). doctor's package promise
|
||||
// (top comment) is that credential_storage runs even when no auth is set up -
|
||||
// e.g. first-time `weknora doctor` to diagnose setup. Pre-resolving Client
|
||||
// here would early-exit with auth.unauthenticated before any check runs,
|
||||
// contradicting the docs. Instead, GetCurrentUser / GetSystemInfo lazily
|
||||
// resolve and surface their own failure as a per-check StatusFail.
|
||||
func buildServices(f *cmdutil.Factory) (Services, error) {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &realServices{f: f, host: resolveDoctorHost(cfg)}, nil
|
||||
}
|
||||
|
||||
// resolveDoctorHost picks the host base_url_reachable probes. Tiers 2 and 3
|
||||
// mirror the client builder (buildClientFromEnv) so doctor probes the host the
|
||||
// real commands actually connect to; tier 1 is a doctor-local test/dev knob:
|
||||
//
|
||||
// 1. WEKNORA_BASE_URL — doctor-only probe override (used by tests); NOT read
|
||||
// by the client builder, so setting it points doctor at a host no real
|
||||
// command uses. Kept for test/dev harnesses; leave unset in normal use.
|
||||
// 2. WEKNORA_HOST — when stateless env credentials (WEKNORA_TOKEN /
|
||||
// WEKNORA_API_KEY) are in effect, i.e. the headless agent path. Without
|
||||
// this, `WEKNORA_API_KEY=… WEKNORA_HOST=… weknora doctor` falsely reported
|
||||
// "no host configured" and exited 1 while every other command worked.
|
||||
// 3. active profile host — the configured default.
|
||||
func resolveDoctorHost(cfg *config.Config) string {
|
||||
host := ""
|
||||
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = ctx.Host
|
||||
}
|
||||
// Env credentials authenticate via WEKNORA_HOST, bypassing the profile.
|
||||
// Honor it only when such creds are actually set, matching the client
|
||||
// builder (a bare WEKNORA_HOST without creds is ignored there too).
|
||||
if envActive, _ := cmdutil.EnvCredential(); envActive {
|
||||
if v := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); v != "" {
|
||||
host = v
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("WEKNORA_BASE_URL"); v != "" {
|
||||
host = v
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
type realServices struct {
|
||||
f *cmdutil.Factory
|
||||
host string
|
||||
}
|
||||
|
||||
// pingTimeout caps the HEAD /health probe so a wedged TCP connection
|
||||
// can't hang doctor indefinitely.
|
||||
const pingTimeout = 5 * time.Second
|
||||
|
||||
func (s *realServices) PingBaseURL(ctx context.Context) error {
|
||||
if s.host == "" {
|
||||
return fmt.Errorf("no host configured for active profile")
|
||||
}
|
||||
url := s.host + "/health"
|
||||
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("server returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentUser lazily resolves the SDK client. When no profile is configured
|
||||
// or credentials missing, f.Client() returns auth.unauthenticated; we surface
|
||||
// that as the auth_credential check's failure rather than aborting doctor.
|
||||
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
|
||||
cli, err := s.f.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cli.GetCurrentUser(ctx)
|
||||
}
|
||||
|
||||
// GetSystemInfo lazily resolves the SDK client (same rationale as GetCurrentUser).
|
||||
// In the cascade ordering, auth_credential gates server_version, so this only
|
||||
// runs when auth_credential succeeded - but the lazy resolution keeps doctor
|
||||
// useful when only credential_storage is checkable (e.g., user not yet logged in).
|
||||
func (s *realServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
|
||||
cli, err := s.f.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cli.GetSystemInfo(ctx)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// withCredStoreFactory swaps the package-level credStoreFactory hook for the
|
||||
// duration of t. Tests that assert the credential_storage outcome use this
|
||||
// rather than touching the real OS keyring (which is environment-dependent
|
||||
// and would make CI flake on macOS vs Linux vs WSL). Restored in t.Cleanup.
|
||||
//
|
||||
// The hook intentionally lives at package scope (rather than as a runChecks
|
||||
// parameter) so the production call site stays a zero-arg function - keeping
|
||||
// the lazy-resolve buildServices contract unchanged (round-4 fix).
|
||||
func withCredStoreFactory(t *testing.T, fn func() (secrets.Store, error)) {
|
||||
t.Helper()
|
||||
saved := credStoreFactory
|
||||
credStoreFactory = fn
|
||||
t.Cleanup(func() { credStoreFactory = saved })
|
||||
}
|
||||
|
||||
type fakeServices struct {
|
||||
systemInfo *sdk.SystemInfo
|
||||
systemErr error
|
||||
userResp *sdk.CurrentUserResponse
|
||||
userErr error
|
||||
pingErr error
|
||||
systemInfoHits atomic.Int32 // count GetSystemInfo invocations
|
||||
}
|
||||
|
||||
func (f *fakeServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
|
||||
f.systemInfoHits.Add(1)
|
||||
return f.systemInfo, f.systemErr
|
||||
}
|
||||
func (f *fakeServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
|
||||
return f.userResp, f.userErr
|
||||
}
|
||||
func (f *fakeServices) PingBaseURL(ctx context.Context) error { return f.pingErr }
|
||||
|
||||
func goodUserResp() *sdk.CurrentUserResponse {
|
||||
r := &sdk.CurrentUserResponse{}
|
||||
r.Data.User = &sdk.AuthUser{ID: "u1"}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestDoctor_AllOK(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
// Pin credential_storage to ok so this test doesn't probe the host OS
|
||||
// keyring - macOS dev machines have Keychain, Linux CI without libsecret
|
||||
// would otherwise warn and break the AllPassed assertion.
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) {
|
||||
return secrets.NewMemStore(), nil
|
||||
})
|
||||
svc := &fakeServices{
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
|
||||
userResp: goodUserResp(),
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
if !r.Summary.AllPassed {
|
||||
t.Errorf("expected all_passed, got summary %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.Passed != 4 {
|
||||
t.Errorf("expected Passed=4 (one per check), got %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.Failed != 0 || r.Summary.Skipped != 0 {
|
||||
t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary)
|
||||
}
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
|
||||
if !strings.Contains(out.String(), `"all_passed":true`) {
|
||||
t.Errorf("bare output should embed all_passed=true, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctor_BaseURLFails_DownstreamSkip(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeServices{
|
||||
pingErr: errors.New("connect refused"),
|
||||
userResp: goodUserResp(),
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
if r.Summary.Skipped != 2 {
|
||||
t.Errorf("expected 2 skipped (auth_credential + server_version), got %d", r.Summary.Skipped)
|
||||
}
|
||||
if r.Checks[0].Status != StatusFail {
|
||||
t.Errorf("base_url_reachable status = %q, want fail", r.Checks[0].Status)
|
||||
}
|
||||
if !strings.Contains(r.Checks[0].Hint, "profile") {
|
||||
t.Errorf("base_url fail hint should reference the active profile's host config, got %q", r.Checks[0].Hint)
|
||||
}
|
||||
if r.Checks[1].Status != StatusSkip {
|
||||
t.Errorf("auth_credential status = %q, want skip", r.Checks[1].Status)
|
||||
}
|
||||
if r.Checks[2].Status != StatusSkip {
|
||||
t.Errorf("server_version status = %q, want skip", r.Checks[2].Status)
|
||||
}
|
||||
// credential_storage is network-independent and runs regardless of base_url failures.
|
||||
if r.Checks[3].Name != "credential_storage" {
|
||||
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctor_Offline_OnlyKeyringChecked(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeServices{}
|
||||
r := runChecks(context.Background(), &Options{Offline: true}, svc, "1.0.0")
|
||||
if r.Summary.Skipped < 3 {
|
||||
t.Errorf("expected >=3 skip in offline mode, got %d", r.Summary.Skipped)
|
||||
}
|
||||
last := r.Checks[3]
|
||||
if last.Name != "credential_storage" {
|
||||
t.Errorf("last check = %q, want credential_storage", last.Name)
|
||||
}
|
||||
if last.Status == StatusSkip {
|
||||
t.Error("credential_storage should NOT be skipped even in offline mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctor_AuthFails_VersionSkipped(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeServices{
|
||||
userErr: errors.New("HTTP error 401: unauthenticated"),
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
if r.Checks[0].Status != StatusOK {
|
||||
t.Errorf("base_url should pass, got %q", r.Checks[0].Status)
|
||||
}
|
||||
if r.Checks[1].Status != StatusFail {
|
||||
t.Errorf("auth_credential should fail, got %q", r.Checks[1].Status)
|
||||
}
|
||||
if r.Checks[2].Status != StatusSkip {
|
||||
t.Errorf("server_version should skip due to auth fail, got %q", r.Checks[2].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctor_CacheHit_SkipsProbe(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// Pre-populate fresh cache
|
||||
if err := compat.SaveCache(&compat.Info{ServerVersion: "1.0.0", ProbedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("seed cache: %v", err)
|
||||
}
|
||||
svc := &fakeServices{userResp: goodUserResp()}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
if r.Checks[2].Status != StatusOK {
|
||||
t.Errorf("server_version should be ok via cache, got %q (%s)", r.Checks[2].Status, r.Checks[2].Details)
|
||||
}
|
||||
if svc.systemInfoHits.Load() != 0 {
|
||||
t.Errorf("expected 0 GetSystemInfo calls (cache hit), got %d", svc.systemInfoHits.Load())
|
||||
}
|
||||
if !strings.Contains(r.Checks[2].Details, "cached") {
|
||||
t.Errorf("details should mention cache, got %q", r.Checks[2].Details)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_NoConfig_StillRunsCredentialStorage guards the package-doc
|
||||
// promise that credential_storage runs even with zero configuration. Round-4
|
||||
// reviewer surfaced that buildServices used to abort on f.Client() failure,
|
||||
// silently violating the doc for any first-time user running `weknora doctor`
|
||||
// to diagnose setup. The lazy-resolve fix means missing auth surfaces as
|
||||
// auth_credential=fail, not a top-level command exit.
|
||||
func TestDoctor_NoConfig_StillRunsCredentialStorage(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// Pin credential_storage to ok regardless of host keyring presence
|
||||
// (Linux CI without libsecret would otherwise warn).
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) {
|
||||
return secrets.NewMemStore(), nil
|
||||
})
|
||||
|
||||
f := cmdutil.New()
|
||||
svc, err := buildServices(f)
|
||||
if err != nil {
|
||||
t.Fatalf("buildServices must succeed even with no config; got: %v", err)
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
|
||||
// All 4 checks must run (no early-exit). Last one is credential_storage and
|
||||
// has no network/auth dependency, so it must report ok.
|
||||
if got := len(r.Checks); got != 4 {
|
||||
t.Fatalf("expected 4 checks executed, got %d", got)
|
||||
}
|
||||
if r.Checks[3].Name != "credential_storage" {
|
||||
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
|
||||
}
|
||||
if r.Checks[3].Status != StatusOK {
|
||||
t.Errorf("credential_storage must run / pass even without auth, got %q (%s)",
|
||||
r.Checks[3].Status, r.Checks[3].Details)
|
||||
}
|
||||
// base_url_reachable fails (no host); cascade then skips auth_credential
|
||||
// and server_version. Whether auth/version are skip vs fail is an internal
|
||||
// cascade detail; the user-visible promise is that 4 checks executed and
|
||||
// the independent credential_storage one passed.
|
||||
if r.Checks[0].Status != StatusFail {
|
||||
t.Errorf("base_url must fail without host, got %q", r.Checks[0].Status)
|
||||
}
|
||||
if r.Checks[1].Status == StatusOK {
|
||||
t.Errorf("auth_credential must NOT report ok without config, got %q", r.Checks[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctor_NoCache_BypassesCache(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// Pre-populate fresh cache; --no-cache should ignore it
|
||||
if err := compat.SaveCache(&compat.Info{ServerVersion: "9.9.9", ProbedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("seed cache: %v", err)
|
||||
}
|
||||
svc := &fakeServices{
|
||||
userResp: goodUserResp(),
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{NoCache: true}, svc, "1.0.0")
|
||||
if svc.systemInfoHits.Load() != 1 {
|
||||
t.Errorf("expected 1 GetSystemInfo call (--no-cache), got %d", svc.systemInfoHits.Load())
|
||||
}
|
||||
if !strings.Contains(r.Checks[2].Details, "1.0.0") {
|
||||
t.Errorf("details should reflect probed version 1.0.0 not cached 9.9.9, got %q", r.Checks[2].Details)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_VersionSkewWarns covers the soft-skew path: server is older
|
||||
// than CLI by ≥ 1 minor (same major, in compat range) → server_version=warn,
|
||||
// summary.failed=0 (exit 0), summary.all_passed=false (so agents reading
|
||||
// just the boolean still notice). The compat decision lives in
|
||||
// cli/internal/compat; this test pins the doctor-side mapping
|
||||
// (compat.SoftWarn → StatusWarn).
|
||||
func TestDoctor_VersionSkewWarns(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// Force keyring path to ok so credential_storage doesn't itself warn -
|
||||
// we want this test to assert ONLY on server_version.
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
|
||||
|
||||
svc := &fakeServices{
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"}, // server 1.0
|
||||
userResp: goodUserResp(),
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.5.0") // CLI 1.5
|
||||
|
||||
v := r.Checks[2]
|
||||
if v.Name != "server_version" {
|
||||
t.Fatalf("Checks[2] = %q, want server_version", v.Name)
|
||||
}
|
||||
if v.Status != StatusWarn {
|
||||
t.Errorf("server_version status = %q, want warn (server older than CLI)", v.Status)
|
||||
}
|
||||
// compat.Compat hint contains "server is older" - that's the load-bearing
|
||||
// substring agents may pattern-match. Not asserting full message text
|
||||
// since compat.Compat owns the wording.
|
||||
if !strings.Contains(v.Details, "older") {
|
||||
t.Errorf("server_version details should mention older server, got %q", v.Details)
|
||||
}
|
||||
// Summary: warned counted, failed=0, all_passed flipped.
|
||||
if r.Summary.Warned != 1 {
|
||||
t.Errorf("expected Warned=1, got summary %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.Failed != 0 {
|
||||
t.Errorf("expected Failed=0, got summary %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.AllPassed {
|
||||
t.Error("AllPassed must be false when any check is warn")
|
||||
}
|
||||
|
||||
// Wire shape: warn-only run has envelope wrapper with ok:true.
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
|
||||
body := out.String()
|
||||
if !strings.Contains(body, `"ok":true`) {
|
||||
t.Errorf("doctor output must carry envelope ok:true, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, `"failed":0`) {
|
||||
t.Errorf("bare output must carry failed:0 on warn-only run, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, `"status":"warn"`) {
|
||||
t.Errorf("bare output must surface status=warn, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_HardErrorStillFails guards that a different-major skew remains
|
||||
// fail (not warn). Without this, a later refactor could silently downgrade
|
||||
// HardError to warn and we'd lose exit-1 on incompatible servers.
|
||||
func TestDoctor_HardErrorStillFails(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
|
||||
|
||||
svc := &fakeServices{
|
||||
systemInfo: &sdk.SystemInfo{Version: "2.0.0"},
|
||||
userResp: goodUserResp(),
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
if r.Checks[2].Status != StatusFail {
|
||||
t.Errorf("server_version status = %q, want fail (different major)", r.Checks[2].Status)
|
||||
}
|
||||
if r.Summary.Failed != 1 {
|
||||
t.Errorf("expected Failed=1, got %+v", r.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_KeychainFallbackWarns covers credential_storage's third
|
||||
// state: keyring unavailable, fell back to FileStore (agent containers,
|
||||
// headless CI, WSL without DBus). The check should warn - secrets still
|
||||
// persist (0600 file perms) but the OS-backed path was unreachable.
|
||||
func TestDoctor_KeychainFallbackWarns(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) {
|
||||
// FileStore concrete type → fillCredentialStorageCheck reads warn.
|
||||
return secrets.NewFileStore()
|
||||
})
|
||||
|
||||
svc := &fakeServices{
|
||||
userResp: goodUserResp(),
|
||||
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
|
||||
}
|
||||
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
|
||||
|
||||
cs := r.Checks[3]
|
||||
if cs.Name != "credential_storage" {
|
||||
t.Fatalf("Checks[3] = %q, want credential_storage", cs.Name)
|
||||
}
|
||||
if cs.Status != StatusWarn {
|
||||
t.Errorf("credential_storage status = %q, want warn (file fallback)", cs.Status)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(cs.Details), "file store") {
|
||||
t.Errorf("details should mention file store, got %q", cs.Details)
|
||||
}
|
||||
if r.Summary.Warned != 1 {
|
||||
t.Errorf("expected Warned=1, got %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.Failed != 0 {
|
||||
t.Errorf("expected Failed=0 (warn doesn't fail), got %+v", r.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_CredStoreFactoryError surfaces the constructor failure path as
|
||||
// fail (not warn) - distinguishes "cannot persist credentials at all" from
|
||||
// "downgraded to file store".
|
||||
func TestDoctor_CredStoreFactoryError(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) {
|
||||
return nil, errors.New("disk full")
|
||||
})
|
||||
|
||||
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
|
||||
if r.Checks[3].Status != StatusFail {
|
||||
t.Errorf("credential_storage status = %q, want fail on constructor error", r.Checks[3].Status)
|
||||
}
|
||||
if !strings.Contains(r.Checks[3].Details, "disk full") {
|
||||
t.Errorf("details should propagate underlying error, got %q", r.Checks[3].Details)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_BareJSON_WarnDoesNotSignalFail pins the wire contract: warn
|
||||
// keeps summary.failed=0 (so exit code stays 0). emit() is the seam between
|
||||
// Result and what agents observe.
|
||||
func TestDoctor_BareJSON_WarnDoesNotSignalFail(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
r := Result{
|
||||
Summary: Summary{AllPassed: false, Passed: 3, Warned: 1},
|
||||
Checks: []Check{
|
||||
{Name: "base_url_reachable", Status: StatusOK},
|
||||
{Name: "auth_credential", Status: StatusOK},
|
||||
{Name: "server_version", Status: StatusWarn, Details: "server is older"},
|
||||
{Name: "credential_storage", Status: StatusOK},
|
||||
},
|
||||
}
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
|
||||
got := out.String()
|
||||
if !strings.Contains(got, `"failed":0`) {
|
||||
t.Errorf("warn-only result must have summary.failed=0 (exit-0 signal), got %q", got)
|
||||
}
|
||||
// v0.7 envelope: ok:true is expected
|
||||
if !strings.Contains(got, `"ok":true`) {
|
||||
t.Errorf("output must carry envelope ok:true, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_BareJSON_FailRaisesSummary pins the dual: any fail surfaces in
|
||||
// summary.failed (caller maps that to exit 1 via SilentError).
|
||||
func TestDoctor_BareJSON_FailRaisesSummary(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
r := Result{
|
||||
Summary: Summary{AllPassed: false, Passed: 2, Failed: 1, Skipped: 1},
|
||||
Checks: []Check{
|
||||
{Name: "base_url_reachable", Status: StatusFail, Details: "refused"},
|
||||
{Name: "auth_credential", Status: StatusSkip},
|
||||
{Name: "server_version", Status: StatusOK},
|
||||
{Name: "credential_storage", Status: StatusOK},
|
||||
},
|
||||
}
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
|
||||
got := out.String()
|
||||
if !strings.Contains(got, `"failed":1`) {
|
||||
t.Errorf("fail must surface in summary.failed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_TextMarker_Warn confirms the human-mode glyph appears for warn
|
||||
// rows. Glyph choice is presentation-only; we pin via substring (no width
|
||||
// alignment assertion since terminal-width handling is environmental).
|
||||
func TestDoctor_TextMarker_Warn(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
r := Result{
|
||||
Summary: Summary{Warned: 1},
|
||||
Checks: []Check{
|
||||
{Name: "server_version", Status: StatusWarn, Details: "older"},
|
||||
},
|
||||
}
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, r)
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "⚠") {
|
||||
t.Errorf("text output should contain ⚠ glyph for warn, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "warn") {
|
||||
t.Errorf("text output should still contain status word `warn`, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_WarnedField_OmittedAtZero protects the JSON wire compactness:
|
||||
// `warned` carries omitempty, so a clean run has no warned key. Existing
|
||||
// agents inspecting older outputs shouldn't see a sudden new field unless
|
||||
// it actually fired.
|
||||
func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
r := Result{
|
||||
Summary: Summary{AllPassed: true, Passed: 4},
|
||||
Checks: []Check{
|
||||
{Name: "base_url_reachable", Status: StatusOK},
|
||||
{Name: "auth_credential", Status: StatusOK},
|
||||
{Name: "server_version", Status: StatusOK},
|
||||
{Name: "credential_storage", Status: StatusOK},
|
||||
},
|
||||
}
|
||||
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
|
||||
got := out.String()
|
||||
if strings.Contains(got, `"warned"`) {
|
||||
t.Errorf("output should omit `warned` field when zero, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_RunE_FailReturnsSilentError is a behavior test on NewCmd:
|
||||
// when any check is fail, RunE must return cmdutil.SilentError so the
|
||||
// framework exit-1 path runs without writing a second error line on top
|
||||
// of the data object emit() already wrote.
|
||||
//
|
||||
// SilenceErrors/SilenceUsage on the leaf cobra.Command suppress cobra's own
|
||||
// "Error: ..." + usage dump that would otherwise leak to stderr when running
|
||||
// the leaf in isolation (root.go sets these on the root, not on every leaf).
|
||||
func TestDoctor_RunE_FailReturnsSilentError(t *testing.T) {
|
||||
// Force base_url to fail by leaving host empty.
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
|
||||
|
||||
f := cmdutil.New()
|
||||
cmd := NewCmd(f)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
// --format is persistent at root in v0.7; register locally for the
|
||||
// isolated leaf-execution test path.
|
||||
cmd.PersistentFlags().String("format", "", "")
|
||||
cmd.PersistentFlags().String("jq", "", "")
|
||||
cmd.SetArgs([]string{"--format", "json"})
|
||||
cmd.SetContext(context.Background())
|
||||
err := cmd.Execute()
|
||||
if !errors.Is(err, cmdutil.SilentError) {
|
||||
t.Errorf("RunE on fail must return SilentError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctor_RunE_WarnReturnsNil - soft skew path stays exit-0. Pairs with
|
||||
// TestDoctor_RunE_FailReturnsSilentError: warn does NOT trigger SilentError.
|
||||
func TestDoctor_RunE_WarnReturnsNil(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewFileStore() })
|
||||
|
||||
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
|
||||
// Smoke-check: warn-only result, no fails.
|
||||
if r.Summary.Failed != 0 {
|
||||
t.Fatalf("setup error: expected Failed=0, got %+v", r.Summary)
|
||||
}
|
||||
if r.Summary.Warned == 0 {
|
||||
t.Fatalf("setup error: expected Warned>=1, got %+v", r.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDoctorHost_EnvCredentials - doctor's base_url probe must honor
|
||||
// WEKNORA_HOST when stateless env credentials are in effect (the headless
|
||||
// agent path), mirroring buildClientFromEnv. Regression: doctor previously
|
||||
// read only the active profile host, so `WEKNORA_API_KEY=... WEKNORA_HOST=...
|
||||
// weknora doctor` reported "no host configured" and exited 1 while every
|
||||
// other command worked.
|
||||
func TestResolveDoctorHost_EnvCredentials(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://profile-host"}},
|
||||
}
|
||||
|
||||
t.Run("env creds + WEKNORA_HOST wins over profile", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_TOKEN", "")
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "https://env-host:8080")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://env-host:8080" {
|
||||
t.Errorf("want env host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env creds without WEKNORA_HOST falls back to profile", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
|
||||
t.Errorf("want profile host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no env creds ignores WEKNORA_HOST (matches client builder)", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_TOKEN", "")
|
||||
t.Setenv("WEKNORA_API_KEY", "")
|
||||
t.Setenv("WEKNORA_HOST", "https://should-be-ignored")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
|
||||
t.Errorf("want profile host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WEKNORA_BASE_URL test override always wins", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "https://env-host")
|
||||
t.Setenv("WEKNORA_BASE_URL", "https://base-url-override")
|
||||
if got := resolveDoctorHost(cfg); got != "https://base-url-override" {
|
||||
t.Errorf("want base-url override, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user