Merge mai/hermes/issue-5-imagen-5-tmux: tmux-window preview for generate (#5)
This commit is contained in:
@@ -11,21 +11,24 @@ import (
|
||||
"mgit.msbls.de/m/ImaGen/internal/backend"
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
"mgit.msbls.de/m/ImaGen/internal/output"
|
||||
"mgit.msbls.de/m/ImaGen/internal/preview"
|
||||
"mgit.msbls.de/m/ImaGen/internal/prompt"
|
||||
)
|
||||
|
||||
func runGenerate(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
var (
|
||||
backendName string
|
||||
size string
|
||||
outPath string
|
||||
seed int64
|
||||
steps int
|
||||
style string
|
||||
negative string
|
||||
configPath string
|
||||
noSidecar bool
|
||||
backendName string
|
||||
size string
|
||||
outPath string
|
||||
seed int64
|
||||
steps int
|
||||
style string
|
||||
negative string
|
||||
configPath string
|
||||
noSidecar bool
|
||||
previewOn bool
|
||||
previewOff bool
|
||||
)
|
||||
fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)")
|
||||
fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024")
|
||||
@@ -36,6 +39,8 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
fs.StringVar(&negative, "negative", "", "negative prompt (ignored by backends that don't support it)")
|
||||
fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)")
|
||||
fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it")
|
||||
fs.BoolVar(&previewOn, "preview", false, "force tmux preview window on (errors outside $TMUX)")
|
||||
fs.BoolVar(&previewOff, "no-preview", false, "skip the tmux preview window")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(fs.Output(), `Usage: imagen generate "<prompt>" [flags]`)
|
||||
fs.PrintDefaults()
|
||||
@@ -118,9 +123,70 @@ func runGenerate(ctx context.Context, args []string) error {
|
||||
if paths.SidecarPath != "" {
|
||||
fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath)
|
||||
}
|
||||
|
||||
if err := maybePreview(cfg, previewOn, previewOff, paths.ImagePath, rawPrompt); err != nil {
|
||||
// preview failures are warnings — the image already wrote.
|
||||
fmt.Fprintln(os.Stderr, "imagen: preview:", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePreviewMode applies the precedence chain config -> env -> flag.
|
||||
// Flags win, env beats config, config beats the implicit auto default.
|
||||
func resolvePreviewMode(cfg *config.Config, flagOn, flagOff bool, env string) (preview.Mode, error) {
|
||||
mode := preview.ModeAuto
|
||||
if cfg != nil && cfg.Output.Preview != "" {
|
||||
m, err := preview.ParseMode(cfg.Output.Preview)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("config output.preview: %w", err)
|
||||
}
|
||||
mode = m
|
||||
}
|
||||
if env != "" {
|
||||
m, err := preview.ParseMode(env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("$IMAGEN_PREVIEW: %w", err)
|
||||
}
|
||||
mode = m
|
||||
}
|
||||
if flagOn && flagOff {
|
||||
return "", userErr("--preview and --no-preview are mutually exclusive")
|
||||
}
|
||||
if flagOn {
|
||||
mode = preview.ModeOn
|
||||
}
|
||||
if flagOff {
|
||||
mode = preview.ModeOff
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// maybePreview resolves the effective preview mode and, if it says yes,
|
||||
// spawns a tmux window via tmux-img. Always non-fatal.
|
||||
func maybePreview(cfg *config.Config, flagOn, flagOff bool, imagePath, rawPrompt string) error {
|
||||
mode, err := resolvePreviewMode(cfg, flagOn, flagOff, os.Getenv("IMAGEN_PREVIEW"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decision, err := preview.Resolve(mode, os.Getenv("TMUX") != "", stdoutIsTTY())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !decision.ShouldPreview {
|
||||
return nil
|
||||
}
|
||||
spawner := &preview.Spawner{}
|
||||
return spawner.Spawn(imagePath, output.Slug(rawPrompt))
|
||||
}
|
||||
|
||||
func stdoutIsTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// splitLeadingPositional separates the positional args at the start of args
|
||||
// from the rest (which begins with the first flag). A literal "--" terminator
|
||||
// pushes everything after it into the positional list and out of flag parsing.
|
||||
|
||||
50
cmd/imagen/generate_test.go
Normal file
50
cmd/imagen/generate_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/ImaGen/internal/config"
|
||||
"mgit.msbls.de/m/ImaGen/internal/preview"
|
||||
)
|
||||
|
||||
func TestResolvePreviewMode(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
flagOn bool
|
||||
flagOff bool
|
||||
env string
|
||||
want preview.Mode
|
||||
wantError bool
|
||||
}
|
||||
cases := []tc{
|
||||
{name: "all-empty-defaults-to-auto", want: preview.ModeAuto},
|
||||
{name: "config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, want: preview.ModeOn},
|
||||
{name: "config-off", cfg: &config.Config{Output: config.OutputConfig{Preview: "off"}}, want: preview.ModeOff},
|
||||
{name: "config-auto-explicit", cfg: &config.Config{Output: config.OutputConfig{Preview: "auto"}}, want: preview.ModeAuto},
|
||||
{name: "env-overrides-config", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, env: "off", want: preview.ModeOff},
|
||||
{name: "flag-on-overrides-env-off", env: "off", flagOn: true, want: preview.ModeOn},
|
||||
{name: "flag-off-overrides-env-on", env: "on", flagOff: true, want: preview.ModeOff},
|
||||
{name: "flag-off-overrides-config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, flagOff: true, want: preview.ModeOff},
|
||||
{name: "both-flags-error", flagOn: true, flagOff: true, wantError: true},
|
||||
{name: "bad-env-errors", env: "yes", wantError: true},
|
||||
{name: "bad-config-errors", cfg: &config.Config{Output: config.OutputConfig{Preview: "yes"}}, wantError: true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := resolvePreviewMode(c.cfg, c.flagOn, c.flagOff, c.env)
|
||||
if c.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got mode %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("mode = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ upstream API. Each adapter only ever sees its own slice of `imagen.yaml`.
|
||||
│ internal/prompt │ style preset → prompt suffix
|
||||
│ internal/output │ filename templating, sidecar
|
||||
│ internal/config │ YAML loader, validation
|
||||
│ internal/preview │ tmux-img window spawner
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
┌──────────▼────────────┐
|
||||
|
||||
@@ -24,8 +24,28 @@ imagen version print version
|
||||
| `--negative` | empty | Negative prompt (ignored by some adapters) |
|
||||
| `--output` | empty (= use naming template) | Explicit path |
|
||||
| `--no-sidecar` | `false` | Skip the JSON sidecar even if config enables it |
|
||||
| `--preview` | (auto) | Force open a tmux preview window via `tmux-img` |
|
||||
| `--no-preview` | (auto) | Suppress the preview window (use for batch / CI callers) |
|
||||
| `--config` | `~/.config/imagen.yaml` | Override config path |
|
||||
|
||||
### Preview window
|
||||
|
||||
After a successful generate, imagen optionally opens a sibling tmux window
|
||||
named `img:<slug>` running `tmux-img --hold <path>`. The new window is
|
||||
spawned in the background (`tmux new-window -d`) so the generating pane
|
||||
keeps focus and its terminal output.
|
||||
|
||||
Resolution order is **config → `$IMAGEN_PREVIEW` → flag** (later wins):
|
||||
|
||||
- `output.preview` in `imagen.yaml`: `auto` (default) | `on` | `off`
|
||||
- `IMAGEN_PREVIEW=auto|on|off` overrides config
|
||||
- `--preview` / `--no-preview` override env
|
||||
|
||||
`auto` previews iff stdout is a TTY *and* `$TMUX` is set. `on` previews
|
||||
unconditionally and errors outside a tmux session. `off` never previews.
|
||||
|
||||
Preview failures are non-fatal — the image already wrote.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
|
||||
@@ -19,11 +19,16 @@ type Config struct {
|
||||
Backends map[string]BackendSpec `yaml:"backends"`
|
||||
}
|
||||
|
||||
// OutputConfig controls where generated images and metadata sidecars land.
|
||||
// OutputConfig controls where generated images and metadata sidecars land,
|
||||
// and whether `imagen generate` opens a tmux preview window.
|
||||
type OutputConfig struct {
|
||||
Directory string `yaml:"directory"`
|
||||
Naming string `yaml:"naming"`
|
||||
WriteMetadataJSON bool `yaml:"write_metadata_json"`
|
||||
// Preview is the tri-state preview mode: "auto" (default), "on", "off".
|
||||
// Empty / unset is treated as "auto". $IMAGEN_PREVIEW and the
|
||||
// --preview/--no-preview flags override this in turn.
|
||||
Preview string `yaml:"preview"`
|
||||
}
|
||||
|
||||
// BackendSpec is one entry under `backends:`. Type identifies the adapter;
|
||||
@@ -78,6 +83,11 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("default_backend %q is not defined under backends:", c.DefaultBackend)
|
||||
}
|
||||
}
|
||||
switch c.Output.Preview {
|
||||
case "", "auto", "on", "off":
|
||||
default:
|
||||
return fmt.Errorf("output.preview = %q (must be auto|on|off)", c.Output.Preview)
|
||||
}
|
||||
for name, spec := range c.Backends {
|
||||
if name == "" {
|
||||
return errors.New("empty backend name")
|
||||
@@ -101,6 +111,11 @@ output:
|
||||
directory: ~/Pictures/imagen
|
||||
naming: "{date}-{slug}-{seed}.png"
|
||||
write_metadata_json: true
|
||||
# Open a tmux window with tmux-img after a successful generation.
|
||||
# auto (default): preview iff stdout is a TTY and $TMUX is set.
|
||||
# on: always preview (errors outside a tmux session).
|
||||
# off: never preview (use this for batch / CI callers).
|
||||
preview: auto
|
||||
|
||||
backends:
|
||||
flux-schnell-local:
|
||||
|
||||
@@ -60,6 +60,34 @@ func TestValidateRejectsMissingType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePreviewMode(t *testing.T) {
|
||||
for _, mode := range []string{"", "auto", "on", "off"} {
|
||||
c := &Config{Output: OutputConfig{Preview: mode}}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Errorf("preview=%q: unexpected error %v", mode, err)
|
||||
}
|
||||
}
|
||||
bad := &Config{Output: OutputConfig{Preview: "yes"}}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Errorf("expected error for invalid preview value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleParsesPreviewAuto(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "imagen.yaml")
|
||||
if err := os.WriteFile(path, []byte(Sample), 0o644); err != nil {
|
||||
t.Fatalf("write sample: %v", err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Output.Preview != "auto" {
|
||||
t.Errorf("Output.Preview = %q, want auto", cfg.Output.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
cases := map[string]string{
|
||||
|
||||
119
internal/preview/tmux.go
Normal file
119
internal/preview/tmux.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package preview opens a tmux window showing a generated image via tmux-img.
|
||||
// Mode resolution and the actual spawn are kept separate so the CLI can
|
||||
// decide-then-act and tests can drive each half independently.
|
||||
package preview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Mode is the tri-state preview setting: auto (default), on (force), off.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeAuto Mode = "auto"
|
||||
ModeOn Mode = "on"
|
||||
ModeOff Mode = "off"
|
||||
)
|
||||
|
||||
// ParseMode normalises a string into a Mode. Empty parses to ModeAuto so
|
||||
// callers can pass through unset config / env values.
|
||||
func ParseMode(s string) (Mode, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "auto":
|
||||
return ModeAuto, nil
|
||||
case "on":
|
||||
return ModeOn, nil
|
||||
case "off":
|
||||
return ModeOff, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid preview mode %q (auto|on|off)", s)
|
||||
}
|
||||
|
||||
// Decision is the answer to "should we preview, and why".
|
||||
type Decision struct {
|
||||
ShouldPreview bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Resolve maps (mode, runtime context) to a Decision.
|
||||
//
|
||||
// - off -> never preview
|
||||
// - on -> preview, but error if not in tmux (forced on outside tmux)
|
||||
// - auto -> preview iff inTmux && stdoutTTY
|
||||
func Resolve(mode Mode, inTmux, stdoutTTY bool) (Decision, error) {
|
||||
switch mode {
|
||||
case ModeOff:
|
||||
return Decision{ShouldPreview: false, Reason: "preview=off"}, nil
|
||||
case ModeOn:
|
||||
if !inTmux {
|
||||
return Decision{}, ErrNoTmuxForced
|
||||
}
|
||||
return Decision{ShouldPreview: true, Reason: "preview=on"}, nil
|
||||
case ModeAuto, "":
|
||||
if !inTmux {
|
||||
return Decision{ShouldPreview: false, Reason: "auto: $TMUX unset"}, nil
|
||||
}
|
||||
if !stdoutTTY {
|
||||
return Decision{ShouldPreview: false, Reason: "auto: stdout not a tty"}, nil
|
||||
}
|
||||
return Decision{ShouldPreview: true, Reason: "auto"}, nil
|
||||
}
|
||||
return Decision{}, fmt.Errorf("invalid preview mode %q", mode)
|
||||
}
|
||||
|
||||
// Errors returned by Spawn and Resolve. Each names the missing piece and,
|
||||
// where relevant, where to install it.
|
||||
var (
|
||||
ErrTmuxMissing = errors.New("tmux: binary not found on $PATH (required for image preview)")
|
||||
ErrTmuxImgMissing = errors.New("tmux-img: binary not found on $PATH (install at ~/.local/bin/tmux-img)")
|
||||
ErrNoTmuxForced = errors.New("--preview requires $TMUX (are you in a tmux session?)")
|
||||
)
|
||||
|
||||
// Spawner spawns the tmux preview window. The exec.LookPath / cmd.Run hooks
|
||||
// exist so tests can inject fakes without touching $PATH.
|
||||
type Spawner struct {
|
||||
LookPath func(string) (string, error)
|
||||
Run func(*exec.Cmd) error
|
||||
}
|
||||
|
||||
// Spawn opens a new tmux window named img:<slug> running tmux-img --hold
|
||||
// <imagePath>. -d keeps focus in the current pane. Caller is expected to
|
||||
// have already verified that we are inside a tmux session.
|
||||
func (s *Spawner) Spawn(imagePath, slug string) error {
|
||||
look := s.LookPath
|
||||
if look == nil {
|
||||
look = exec.LookPath
|
||||
}
|
||||
run := s.Run
|
||||
if run == nil {
|
||||
run = func(c *exec.Cmd) error { return c.Run() }
|
||||
}
|
||||
|
||||
tmuxBin, err := look("tmux")
|
||||
if err != nil {
|
||||
return ErrTmuxMissing
|
||||
}
|
||||
tmuxImgBin, err := look("tmux-img")
|
||||
if err != nil {
|
||||
return ErrTmuxImgMissing
|
||||
}
|
||||
|
||||
name := "img:" + slug
|
||||
shellCmd := fmt.Sprintf("%s --hold %s",
|
||||
shellQuote(tmuxImgBin), shellQuote(imagePath))
|
||||
cmd := exec.Command(tmuxBin, "new-window", "-d", "-n", name, shellCmd)
|
||||
if err := run(cmd); err != nil {
|
||||
return fmt.Errorf("tmux new-window: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote single-quotes s for /bin/sh — tmux passes the trailing arg of
|
||||
// new-window through a shell.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
170
internal/preview/tmux_test.go
Normal file
170
internal/preview/tmux_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user