f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
1053 lines
33 KiB
Go
1053 lines
33 KiB
Go
// 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")
|
|
}
|