1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/patriceckhart/zot/packages/provider"
13 "github.com/patriceckhart/zot/packages/tui"
14)
15
16func timelineTestData() timelineData {
17 started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
18 finished := started.Add(1250 * time.Millisecond)
19 return timelineData{
20 System: "You are zot.",
21 Tools: []provider.Tool{{
22 Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
23 }},
24 Messages: []provider.Message{
25 {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
26 {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
27 provider.TextBlock{Text: "I will inspect them."},
28 provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
29 }},
30 {Role: provider.RoleTool, Time: finished, Content: []provider.Content{
31 provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
32 }},
33 },
34 ContextUsed: 1200,
35 ContextMax: 200000,
36 }
37}
38
39func TestSessionTimelineOpensAlternateView(t *testing.T) {
40 interactive := &Interactive{timeline: newTimelineView()}
41 interactive.runSlash(context.Background(), "/session timeline")
42 if !interactive.timeline.Active() {
43 t.Fatal("/session timeline did not open the timeline view")
44 }
45}
46
47func TestSessionPickerIncludesTimeline(t *testing.T) {
48 interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
49 interactive.openSessionOpsDialog()
50 if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
51 t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
52 }
53}
54
55func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
56 entries := buildTimelineEntries(timelineTestData())
57 if len(entries) != 4 {
58 t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
59 }
60 tool := entries[3]
61 if tool.Kind != "tool" || tool.ToolCall != "call-1" {
62 t.Fatalf("tool entry = %+v", tool)
63 }
64 if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
65 t.Fatalf("tool details missing: %+v", tool)
66 }
67 if !strings.Contains(tool.Schema, "command") {
68 t.Fatalf("tool schema missing: %q", tool.Schema)
69 }
70 if tool.Duration != "1.25s" {
71 t.Fatalf("duration = %q, want 1.25s", tool.Duration)
72 }
73}
74
75func TestTimelineSearchAndTabs(t *testing.T) {
76 data := timelineTestData()
77 view := newTimelineView()
78 view.Open(data)
79 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
80 for _, r := range "readme" {
81 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
82 }
83 matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
84 if len(matches) != 1 || matches[0].Kind != "tool" {
85 t.Fatalf("matches = %+v, want tool result", matches)
86 }
87 view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
88 if view.tab != 1 {
89 t.Fatalf("tab = %d, want payload tab", view.tab)
90 }
91 view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
92 if view.filter != "" || !view.Active() {
93 t.Fatal("first esc should clear search without closing timeline")
94 }
95}
96
97func TestTimelineRenderShowsContextAndControls(t *testing.T) {
98 data := timelineTestData()
99 if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
100 t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
101 }
102 view := newTimelineView()
103 view.Open(data)
104 rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
105 for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
106 if !strings.Contains(rendered, want) {
107 t.Fatalf("render missing %q:\n%s", want, rendered)
108 }
109 }
110}
111
112func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
113 data := timelineTestData()
114 data.Messages = append(data.Messages, provider.Message{
115 Role: provider.RoleUser,
116 Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
117 })
118 view := newTimelineView()
119 path, err := view.Export(t.TempDir(), data)
120 if err != nil {
121 t.Fatal(err)
122 }
123 contents, err := os.ReadFile(path)
124 if err != nil {
125 t.Fatal(err)
126 }
127 if strings.Contains(string(contents), "secret-image-bytes") {
128 t.Fatal("export embedded image data")
129 }
130 if !strings.Contains(string(contents), "image/png") {
131 t.Fatal("export omitted image metadata")
132 }
133 info, err := os.Stat(path)
134 if err != nil {
135 t.Fatal(err)
136 }
137 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
138 t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
139 }
140}