okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: ctrl-click a path in the transcript to open it in an editorcc6ab84

main
4.9 KiB145 linesraw
1// Editor failures must not lose saved edits or leave prompt files behind,
2// and a clicked row must name the right file and line in every way a path is
3// written. The daemon E2E driver does not run the prompt editor, and E2E
4// clicks one row of each kind rather than every spelling of a path.
5package tui
6
7import (
8	"errors"
9	"io"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"strings"
14	"testing"
15)
16
17type restorationWriter struct {
18	err   error
19	short bool
20}
21
22func (writer restorationWriter) Write(data []byte) (int, error) {
23	if writer.err != nil || writer.short {
24		return 0, writer.err
25	}
26	return len(data), nil
27}
28
29func TestPromptEditorPreservesEditsAndCleansUp(t *testing.T) {
30	shell, err := exec.LookPath("sh")
31	if err != nil {
32		t.Skip("shell unavailable")
33	}
34	restoreFailure := errors.New("output unavailable")
35	for _, scenario := range []struct {
36		name, script, text string
37		writer             restorationWriter
38		wantRestore        error
39		wantEdited         bool
40		wantExit           bool
41	}{
42		{"success", `printf '%s' "$1" >&2; printf 'edited' > "$1"`, "edited", restorationWriter{}, nil, true, false},
43		{"saved edits", `printf '%s' "$1" >&2; printf 'edited' > "$1"`, "edited", restorationWriter{err: restoreFailure}, restoreFailure, true, false},
44		{"empty edits", `printf '%s' "$1" >&2; : > "$1"`, "", restorationWriter{err: restoreFailure}, restoreFailure, true, false},
45		{"short write", `printf '%s' "$1" >&2; printf 'edited' > "$1"`, "edited", restorationWriter{short: true}, io.ErrShortWrite, true, false},
46		{"execution and restoration", `printf '%s' "$1" >&2; exit 7`, "", restorationWriter{err: restoreFailure}, restoreFailure, false, true},
47	} {
48		t.Run(scenario.name, func(t *testing.T) {
49			t.Setenv("TMPDIR", t.TempDir())
50			process := &promptEditorProcess{
51				editorProcess: editorProcess{Cmd: exec.Command(shell, "-c", scenario.script, "editor")},
52				prompt:        "original",
53			}
54			var path strings.Builder
55			process.SetStderr(&path)
56			process.SetStdout(scenario.writer)
57			err := process.Run()
58			if !errors.Is(err, scenario.wantRestore) {
59				t.Fatalf("restoration error lost: %v", err)
60			}
61			_, hasExit := errors.AsType[*exec.ExitError](err)
62			if hasExit != scenario.wantExit {
63				t.Fatalf("execution error lost: %v", err)
64			}
65			if process.edited != scenario.wantEdited || process.text != scenario.text {
66				t.Fatalf("edited=%v text=%q", process.edited, process.text)
67			}
68			if path.String() == "" {
69				t.Fatal("editor did not receive a temporary file")
70			}
71			if _, err := os.Stat(path.String()); !errors.Is(err, os.ErrNotExist) {
72				t.Fatalf("temporary file survived: %v", err)
73			}
74		})
75	}
76}
77
78func TestEditorCompletionPreservesTextAndReportsRestorationFailure(t *testing.T) {
79	for _, scenario := range []struct {
80		name, text string
81		stale      bool
82	}{
83		{"saved edits", "edited", false},
84		{"empty edits", "", false},
85		{"stale completion", "edited", true},
86	} {
87		t.Run(scenario.name, func(t *testing.T) {
88			model := composer(t, 80, 24)
89			model.TextArea.SetValue("original")
90			message := ChatEditorFinishedMsg{
91				SessionID: model.SessionID, Generation: model.Generation,
92				Text: scenario.text, Edited: true, Err: errors.New("restore failed"),
93			}
94			if scenario.stale {
95				message.Generation++
96			}
97			model, _ = model.Update(message)
98			wantText := scenario.text
99			if scenario.stale {
100				wantText = "original"
101			}
102			if model.TextArea.Value() != wantText || model.Notices.HasError() == scenario.stale {
103				t.Fatalf("text=%q, error notice=%v", model.TextArea.Value(), model.Notices.HasError())
104			}
105		})
106	}
107}
108
109func TestFileTargetAtReadsEverySpellingOfAPath(t *testing.T) {
110	ws := t.TempDir()
111	for _, name := range []string{"main.go", "sub/util.go"} {
112		path := filepath.Join(ws, name)
113		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
114			t.Fatal(err)
115		}
116		if err := os.WriteFile(path, nil, 0o644); err != nil {
117			t.Fatal(err)
118		}
119	}
120	main, util := filepath.Join(ws, "main.go"), filepath.Join(ws, "sub", "util.go")
121	for _, c := range []struct {
122		line string
123		col  int
124		want FileTarget // zero when nothing opens
125	}{
126		{"error at main.go:42:15: failed", 12, FileTarget{main, 42, 15}},
127		{"main.go:42:", 0, FileTarget{main, 42, 0}},
128		{`files.read(path="sub/util.go")`, 20, FileTarget{util, 0, 0}},
129		{"see `sub/util.go#L10`.", 8, FileTarget{util, 10, 0}},
130		{"+++ b/sub/util.go", 8, FileTarget{util, 0, 0}},
131		{"file://" + main + ":3", 10, FileTarget{main, 3, 0}},
132		{"compare main.go with sub/util.go", 10, FileTarget{main, 0, 0}},
133		{"compare main.go with sub/util.go", 25, FileTarget{util, 0, 0}},
134		{"compare main.go with sub/util.go", 0, FileTarget{}},
135		{"I updated sub/util.go here", 0, FileTarget{util, 0, 0}},
136		{"wide 漢字 main.go", 10, FileTarget{main, 0, 0}},
137		{"see https://example.com/main.go", 10, FileTarget{}},
138		{"missing.go and plain words", 2, FileTarget{}},
139	} {
140		got, _ := fileTargetAt(c.line, c.col, ws)
141		if got != c.want {
142			t.Errorf("%q at %d: got %+v, want %+v", c.line, c.col, got, c.want)
143		}
144	}
145}