// ABOUTME: tests for the parse-diff CLI command: flag validation, // ABOUTME: empty-archive runs, JSON shape, and the text renderer. package main import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/dbtest" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/sync" "go.kenn.io/agentsview/internal/testjsonl" ) // isolateParseDiffEnv points the data dir, HOME, and every per-agent // directory override at empty temp dirs so end-to-end runs never // discover the developer machine's real session files. func isolateParseDiffEnv(t *testing.T) { t.Helper() testDataDir(t) home := t.TempDir() t.Setenv("HOME", home) for _, def := range parser.Registry { if def.EnvVar != "" { t.Setenv(def.EnvVar, filepath.Join(home, "agent-dirs", string(def.Type))) } } } func TestParseDiff_RegisteredInRootHelp(t *testing.T) { help, err := executeCommand(newRootCommand(), "--help") require.NoError(t, err, "Execute") assert.Contains(t, help, "parse-diff", "root help should list the parse-diff command") } func TestParseDiff_UnknownAgentListsSupported(t *testing.T) { testDataDir(t) _, err := executeCommand(newRootCommand(), "parse-diff", "--agent", "definitely-not-an-agent") require.Error(t, err) assert.Contains(t, err.Error(), `unknown agent "definitely-not-an-agent"`) for _, want := range []string{"claude", "codex", "gemini"} { assert.Contains(t, err.Error(), want, "error should list supported agent %q", want) } // The DB-backed provider-authoritative agents are re-parseable through // their providers, so they appear in the supported list too. for _, want := range []string{"forge", "devin", "piebald", "warp"} { assert.Contains(t, err.Error(), want, "error should list supported DB-backed agent %q", want) } // Import-only agents remain unsupported and must not be listed. for _, unwanted := range []string{"claude-ai", "chatgpt"} { assert.NotContains(t, err.Error(), unwanted, "error should not list import-only agent %q", unwanted) } } func TestParseDiff_RejectsAgentsWithoutOnDiskSource(t *testing.T) { // Only import-only agents have no source to re-parse. The DB-backed // provider-authoritative agents (Forge/Piebald/Warp) are re-parseable // through their providers and are covered as supported elsewhere. tests := []struct { name string agent string }{ {"import-only claude-ai", "claude-ai"}, {"import-only chatgpt", "chatgpt"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { testDataDir(t) _, err := executeCommand(newRootCommand(), "parse-diff", "--agent", tc.agent) require.Error(t, err) assert.Contains(t, err.Error(), fmt.Sprintf( "agent %q is not supported by parse-diff", tc.agent)) }) } } func TestParseDiff_RejectsNegativeLimit(t *testing.T) { testDataDir(t) _, err := executeCommand(newRootCommand(), "parse-diff", "--limit", "-1") require.Error(t, err) assert.Contains(t, err.Error(), "--limit must be >= 0") } func TestParseDiffAgentTypes(t *testing.T) { tests := []struct { name string in []string want []string wantErr string }{ {name: "empty means all", in: nil, want: nil}, { name: "single agent", in: []string{"claude"}, want: []string{"claude"}, }, { name: "provider authoritative agent", in: []string{"pi"}, want: []string{"pi"}, }, { name: "provider authoritative shared provider family agent", in: []string{"omp"}, want: []string{"omp"}, }, { name: "db-backed provider-authoritative agents", in: []string{"forge", "devin", "piebald", "warp"}, want: []string{"forge", "devin", "piebald", "warp"}, }, { name: "trims and lowercases", in: []string{" Claude "}, want: []string{"claude"}, }, { name: "dedupes preserving order", in: []string{"codex", "claude", "codex"}, want: []string{"codex", "claude"}, }, { name: "unknown agent", in: []string{"nope"}, wantErr: `unknown agent "nope"`, }, { name: "import-only agent", in: []string{"claude-ai"}, wantErr: "is not supported by parse-diff", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := parseDiffAgentTypes(tc.in) if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) return } require.NoError(t, err) strs := make([]string, 0, len(got)) for _, a := range got { strs = append(strs, string(a)) } if tc.want == nil { assert.Empty(t, strs) return } assert.Equal(t, tc.want, strs) }) } } func TestParseDiffSupportedAgentsIncludesProviderAuthoritativeAgents(t *testing.T) { supported := parseDiffSupportedAgents() modes := parser.ProviderMigrationModes() // Build the expected set from the registry so the contract covers every // current provider-authoritative agent and stays correct as the migration // manifest changes, rather than a hand-maintained subset. FileBased is not // part of the gate: DB-backed provider-authoritative agents // (Forge/Devin/Piebald/Warp) are re-parseable through their providers too. checked := 0 for _, def := range parser.Registry { if modes[def.Type] != parser.ProviderMigrationProviderAuthoritative { continue } if _, ok := parser.ProviderFactoryByType(def.Type); !ok { assert.False(t, parseDiffAgentSupported(def), "parse-diff must exclude %s without a provider factory", def.Type) assert.NotContains(t, supported, string(def.Type), "parse-diff supported list must exclude %s without a provider factory", def.Type) continue } checked++ assert.True(t, parseDiffAgentSupported(def), "parse-diff support must include %s", def.Type) assert.Contains(t, supported, string(def.Type), "parse-diff supported list must include %s", def.Type) } require.Positive(t, checked, "expected at least one provider-authoritative agent") // Explicitly pin the DB-backed provider-authoritative agents so a // regression that re-adds a FileBased gate to the parse-diff support // check is caught by name, not just by the registry-wide sweep above. for _, agent := range []parser.AgentType{ parser.AgentForge, parser.AgentDevin, parser.AgentPiebald, parser.AgentWarp, } { def, ok := parser.AgentByType(agent) require.True(t, ok, "agent %s", agent) assert.False(t, def.FileBased, "%s is expected to be DB-backed (FileBased=false)", agent) assert.True(t, parseDiffAgentSupported(def), "DB-backed %s must be supported by parse-diff", agent) assert.Contains(t, supported, string(agent), "parse-diff supported list must include DB-backed %s", agent) } } func TestParseDiff_EmptyArchiveRunsClean(t *testing.T) { isolateParseDiffEnv(t) out, err := executeCommand(newRootCommand(), "parse-diff") require.NoError(t, err) assert.Contains(t, out, "Parse diff: 0 files re-parsed (all agents)") assert.Contains(t, out, "Summary") assert.Contains(t, out, "Examined") assert.Contains(t, out, "0 sessions changed, 0 identical.") assert.NotContains(t, out, "Changed fields") assert.NotContains(t, out, "Changed sessions") } func TestParseDiff_JSONShape(t *testing.T) { isolateParseDiffEnv(t) out, err := executeCommand(newRootCommand(), "parse-diff", "--json") require.NoError(t, err) var got map[string]any require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout should be valid JSON: %q", out) for _, key := range []string{ "generated_at", "data_version", "agents", "files_examined", "files_limited", "totals", "field_counts", "sessions", } { assert.Contains(t, got, key, "JSON report missing top-level key %q", key) } assert.NotEmpty(t, got["generated_at"]) } func TestDoParseDiff_FailOnChangeFalseOnEmptyArchive(t *testing.T) { isolateParseDiffEnv(t) var buf bytes.Buffer failed := doParseDiff(ParseDiffConfig{ FailOnChange: true, Stdout: &buf, Stderr: &buf, }) assert.False(t, failed, "empty archive must not trip --fail-on-change") assert.Contains(t, buf.String(), "0 sessions changed, 0 identical.") } // TestParseDiffExitFailure pins the --fail-on-change exit contract, // including the rule that a vacuous run (data version ahead of the whole // archive) is a gate failure, not a passing vet, with an explanation on // stderr that also reaches --json callers. func TestParseDiffExitFailure(t *testing.T) { tests := []struct { name string totals sync.ParseDiffTotals failOnChange bool wantFail bool wantStderr bool }{ { name: "flag off never fails", totals: sync.ParseDiffTotals{Examined: 5, PendingResync: 5}, failOnChange: false, }, { name: "identical passes", totals: sync.ParseDiffTotals{Examined: 5, Identical: 5}, failOnChange: true, }, { name: "real change fails without stderr note", totals: sync.ParseDiffTotals{Examined: 5, Identical: 4, Changed: 1}, failOnChange: true, wantFail: true, }, { name: "parse error fails without stderr note", totals: sync.ParseDiffTotals{Examined: 1, Identical: 1, ParseErrors: 1}, failOnChange: true, wantFail: true, }, { name: "raced sessions alone do not fail", totals: sync.ParseDiffTotals{ Examined: 3, Identical: 2, Raced: 1, }, failOnChange: true, }, { name: "a real change still fails alongside raced sessions", totals: sync.ParseDiffTotals{ Examined: 4, Identical: 2, Changed: 1, Raced: 1, }, failOnChange: true, wantFail: true, }, { name: "vacuous run fails with stderr note", totals: sync.ParseDiffTotals{Examined: 5, PendingResync: 5}, failOnChange: true, wantFail: true, wantStderr: true, }, { name: "partial pending resync is not vacuous", totals: sync.ParseDiffTotals{ Examined: 5, Identical: 1, PendingResync: 4, }, failOnChange: true, }, { name: "no examined sessions is not vacuous", totals: sync.ParseDiffTotals{}, failOnChange: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { r := &sync.ParseDiffReport{Totals: tc.totals} var stderr bytes.Buffer got := parseDiffExitFailure(r, tc.failOnChange, &stderr) assert.Equal(t, tc.wantFail, got, "exit failure") if tc.wantStderr { assert.Contains(t, stderr.String(), "--fail-on-change failed: the run was vacuous", "vacuous run must explain the non-zero exit on stderr") } else { assert.Empty(t, stderr.String(), "only a vacuous run writes to stderr") } }) } } // changedSession builds a DiffChanged SessionDiff fixture. func changedSession( agent, id string, fields ...sync.FieldDiff, ) sync.SessionDiff { return sync.SessionDiff{ SessionID: id, Agent: agent, Class: sync.DiffChanged, Fields: fields, } } // skewSession builds a DiffIncrementalSkew SessionDiff fixture. func skewSession( agent, id string, fields ...sync.FieldDiff, ) sync.SessionDiff { return sync.SessionDiff{ SessionID: id, Agent: agent, Class: sync.DiffIncrementalSkew, Fields: fields, } } func TestRenderParseDiffReport_ChangedSessions(t *testing.T) { r := &sync.ParseDiffReport{ GeneratedAt: "2026-06-12T00:00:00Z", DataVersion: 42, Agents: []string{"claude", "codex"}, FilesExamined: 7, Totals: sync.ParseDiffTotals{ Examined: 5, Identical: 3, Changed: 2, }, FieldCounts: map[string]int{ sync.FieldMessageCount: 2, sync.FieldModels: 1, }, Sessions: []sync.SessionDiff{ changedSession("claude", "abcdef1234567890", sync.FieldDiff{ Field: sync.FieldMessageCount, Stored: "10", Parsed: "11", }, sync.FieldDiff{ Field: sync.FieldModels, Stored: "opus", Parsed: "opus, sonnet", }, ), changedSession("codex", "ffff00001111", sync.FieldDiff{ Field: sync.FieldMessageCount, Stored: "4", Parsed: "5", }, ), { SessionID: "skip-me", Agent: "claude", Class: sync.DiffSkipped, Reason: "source missing", }, }, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "/tmp/sessions.db", "claude, codex", false) out := buf.String() assert.Contains(t, out, "Parse diff: 7 files re-parsed (claude, codex) "+ "against /tmp/sessions.db (data version 42)") assert.Contains(t, out, "Changed fields (sessions affected)") assert.Contains(t, out, "Changed sessions") assert.Contains(t, out, "abcdef12", "changed session should be listed by short id") assert.Contains(t, out, "message_count, models") assert.Contains(t, out, "2 sessions changed, 3 identical.") assert.NotContains(t, out, "skip-me", "skipped sessions must not appear in the changed list") assert.NotContains(t, out, "more; use --verbose", "no cap notice when under the cap") } func TestRenderParseDiffReport_FieldCountOrdering(t *testing.T) { r := &sync.ParseDiffReport{ FieldCounts: map[string]int{ "alpha_field": 2, "beta_field": 2, "big_field": 9, }, Totals: sync.ParseDiffTotals{Changed: 9, Examined: 9}, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() big := strings.Index(out, "big_field") alpha := strings.Index(out, "alpha_field") beta := strings.Index(out, "beta_field") require.NotEqual(t, -1, big) require.NotEqual(t, -1, alpha) require.NotEqual(t, -1, beta) assert.Less(t, big, alpha, "highest count must sort first") assert.Less(t, alpha, beta, "ties must break alphabetically") } func TestRenderParseDiffReport_CapAndVerbose(t *testing.T) { // IDs stay at 7 chars so shortID does not truncate them and // each compact line keeps a distinguishable id. var sessions []sync.SessionDiff for i := range parseDiffChangedCap + 5 { sessions = append(sessions, changedSession( "claude", fmt.Sprintf("sess-%02d", i), sync.FieldDiff{ Field: sync.FieldFirstMessage, Stored: "old", Parsed: "new", Detail: "lengths 3 vs 3", }, sync.FieldDiff{ Field: sync.FieldTerminationStatus, Stored: "completed", Parsed: "(null)", Informational: true, }, )) } r := &sync.ParseDiffReport{ Totals: sync.ParseDiffTotals{ Examined: len(sessions), Changed: len(sessions), }, Sessions: sessions, } t.Run("capped", func(t *testing.T) { var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.Contains(t, out, "... (5 more; use --verbose or --json)") assert.Contains(t, out, "sess-00") assert.Contains(t, out, fmt.Sprintf("sess-%02d", parseDiffChangedCap-1)) assert.NotContains(t, out, fmt.Sprintf("sess-%02d", parseDiffChangedCap), "sessions past the cap must be elided") assert.NotContains(t, out, "[informational]", "compact lines summarize non-informational fields only") }) t.Run("verbose", func(t *testing.T) { var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", true) out := buf.String() assert.NotContains(t, out, "more; use --verbose", "verbose output is never capped") assert.Contains(t, out, fmt.Sprintf("sess-%02d", parseDiffChangedCap+4), "verbose lists every changed session") assert.Contains(t, out, "first_message: old -> new (lengths 3 vs 3)") assert.Contains(t, out, "termination_status: completed -> (null) [informational]") }) } func TestRenderParseDiffReport_IncrementalSkewCapAndVerbose(t *testing.T) { var sessions []sync.SessionDiff for i := range parseDiffChangedCap + 5 { sessions = append(sessions, skewSession( "claude", fmt.Sprintf("skew-%02d", i), sync.FieldDiff{ Field: sync.FieldFirstMessage, Stored: "old", Parsed: "new", }, )) } r := &sync.ParseDiffReport{ Totals: sync.ParseDiffTotals{ Examined: len(sessions), IncrementalSkew: len(sessions), }, Sessions: sessions, } t.Run("capped", func(t *testing.T) { var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.Contains(t, out, "Incremental-skew sessions", "the skew drill-down section must be present") assert.Contains(t, out, "... (5 more; use --verbose or --json)") assert.Contains(t, out, "skew-00") assert.Contains(t, out, fmt.Sprintf("skew-%02d", parseDiffChangedCap-1)) assert.NotContains(t, out, fmt.Sprintf("skew-%02d", parseDiffChangedCap), "sessions past the cap must be elided") assert.Contains(t, out, "Incremental skew", "the summary must include the incremental-skew line") }) t.Run("verbose", func(t *testing.T) { var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", true) out := buf.String() assert.NotContains(t, out, "more; use --verbose", "verbose output is never capped") assert.Contains(t, out, fmt.Sprintf("skew-%02d", parseDiffChangedCap+4), "verbose lists every skew session") assert.Contains(t, out, "first_message: old -> new") }) } // TestRenderParseDiffReport_ResyncNote pins the resync-baseline // recommendation: the note appears only when the report carries // incremental-skew sessions, and is absent otherwise. func TestRenderParseDiffReport_ResyncNote(t *testing.T) { t.Run("note present when incremental skew > 0", func(t *testing.T) { r := &sync.ParseDiffReport{ Totals: sync.ParseDiffTotals{ Examined: 2, Identical: 1, IncrementalSkew: 1, }, Sessions: []sync.SessionDiff{ skewSession("claude", "skew-1", sync.FieldDiff{ Field: sync.FieldFirstMessage, Stored: "a", Parsed: "b", }), }, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.Contains(t, out, "1 session(s) were last written through the "+ "incremental-append path", "the resync note must report the skew count") assert.Contains(t, out, "Run a full resync", "the resync note must recommend a resync") }) t.Run("note absent without incremental skew", func(t *testing.T) { r := &sync.ParseDiffReport{ Totals: sync.ParseDiffTotals{ Examined: 2, Identical: 1, Changed: 1, }, FieldCounts: map[string]int{sync.FieldFirstMessage: 1}, Sessions: []sync.SessionDiff{ changedSession("claude", "chg-1", sync.FieldDiff{ Field: sync.FieldFirstMessage, Stored: "a", Parsed: "b", }), }, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.NotContains(t, out, "incremental-append path", "the resync note must not appear without skew sessions") }) } func TestRenderParseDiffReport_EmptyReport(t *testing.T) { r := &sync.ParseDiffReport{ DataVersion: 7, FieldCounts: map[string]int{}, } var buf bytes.Buffer require.NotPanics(t, func() { renderParseDiffReport(&buf, r, "/data/sessions.db", "all agents", false) }) out := buf.String() assert.Contains(t, out, "Parse diff: 0 files re-parsed (all agents) "+ "against /data/sessions.db (data version 7)") assert.Contains(t, out, "Examined") assert.NotContains(t, out, "Changed fields") assert.NotContains(t, out, "Changed sessions") assert.Contains(t, out, "0 sessions changed, 0 identical.") } func TestRenderParseDiffReport_NonZeroTotalsOnly(t *testing.T) { r := &sync.ParseDiffReport{ FilesLimited: true, Totals: sync.ParseDiffTotals{ Examined: 4, Identical: 3, Changed: 1, ParseErrors: 2, }, FieldCounts: map[string]int{}, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() for _, want := range []string{ "Examined", "Identical", "Changed", "Parse errors", "--limit truncated discovery", } { assert.Contains(t, out, want) } for _, unwanted := range []string{ "Pending resync", "New on disk", "Skipped", "Excluded by parser", "Needs retry", "Informational only", } { assert.NotContains(t, out, unwanted, "zero totals must not render a summary line") } } func TestRenderParseDiffReport_ParseErrorsListed(t *testing.T) { r := &sync.ParseDiffReport{ DataVersion: 39, Totals: sync.ParseDiffTotals{ParseErrors: 2}, FieldCounts: map[string]int{}, Sessions: []sync.SessionDiff{ { SessionID: "broken-1", Agent: "claude", FilePath: "/data/proj/broken-1.jsonl", Class: sync.DiffParseError, Reason: "unexpected end of JSON input", }, { Agent: "gemini", FilePath: "/data/proj/headless.json", Class: sync.DiffParseError, Reason: "invalid character '}'", }, }, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.Contains(t, out, "Parse errors") assert.Contains(t, out, "/data/proj/broken-1.jsonl", "parse-error file path must be shown, not just the count") assert.Contains(t, out, "unexpected end of JSON input", "parse-error reason must be shown") assert.Contains(t, out, "/data/proj/headless.json") assert.Contains(t, out, "invalid character") } // TestRenderParseDiffReport_SanitizesControlSequences proves the // human renderer strips terminal control bytes from every // session-derived value (agents, IDs, paths, field values, details, // parse-error reasons). Session files control these strings, so // without sanitization a crafted session could emit OSC 52 clipboard // writes, OSC 8 phishing hyperlinks, or cursor movement on a plain // `parse-diff --verbose` run. func TestRenderParseDiffReport_SanitizesControlSequences(t *testing.T) { r := &sync.ParseDiffReport{ DataVersion: 42, Totals: sync.ParseDiffTotals{ Examined: 1, Changed: 1, ParseErrors: 1, }, FieldCounts: map[string]int{ sync.FieldFirstMessage + "\x1b[31m": 1, }, Sessions: []sync.SessionDiff{ changedSession( "claude\x1b[31m", "evil\x1b]0;title\x07session", sync.FieldDiff{ Field: sync.FieldFirstMessage, Stored: "safe\x1b]52;c;ZXZpbA==\x07clip", Parsed: "new\x1b[2K\rvalue", Detail: "detail\x1b[1;31mred", }, ), { Agent: "gemini", FilePath: "/data/evil\x1b[8mhidden.jsonl", Class: sync.DiffParseError, Reason: "bad\x1b]8;;https://evil.example\x07link", }, }, } for _, verbose := range []bool{false, true} { var buf bytes.Buffer renderParseDiffReport(&buf, r, "/tmp/db\x1b[5m", "all agents", verbose) out := buf.String() assert.NotContains(t, out, "\x1b", "verbose=%v output must contain no ESC bytes", verbose) assert.NotContains(t, out, "\x07", "verbose=%v output must contain no BEL bytes", verbose) assert.NotContains(t, out, "\r", "verbose=%v output must contain no carriage returns", verbose) // The text around the stripped sequences must survive. assert.Contains(t, out, "link", "reason text retained") assert.Contains(t, out, "hidden.jsonl", "path text retained") } var verbose bytes.Buffer renderParseDiffReport(&verbose, r, "db", "all agents", true) out := verbose.String() assert.Contains(t, out, "clip", "stored value text retained") assert.Contains(t, out, "value", "parsed value text retained") assert.Contains(t, out, "red", "detail text retained") } func TestRenderParseDiffReport_VacuousResyncWarning(t *testing.T) { r := &sync.ParseDiffReport{ DataVersion: 40, Totals: sync.ParseDiffTotals{ Examined: 5, PendingResync: 5, }, FieldCounts: map[string]int{}, } var buf bytes.Buffer renderParseDiffReport(&buf, r, "db", "all agents", false) out := buf.String() assert.Contains(t, out, "Warning:") assert.Contains(t, out, "data version is ahead", "vacuous run must warn that no drift can be detected") // A run with at least one comparable session is not vacuous. r.Totals = sync.ParseDiffTotals{Examined: 5, Identical: 1, PendingResync: 4} var buf2 bytes.Buffer renderParseDiffReport(&buf2, r, "db", "all agents", false) assert.NotContains(t, buf2.String(), "Warning:", "a run with comparable sessions must not warn") } func TestRenderParseDiffReport_PendingResyncVerbose(t *testing.T) { r := &sync.ParseDiffReport{ DataVersion: 40, Totals: sync.ParseDiffTotals{Examined: 1, PendingResync: 1}, FieldCounts: map[string]int{}, Sessions: []sync.SessionDiff{{ SessionID: "stale-1", Agent: "claude", Class: sync.DiffPendingResync, StoredDataVersion: 38, Fields: []sync.FieldDiff{{ Field: sync.FieldFirstMessage, Stored: "old", Parsed: "new", }}, }}, } var plain bytes.Buffer renderParseDiffReport(&plain, r, "db", "all agents", false) assert.NotContains(t, plain.String(), "stale-1", "pending-resync drill-down is verbose-only") var verbose bytes.Buffer renderParseDiffReport(&verbose, r, "db", "all agents", true) out := verbose.String() assert.Contains(t, out, "Pending-resync sessions") assert.Contains(t, out, "stale-1") assert.Contains(t, out, "first_message: old -> new") } func TestParseDiff_JSONSessionsAndDBPath(t *testing.T) { isolateParseDiffEnv(t) out, err := executeCommand(newRootCommand(), "parse-diff", "--json") require.NoError(t, err) var got map[string]json.RawMessage require.NoError(t, json.Unmarshal([]byte(out), &got)) // A clean run must serialize an empty array, never null, so jq // pipelines and typed consumers do not break. require.Contains(t, got, "sessions") assert.Equal(t, "[]", strings.TrimSpace(string(got["sessions"])), "clean run must emit sessions: [] not null") // The archive identity must be present so the report is // self-describing when attached to a PR. require.Contains(t, got, "db_path") var dbPath string require.NoError(t, json.Unmarshal(got["db_path"], &dbPath)) assert.NotEmpty(t, dbPath, "db_path must identify the vetted archive") } // TestDoParseDiff_FailOnChangeDirections exercises both directions of // the exit-code conjunction (cfg.FailOnChange && report.HasFailures()). // A stored session at the current data version whose source file no // longer emits it is a presence change, so HasFailures() is true; the // flag then decides the exit. Staging it via a valid source file plus a // phantom stored row keeps the test independent of the parser's session // ID derivation. func TestDoParseDiff_FailOnChangeDirections(t *testing.T) { // Isolate every other agent's directory env var to a temp path so an // inherited dir from the developer or CI environment cannot be scanned and // trip --fail-on-change with an unrelated parse error. The data dir and // Claude dir are then overridden to the paths this test controls. isolateParseDiffEnv(t) dataDir := os.Getenv("AGENTSVIEW_DATA_DIR") require.NotEmpty(t, dataDir) claudeDir := t.TempDir() t.Setenv("CLAUDE_PROJECTS_DIR", claudeDir) // A valid Claude source file so discovery and parse succeed. projDir := filepath.Join(claudeDir, "-home-proj") require.NoError(t, os.MkdirAll(projDir, 0o755)) srcPath := filepath.Join(projDir, "real-session.jsonl") content := testjsonl.NewSessionBuilder(). AddClaudeUser("2026-01-01T00:00:00Z", "hello"). AddClaudeAssistant("2026-01-01T00:00:01Z", "hi"). String() require.NoError(t, os.WriteFile(srcPath, []byte(content), 0o644)) // A phantom stored row under that file at the current data version: // the re-parse never emits this id, so it reports as a presence // change (HasFailures() == true). d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")) require.NoError(t, d.UpsertSession(db.Session{ ID: "phantom-session", Project: "proj", Machine: "m", Agent: "claude", MessageCount: 4, UserMessageCount: 2, FilePath: &srcPath, })) require.NoError(t, d.SetSessionDataVersion("phantom-session", db.CurrentDataVersion())) require.NoError(t, d.Close()) var failBuf bytes.Buffer failed := doParseDiff(ParseDiffConfig{ FailOnChange: true, Stdout: &failBuf, Stderr: &failBuf, }) assert.True(t, failed, "a presence change with --fail-on-change must fail") assert.Contains(t, failBuf.String(), "sessions changed") var cleanBuf bytes.Buffer notFailed := doParseDiff(ParseDiffConfig{ FailOnChange: false, Stdout: &cleanBuf, Stderr: &cleanBuf, }) assert.False(t, notFailed, "without --fail-on-change the same drift must not fail") } // TestDoParseDiff_RacedSessionDoesNotFail is the end-to-end exit-code // proof of the live-write skew guard: a stored row whose source file // advanced past its snapshot file_mtime mid-run is reclassified raced, // so --fail-on-change exits clean even though the content diverged. // Staged through a real sync so the row's id, file_path, and file_mtime // are exactly what the parser derives, then drifted and the source mtime // pushed forward to simulate a daemon write after the snapshot. func TestDoParseDiff_RacedSessionDoesNotFail(t *testing.T) { // Isolate every other agent's directory env var to a temp path so an // inherited dir from the developer or CI environment cannot be scanned and // trip --fail-on-change with an unrelated parse error. The data dir and // Claude dir are then overridden to the paths this test controls. isolateParseDiffEnv(t) dataDir := t.TempDir() t.Setenv("AGENTSVIEW_DATA_DIR", dataDir) claudeDir := t.TempDir() t.Setenv("CLAUDE_PROJECTS_DIR", claudeDir) projDir := filepath.Join(claudeDir, "-home-proj") require.NoError(t, os.MkdirAll(projDir, 0o755)) srcPath := filepath.Join(projDir, "raced-session.jsonl") content := testjsonl.NewSessionBuilder(). AddClaudeUser("2026-01-01T00:00:00Z", "hello"). AddClaudeAssistant("2026-01-01T00:00:01Z", "hi"). String() require.NoError(t, os.WriteFile(srcPath, []byte(content), 0o644)) dbPath := filepath.Join(dataDir, "sessions.db") d := dbtest.OpenTestDBAt(t, dbPath) engine := sync.NewEngine(d, sync.EngineConfig{ AgentDirs: map[parser.AgentType][]string{ parser.AgentClaude: {claudeDir}, }, Machine: "local", }) stats := engine.SyncAll(context.Background(), nil) require.Equal(t, 1, stats.Synced, "one session synced") // Find the synced session id so the drift targets the real row. rows, err := d.ListSessionsModifiedBetween( context.Background(), "", "", nil, nil, ) require.NoError(t, err) require.Len(t, rows, 1, "exactly one stored session") sessionID := rows[0].ID // Drift the stored row so a fresh parse reports a real change, then // push the source mtime past the recorded snapshot file_mtime. require.NoError(t, d.Update(func(tx *sql.Tx) error { _, uerr := tx.Exec( "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", sessionID, ) return uerr })) require.NoError(t, d.Close()) future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes(srcPath, future, future), "advance source mtime past the snapshot") var racedBuf bytes.Buffer racedFailed := doParseDiff(ParseDiffConfig{ FailOnChange: true, Stdout: &racedBuf, Stderr: &racedBuf, }) assert.False(t, racedFailed, "a raced session must not trip --fail-on-change") assert.Contains(t, racedBuf.String(), "Raced", "the summary must surface the raced session") } // TestDoParseDiff_UntouchedDriftStillFails is the negative control for // the skew guard: the same staged drift WITHOUT advancing the source // mtime stays a genuine change and trips --fail-on-change, proving the // guard never masks a real regression on an untouched file. func TestDoParseDiff_UntouchedDriftStillFails(t *testing.T) { // Isolate every other agent's directory env var to a temp path so an // inherited dir from the developer or CI environment cannot be scanned and // trip --fail-on-change with an unrelated parse error. The data dir and // Claude dir are then overridden to the paths this test controls. isolateParseDiffEnv(t) dataDir := t.TempDir() t.Setenv("AGENTSVIEW_DATA_DIR", dataDir) claudeDir := t.TempDir() t.Setenv("CLAUDE_PROJECTS_DIR", claudeDir) projDir := filepath.Join(claudeDir, "-home-proj") require.NoError(t, os.MkdirAll(projDir, 0o755)) srcPath := filepath.Join(projDir, "drift-session.jsonl") content := testjsonl.NewSessionBuilder(). AddClaudeUser("2026-01-01T00:00:00Z", "hello"). AddClaudeAssistant("2026-01-01T00:00:01Z", "hi"). String() require.NoError(t, os.WriteFile(srcPath, []byte(content), 0o644)) dbPath := filepath.Join(dataDir, "sessions.db") d := dbtest.OpenTestDBAt(t, dbPath) engine := sync.NewEngine(d, sync.EngineConfig{ AgentDirs: map[parser.AgentType][]string{ parser.AgentClaude: {claudeDir}, }, Machine: "local", }) stats := engine.SyncAll(context.Background(), nil) require.Equal(t, 1, stats.Synced, "one session synced") rows, err := d.ListSessionsModifiedBetween( context.Background(), "", "", nil, nil, ) require.NoError(t, err) require.Len(t, rows, 1) sessionID := rows[0].ID require.NoError(t, d.Update(func(tx *sql.Tx) error { _, uerr := tx.Exec( "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", sessionID, ) return uerr })) require.NoError(t, d.Close()) // Source mtime is left untouched: the change is genuine drift. var buf bytes.Buffer failed := doParseDiff(ParseDiffConfig{ FailOnChange: true, Stdout: &buf, Stderr: &buf, }) assert.True(t, failed, "untouched-source drift must still trip --fail-on-change") assert.Contains(t, buf.String(), "sessions changed") }