Adds an optional `imagen generate` post-step that opens a sibling tmux window running tmux-img --hold <path>. - internal/preview: Mode (auto|on|off), Resolve, and a Spawner that shells out to tmux new-window. Typed errors for missing tmux, missing tmux-img, and "preview forced on outside $TMUX". - cmd/imagen/generate: --preview / --no-preview flags plus $IMAGEN_PREVIEW. Resolution chain: config -> env -> flag. auto requires both stdout-is-tty and $TMUX. Failures are warnings - the image is already on disk. - internal/config: output.preview field, validated to auto|on|off, threaded into the sample. - Tests for ParseMode, Resolve, Spawn argv (incl. shell quoting of paths with apostrophes), missing-binary errors, and the CLI resolution table. - Docs (usage + architecture) updated. /imagine SKILL.md edit lives in dotfiles - deferred to coordinate with #4.
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
package preview
|
|
|
|
import (
|
|
"errors"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseMode(t *testing.T) {
|
|
cases := map[string]Mode{
|
|
"": ModeAuto,
|
|
"auto": ModeAuto,
|
|
"AUTO": ModeAuto,
|
|
"on": ModeOn,
|
|
" on ": ModeOn,
|
|
"off": ModeOff,
|
|
}
|
|
for in, want := range cases {
|
|
got, err := ParseMode(in)
|
|
if err != nil {
|
|
t.Errorf("ParseMode(%q) err = %v", in, err)
|
|
continue
|
|
}
|
|
if got != want {
|
|
t.Errorf("ParseMode(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
if _, err := ParseMode("nope"); err == nil {
|
|
t.Errorf("ParseMode(nope) should have errored")
|
|
}
|
|
}
|
|
|
|
func TestResolve(t *testing.T) {
|
|
type tc struct {
|
|
mode Mode
|
|
inTmux bool
|
|
stdoutTTY bool
|
|
want bool
|
|
wantErr error
|
|
}
|
|
cases := map[string]tc{
|
|
"off-anywhere": {ModeOff, false, false, false, nil},
|
|
"off-in-tmux-tty": {ModeOff, true, true, false, nil},
|
|
"on-in-tmux": {ModeOn, true, false, true, nil},
|
|
"on-outside-tmux-errs": {ModeOn, false, true, false, ErrNoTmuxForced},
|
|
"auto-no-tmux": {ModeAuto, false, true, false, nil},
|
|
"auto-tmux-no-tty": {ModeAuto, true, false, false, nil},
|
|
"auto-tmux-and-tty": {ModeAuto, true, true, true, nil},
|
|
}
|
|
for name, c := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
d, err := Resolve(c.mode, c.inTmux, c.stdoutTTY)
|
|
if c.wantErr != nil {
|
|
if !errors.Is(err, c.wantErr) {
|
|
t.Fatalf("err = %v, want %v", err, c.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if d.ShouldPreview != c.want {
|
|
t.Errorf("ShouldPreview = %v, want %v (reason: %s)", d.ShouldPreview, c.want, d.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSpawn_BuildsCorrectCommand(t *testing.T) {
|
|
var captured *exec.Cmd
|
|
s := &Spawner{
|
|
LookPath: func(name string) (string, error) {
|
|
switch name {
|
|
case "tmux":
|
|
return "/usr/bin/tmux", nil
|
|
case "tmux-img":
|
|
return "/home/m/.local/bin/tmux-img", nil
|
|
}
|
|
return "", exec.ErrNotFound
|
|
},
|
|
Run: func(c *exec.Cmd) error {
|
|
captured = c
|
|
return nil
|
|
},
|
|
}
|
|
if err := s.Spawn("/tmp/imagen/cat.png", "cat-in-a-fishbowl"); err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
if captured == nil {
|
|
t.Fatal("Run was not called")
|
|
}
|
|
if captured.Path != "/usr/bin/tmux" {
|
|
t.Errorf("Path = %q, want /usr/bin/tmux", captured.Path)
|
|
}
|
|
args := captured.Args
|
|
if len(args) < 6 {
|
|
t.Fatalf("args = %v (need at least 6)", args)
|
|
}
|
|
// tmux new-window -d -n img:<slug> '<shell-cmd>'
|
|
if args[1] != "new-window" {
|
|
t.Errorf("args[1] = %q, want new-window", args[1])
|
|
}
|
|
if args[2] != "-d" {
|
|
t.Errorf("args[2] = %q, want -d", args[2])
|
|
}
|
|
if args[3] != "-n" {
|
|
t.Errorf("args[3] = %q, want -n", args[3])
|
|
}
|
|
if args[4] != "img:cat-in-a-fishbowl" {
|
|
t.Errorf("args[4] = %q, want img:cat-in-a-fishbowl", args[4])
|
|
}
|
|
shellCmd := args[5]
|
|
if !strings.Contains(shellCmd, "tmux-img") || !strings.Contains(shellCmd, "--hold") || !strings.Contains(shellCmd, "/tmp/imagen/cat.png") {
|
|
t.Errorf("shell cmd %q missing expected pieces", shellCmd)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_PathWithSpacesAndQuotes(t *testing.T) {
|
|
var captured *exec.Cmd
|
|
s := &Spawner{
|
|
LookPath: func(name string) (string, error) {
|
|
if name == "tmux" {
|
|
return "/usr/bin/tmux", nil
|
|
}
|
|
if name == "tmux-img" {
|
|
return "/usr/local/bin/tmux-img", nil
|
|
}
|
|
return "", exec.ErrNotFound
|
|
},
|
|
Run: func(c *exec.Cmd) error { captured = c; return nil },
|
|
}
|
|
weird := "/tmp/imagen/o'malley's cat.png"
|
|
if err := s.Spawn(weird, "slug"); err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
shellCmd := captured.Args[5]
|
|
// Single-quoted with the embedded apostrophe escaped via the
|
|
// '\'' shell idiom — confirm we did not just splice the raw path.
|
|
if strings.Contains(shellCmd, "o'malley's") {
|
|
t.Errorf("shell cmd %q contains unescaped apostrophes", shellCmd)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_MissingTmux(t *testing.T) {
|
|
s := &Spawner{
|
|
LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
|
|
Run: func(*exec.Cmd) error { return nil },
|
|
}
|
|
err := s.Spawn("/x.png", "s")
|
|
if !errors.Is(err, ErrTmuxMissing) {
|
|
t.Errorf("err = %v, want ErrTmuxMissing", err)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_MissingTmuxImg(t *testing.T) {
|
|
s := &Spawner{
|
|
LookPath: func(name string) (string, error) {
|
|
if name == "tmux" {
|
|
return "/usr/bin/tmux", nil
|
|
}
|
|
return "", exec.ErrNotFound
|
|
},
|
|
Run: func(*exec.Cmd) error { return nil },
|
|
}
|
|
err := s.Spawn("/x.png", "s")
|
|
if !errors.Is(err, ErrTmuxImgMissing) {
|
|
t.Errorf("err = %v, want ErrTmuxImgMissing", err)
|
|
}
|
|
}
|