output_iframe_test.go

  1package claudetool
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12func TestOutputIframeRun(t *testing.T) {
 13	// Create a temp directory for test files
 14	tmpDir := t.TempDir()
 15
 16	// Create test HTML files
 17	htmlFile := filepath.Join(tmpDir, "test.html")
 18	if err := os.WriteFile(htmlFile, []byte("<html><head></head><body><h1>Hello</h1></body></html>"), 0o644); err != nil {
 19		t.Fatal(err)
 20	}
 21
 22	chartFile := filepath.Join(tmpDir, "chart.html")
 23	if err := os.WriteFile(chartFile, []byte("<html><head></head><body><div>Chart</div></body></html>"), 0o644); err != nil {
 24		t.Fatal(err)
 25	}
 26
 27	// Create a data file
 28	dataFile := filepath.Join(tmpDir, "data.json")
 29	if err := os.WriteFile(dataFile, []byte(`{"values": [1, 2, 3]}`), 0o644); err != nil {
 30		t.Fatal(err)
 31	}
 32
 33	// Create a CSS file
 34	cssFile := filepath.Join(tmpDir, "styles.css")
 35	if err := os.WriteFile(cssFile, []byte("body { color: red; }"), 0o644); err != nil {
 36		t.Fatal(err)
 37	}
 38
 39	workingDir := &MutableWorkingDir{}
 40	workingDir.Set(tmpDir)
 41
 42	tool := &OutputIframeTool{WorkingDir: workingDir}
 43
 44	tests := []struct {
 45		name         string
 46		input        map[string]any
 47		wantErr      bool
 48		wantTitle    string
 49		wantFilename string
 50		wantFiles    int
 51		checkHTML    func(html string) bool
 52	}{
 53		{
 54			name: "basic html file",
 55			input: map[string]any{
 56				"path": "test.html",
 57			},
 58			wantErr:      false,
 59			wantTitle:    "",
 60			wantFilename: "test.html",
 61			wantFiles:    0,
 62		},
 63		{
 64			name: "html with title",
 65			input: map[string]any{
 66				"path":  "chart.html",
 67				"title": "My Chart",
 68			},
 69			wantErr:      false,
 70			wantTitle:    "My Chart",
 71			wantFilename: "chart.html",
 72			wantFiles:    0,
 73		},
 74		{
 75			name: "html with data file",
 76			input: map[string]any{
 77				"path":  "chart.html",
 78				"title": "Chart with Data",
 79				"files": map[string]any{
 80					"data.json": "data.json",
 81				},
 82			},
 83			wantErr:      false,
 84			wantTitle:    "Chart with Data",
 85			wantFilename: "chart.html",
 86			wantFiles:    1,
 87			checkHTML: func(html string) bool {
 88				return strings.Contains(html, "window.__FILES__") &&
 89					strings.Contains(html, "data.json")
 90			},
 91		},
 92		{
 93			name: "html with multiple files",
 94			input: map[string]any{
 95				"path":  "chart.html",
 96				"title": "Styled Chart",
 97				"files": map[string]any{
 98					"data.json":  "data.json",
 99					"styles.css": "styles.css",
100				},
101			},
102			wantErr:      false,
103			wantTitle:    "Styled Chart",
104			wantFilename: "chart.html",
105			wantFiles:    2,
106			checkHTML: func(html string) bool {
107				return strings.Contains(html, "window.__FILES__") &&
108					strings.Contains(html, "<style data-file=\"styles.css\">") &&
109					strings.Contains(html, "body { color: red; }")
110			},
111		},
112		{
113			name: "absolute path",
114			input: map[string]any{
115				"path": htmlFile,
116			},
117			wantErr:      false,
118			wantFilename: "test.html",
119			wantFiles:    0,
120		},
121		{
122			name: "empty path",
123			input: map[string]any{
124				"path": "",
125			},
126			wantErr: true,
127		},
128		{
129			name:    "missing path",
130			input:   map[string]any{},
131			wantErr: true,
132		},
133		{
134			name: "nonexistent file",
135			input: map[string]any{
136				"path": "nonexistent.html",
137			},
138			wantErr: true,
139		},
140		{
141			name: "nonexistent data file",
142			input: map[string]any{
143				"path": "chart.html",
144				"files": map[string]any{
145					"missing.json": "missing.json",
146				},
147			},
148			wantErr: true,
149		},
150	}
151
152	for _, tt := range tests {
153		t.Run(tt.name, func(t *testing.T) {
154			inputJSON, err := json.Marshal(tt.input)
155			if err != nil {
156				t.Fatalf("failed to marshal input: %v", err)
157			}
158
159			result := tool.Run(context.Background(), inputJSON)
160
161			if tt.wantErr {
162				if result.Error == nil {
163					t.Error("expected error, got nil")
164				}
165				return
166			}
167
168			if result.Error != nil {
169				t.Errorf("unexpected error: %v", result.Error)
170				return
171			}
172
173			if len(result.LLMContent) != 1 || result.LLMContent[0].Text != "displayed" {
174				t.Errorf("expected LLMContent [displayed], got %v", result.LLMContent)
175			}
176
177			display, ok := result.Display.(OutputIframeDisplay)
178			if !ok {
179				t.Errorf("expected Display to be OutputIframeDisplay, got %T", result.Display)
180				return
181			}
182
183			if display.Type != "output_iframe" {
184				t.Errorf("expected Type 'output_iframe', got %q", display.Type)
185			}
186
187			if display.Title != tt.wantTitle {
188				t.Errorf("expected Title %q, got %q", tt.wantTitle, display.Title)
189			}
190
191			if display.Filename != tt.wantFilename {
192				t.Errorf("expected Filename %q, got %q", tt.wantFilename, display.Filename)
193			}
194
195			if len(display.Files) != tt.wantFiles {
196				t.Errorf("expected %d files, got %d", tt.wantFiles, len(display.Files))
197			}
198
199			if tt.checkHTML != nil && !tt.checkHTML(display.HTML) {
200				t.Errorf("HTML check failed, got: %s", display.HTML)
201			}
202		})
203	}
204}
205
206func TestDetectFileType(t *testing.T) {
207	tests := []struct {
208		filename string
209		want     string
210	}{
211		{"data.json", "json"},
212		{"styles.css", "css"},
213		{"script.js", "js"},
214		{"data.csv", "csv"},
215		{"readme.txt", "text"},
216		{"unknown", "text"},
217		{"DATA.JSON", "json"}, // case insensitive
218	}
219
220	for _, tt := range tests {
221		t.Run(tt.filename, func(t *testing.T) {
222			got := detectFileType(tt.filename)
223			if got != tt.want {
224				t.Errorf("detectFileType(%q) = %q, want %q", tt.filename, got, tt.want)
225			}
226		})
227	}
228}
229
230func TestEscapeJSString(t *testing.T) {
231	tests := []struct {
232		input string
233		want  string
234	}{
235		{"hello", "hello"},
236		{"hello\nworld", "hello\\nworld"},
237		{`say "hi"`, `say \"hi\"`},
238		{"back\\slash", "back\\\\slash"},
239		{"tab\there", "tab\\there"},
240	}
241
242	for _, tt := range tests {
243		t.Run(tt.input, func(t *testing.T) {
244			got := escapeJSString(tt.input)
245			if got != tt.want {
246				t.Errorf("escapeJSString(%q) = %q, want %q", tt.input, got, tt.want)
247			}
248		})
249	}
250}
251
252func TestInjectFiles(t *testing.T) {
253	tests := []struct {
254		name     string
255		html     string
256		files    []EmbeddedFile
257		contains []string
258	}{
259		{
260			name:     "no files",
261			html:     "<html><head></head><body></body></html>",
262			files:    nil,
263			contains: []string{"<html><head></head><body></body></html>"},
264		},
265		{
266			name: "inject json file",
267			html: "<html><head></head><body></body></html>",
268			files: []EmbeddedFile{
269				{Name: "data.json", Content: `{"x": 1}`, Type: "json"},
270			},
271			contains: []string{"window.__FILES__", "data.json"},
272		},
273		{
274			name: "inject css file",
275			html: "<html><head></head><body></body></html>",
276			files: []EmbeddedFile{
277				{Name: "styles.css", Content: "body { color: red; }", Type: "css"},
278			},
279			contains: []string{"<style data-file=\"styles.css\">", "body { color: red; }"},
280		},
281		{
282			name: "inject js file",
283			html: "<html><head></head><body></body></html>",
284			files: []EmbeddedFile{
285				{Name: "app.js", Content: "console.log('hi');", Type: "js"},
286			},
287			contains: []string{"<script data-file=\"app.js\">", "console.log('hi');"},
288		},
289		{
290			name: "html without head tag",
291			html: "<html><body>content</body></html>",
292			files: []EmbeddedFile{
293				{Name: "data.json", Content: `{}`, Type: "json"},
294			},
295			contains: []string{"<head>", "</head>", "window.__FILES__"},
296		},
297		{
298			name: "plain html without tags",
299			html: "<div>content</div>",
300			files: []EmbeddedFile{
301				{Name: "data.json", Content: `{}`, Type: "json"},
302			},
303			contains: []string{"window.__FILES__", "<div>content</div>"},
304		},
305	}
306
307	for _, tt := range tests {
308		t.Run(tt.name, func(t *testing.T) {
309			result := injectFiles(tt.html, tt.files)
310			for _, s := range tt.contains {
311				if !strings.Contains(result, s) {
312					t.Errorf("expected result to contain %q, got: %s", s, result)
313				}
314			}
315		})
316	}
317}
318
319func TestOutputIframeToolSchema(t *testing.T) {
320	workingDir := &MutableWorkingDir{}
321	workingDir.Set("/tmp")
322	tool := &OutputIframeTool{WorkingDir: workingDir}
323	llmTool := tool.Tool()
324
325	if llmTool.Name != "output_iframe" {
326		t.Errorf("expected name 'output_iframe', got %q", llmTool.Name)
327	}
328
329	if llmTool.Run == nil {
330		t.Error("expected Run function to be set")
331	}
332
333	if len(llmTool.InputSchema) == 0 {
334		t.Error("expected InputSchema to be set")
335	}
336
337	// Verify schema contains files property
338	if !strings.Contains(string(llmTool.InputSchema), "files") {
339		t.Error("expected InputSchema to contain 'files' property")
340	}
341}