package attach import ( "bytes" "encoding/base64" "errors" "image" "image/color" "image/png" "os" "path/filepath" "strings" "testing" "codey/internal/llm" ) // tinyPNG is a real one-pixel PNG, encoded rather than pasted in as a literal // so the magic bytes under test are the ones a library actually writes. func tinyPNG(t *testing.T) []byte { t.Helper() im := image.NewRGBA(image.Rect(0, 0, 1, 1)) im.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) var b bytes.Buffer if err := png.Encode(&b, im); err != nil { t.Fatal(err) } return b.Bytes() } func TestSniffKnowsTheFourFormats(t *testing.T) { cases := []struct { name string data []byte want string }{ {"png", tinyPNG(t), "image/png"}, {"jpeg", []byte{0xff, 0xd8, 0xff, 0xe0, 0}, "image/jpeg"}, {"gif", []byte("GIF89a....."), "image/gif"}, {"webp", []byte("RIFF\x00\x00\x00\x00WEBPVP8 "), "image/webp"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok := Sniff(c.data) if !ok || got != c.want { t.Errorf("Sniff = %q, %v; want %q, true", got, ok, c.want) } }) } } // Text on the clipboard is the ordinary case for ctrl-V, and it must not come // back looking like a broken image. func TestSniffRejectsNonImages(t *testing.T) { for _, s := range []string{"", "hello", "", "%PDF-1.4"} { if got, ok := Sniff([]byte(s)); ok { t.Errorf("Sniff(%q) = %q, true; want false", s, got) } } } // An extension is a claim, not evidence: the bytes decide. func TestNewRejectsAMislabelledFile(t *testing.T) { _, err := New([]byte("this is just text"), "notes.png") if err == nil { t.Fatal("text passed as an image") } if errors.Is(err, ErrTooBig) { t.Errorf("wrong complaint: %v", err) } } func TestNewRefusesAnOversizeImage(t *testing.T) { big := append(tinyPNG(t), make([]byte, MaxBytes)...) _, err := New(big, "huge.png") if !errors.Is(err, ErrTooBig) { t.Fatalf("err = %v; want ErrTooBig", err) } // The message has to say how big, because the next move is to shrink it. if !strings.Contains(err.Error(), "MB") { t.Errorf("message does not give a size: %v", err) } } func TestBlockCarriesBase64OfTheBytes(t *testing.T) { data := tinyPNG(t) im, err := New(data, "x.png") if err != nil { t.Fatal(err) } blk := im.Block() if blk.Type != llm.BlockImage || blk.Source == nil { t.Fatalf("block = %+v", blk) } if blk.Source.Type != "base64" || blk.Source.MediaType != "image/png" { t.Errorf("source = %+v", blk.Source) } got, err := base64.StdEncoding.DecodeString(blk.Source.Data) if err != nil || !bytes.Equal(got, data) { t.Errorf("data did not survive encoding: %v", err) } } // ---- paths -------------------------------------------------------------- func TestPathAcceptsWhatATerminalPastes(t *testing.T) { dir := t.TempDir() plain := filepath.Join(dir, "shot.png") spaced := filepath.Join(dir, "my holiday.png") for _, p := range []string{plain, spaced} { if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } } cases := []struct { name string paste string want string }{ {"bare", plain, plain}, {"trailing newline", plain + "\n", plain}, {"quoted", `"` + spaced + `"`, spaced}, {"shell escaped", strings.ReplaceAll(spaced, " ", `\ `), spaced}, {"file url", "file://" + strings.ReplaceAll(spaced, " ", "%20"), spaced}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, ok := Path(c.paste) if !ok || got != c.want { t.Errorf("Path(%q) = %q, %v; want %q, true", c.paste, got, ok, c.want) } }) } } // Text that merely mentions a file is text. Turning it into an attachment // would silently eat what somebody typed. func TestPathDeclinesOrdinaryText(t *testing.T) { dir := t.TempDir() real := filepath.Join(dir, "shot.png") if err := os.WriteFile(real, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } for _, s := range []string{ "look at " + real, // a sentence, not a path real + "\nand the other one", // more than one line filepath.Join(dir, "missing.png"), // nothing there filepath.Join(dir, "notes.txt"), // not an image dir, // a directory "", } { if got, ok := Path(s); ok { t.Errorf("Path(%q) = %q, true; want false", s, got) } } } func TestFromFileReadsAnImage(t *testing.T) { p := filepath.Join(t.TempDir(), "shot.png") if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } im, err := FromFile(p) if err != nil { t.Fatal(err) } if im.Media != "image/png" || im.Origin != "shot.png" { t.Errorf("image = %q from %q", im.Media, im.Origin) } } // ---- markers ------------------------------------------------------------ func fill(t *testing.T, s *Set, n int) { t.Helper() for i := 0; i < n; i++ { im, err := New(tinyPNG(t), "clipboard") if err != nil { t.Fatal(err) } if got := s.Add(im); got != i+1 { t.Fatalf("Add returned %d, want %d", got, i+1) } } } // kinds renders a block list as a shape — "text,image,text" — because what // matters about the expansion is the order. func kinds(blocks []llm.ContentBlock) string { var out []string for _, b := range blocks { out = append(out, b.Type) } return strings.Join(out, ",") } func TestBlocksPutsThePictureWhereTheMarkerStood(t *testing.T) { var s Set fill(t, &s, 1) blocks := s.Blocks("here is the bug [image 1] can you see it") if got := kinds(blocks); got != "text,image,text" { t.Fatalf("shape = %s", got) } if blocks[0].Text != "here is the bug" { t.Errorf("leading text = %q", blocks[0].Text) } if blocks[2].Text != "can you see it" { t.Errorf("trailing text = %q", blocks[2].Text) } } func TestBlocksKeepsTwoImagesInOrder(t *testing.T) { var s Set fill(t, &s, 2) // Deliberately out of order in the text: the marker says which is which, // not the position. blocks := s.Blocks("after [image 2] before [image 1]") if got := kinds(blocks); got != "text,image,text,image" { t.Fatalf("shape = %s", got) } if blocks[1].Source.Data == "" || blocks[3].Source.Data == "" { t.Error("an image block lost its data") } } // Deleting the marker is the undo. The image it named must not be sent // anyway, tacked on somewhere the model cannot place it. func TestBlocksDropsAnImageWhoseMarkerWasDeleted(t *testing.T) { var s Set fill(t, &s, 2) blocks := s.Blocks("only this one please [image 2]") if got := kinds(blocks); got != "text,image" { t.Fatalf("shape = %s", got) } } // A number nobody pasted is just something typed, and typing it should reach // the model as written. func TestBlocksLeavesAnUnknownMarkerAsText(t *testing.T) { var s Set fill(t, &s, 1) blocks := s.Blocks("what does [image 7] mean") if got := kinds(blocks); got != "text" { t.Fatalf("shape = %s", got) } if blocks[0].Text != "what does [image 7] mean" { t.Errorf("text = %q", blocks[0].Text) } } func TestBlocksGivesAWordlessMessageWords(t *testing.T) { var s Set fill(t, &s, 1) blocks := s.Blocks("[image 1]") if got := kinds(blocks); got != "image,text" { t.Fatalf("shape = %s", got) } if strings.TrimSpace(blocks[1].Text) == "" { t.Error("the message has no text at all") } } func TestBlocksOfPlainTextIsOneTextBlock(t *testing.T) { var s Set blocks := s.Blocks("just a question") if got := kinds(blocks); got != "text" { t.Fatalf("shape = %s", got) } } func TestResetEmptiesTheSet(t *testing.T) { var s Set fill(t, &s, 2) s.Reset() if s.Len() != 0 { t.Fatalf("Len = %d after Reset", s.Len()) } // Numbering starts over with the next prompt. fill(t, &s, 1) } func TestMarkerReads(t *testing.T) { if got := Marker(3); got != "[image 3]" { t.Errorf("Marker(3) = %q", got) } } // ---- paths left in the prompt ------------------------------------------- // The case this exists for: a terminal that does not bracket its pastes drops // the path in as if it had been typed, and nothing catches it until the // prompt is sent. func TestExpandPathsTakesAPastedPath(t *testing.T) { p := filepath.Join(t.TempDir(), "clip.png") if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } var s Set got := s.ExpandPaths("what is wrong here " + p) if got != "what is wrong here [image 1]" { t.Fatalf("text = %q", got) } if s.Len() != 1 { t.Fatalf("%d images attached", s.Len()) } } // Terminals hand over a dropped or copied file quoted. The quotes go with the // path, not into the sentence. func TestExpandPathsTakesAQuotedPathAlone(t *testing.T) { p := filepath.Join(t.TempDir(), "clip.png") if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } var s Set if got := s.ExpandPaths(`"` + p + `"`); got != "[image 1]" { t.Fatalf("text = %q", got) } } func TestExpandPathsTakesTwo(t *testing.T) { dir := t.TempDir() var paths []string for _, n := range []string{"a.png", "b.png"} { p := filepath.Join(dir, n) if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } paths = append(paths, p) } var s Set got := s.ExpandPaths("compare " + paths[0] + " with " + paths[1]) if got != "compare [image 1] with [image 2]" { t.Fatalf("text = %q", got) } if s.Len() != 2 { t.Fatalf("%d images attached", s.Len()) } } // A prompt about the code must come through untouched. This is the risk the // feature carries, so it is the test that matters most. func TestExpandPathsLeavesOrdinaryPromptsAlone(t *testing.T) { dir := t.TempDir() missing := filepath.Join(dir, "gone.png") source := filepath.Join(dir, "model.go") if err := os.WriteFile(source, []byte("package tui"), 0o644); err != nil { t.Fatal(err) } for _, prompt := range []string{ "rename shot.png to screenshot.png", "why does " + source + " not compile", "the file " + missing + " is missing", "look at internal/tui/model.go:44", "no paths at all here", } { var s Set if got := s.ExpandPaths(prompt); got != prompt { t.Errorf("ExpandPaths(%q) = %q", prompt, got) } if s.Len() != 0 { t.Errorf("%q attached %d images", prompt, s.Len()) } } } // A marker already in the text is left as it is: the paste key put it there // and its picture is already attached. func TestExpandPathsLeavesExistingMarkers(t *testing.T) { var s Set fill(t, &s, 1) if got := s.ExpandPaths("look at [image 1] closely"); got != "look at [image 1] closely" { t.Fatalf("text = %q", got) } if s.Len() != 1 { t.Fatalf("%d images after expanding", s.Len()) } } // The same file named twice is one picture, and both mentions point at it. func TestExpandPathsMentionedTwiceAttachesOnce(t *testing.T) { p := filepath.Join(t.TempDir(), "clip.png") if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { t.Fatal(err) } var s Set got := s.ExpandPaths(p + " and again " + p) if s.Len() != 1 { t.Fatalf("%d images attached, want 1", s.Len()) } if got != "[image 1] and again [image 1]" { t.Fatalf("text = %q", got) } }