| 🛟 Updated. 28d5985 k33g 4h ago | 1 | package tools |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "codeberg.org/turbo-editors/turbo-core/profile" |
| 11 | ) |
| 12 | |
| 13 | // testProfile is a fictional editor. The template it carries is a small but |
| 14 | // real tools file, because Create's job is to write the profile's template and |
| 15 | // a test asserting on what is in it would only be reading its own fixture back. |
| 16 | func testProfile() profile.Profile { |
| 17 | return profile.Profile{ |
| 18 | Name: "Turbo Test", |
| 19 | Slug: "turbo-test", |
| 20 | Language: "Test", |
| 21 | ToolsMenu: "~T~est", |
| 22 | Templates: profile.Templates{Tools: testToolsTemplate}, |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | const testToolsTemplate = `# turbo-test tools. |
| 27 | |
| 28 | [[tool]] |
| 29 | name = "~B~uild" |
| 30 | command = "make build" |
| 31 | output = "popup" |
| 32 | |
| 33 | [[tool]] |
| 34 | name = "~R~un" |
| 35 | command = "make run" |
| 36 | output = "terminal" |
| 37 | ` |
| 38 | |
| 39 | // menu is the plain name of the toolchain menu the test profile uses. |
| 40 | var menu = DefaultMenuName(testProfile()) |
| 41 | |
| 42 | // project builds a project directory holding a tools file. |
| 43 | func project(t *testing.T, contents string) string { |
| 44 | t.Helper() |
| 45 | |
| 46 | dir := t.TempDir() |
| 47 | if contents == "" { |
| 48 | return dir |
| 49 | } |
| 50 | if err := os.MkdirAll(filepath.Dir(Path(testProfile(), dir)), 0o755); err != nil { |
| 51 | t.Fatalf("creating the tools directory: %v", err) |
| 52 | } |
| 53 | if err := os.WriteFile(Path(testProfile(), dir), []byte(contents), 0o644); err != nil { |
| 54 | t.Fatalf("writing the tools file: %v", err) |
| 55 | } |
| 56 | return dir |
| 57 | } |
| 58 | |
| 59 | // load reads a project's tools, failing the test if it cannot. |
| 60 | func load(t *testing.T, dir string) List { |
| 61 | t.Helper() |
| 62 | |
| 63 | list, err := Load(testProfile(), dir) |
| 64 | if err != nil { |
| 65 | t.Fatalf("Load(testProfile(), %q) error = %v", dir, err) |
| 66 | } |
| 67 | return list |
| 68 | } |
| 69 | |
| 70 | // summary renders the tools as "name=command" strings, in order. |
| 71 | func summary(list List) []string { |
| 72 | out := make([]string, 0, list.Len()) |
| 73 | for _, tool := range list.Tools() { |
| 74 | out = append(out, tool.Name+"="+tool.Command) |
| 75 | } |
| 76 | return out |
| 77 | } |
| 78 | |
| 79 | func TestLoadReadsTheToolsInFileOrder(t *testing.T) { |
| 80 | // The menu should match the file, so someone reordering the file sees the |
| 81 | // menu reorder. |
| 82 | dir := project(t, ` |
| 83 | [[tool]] |
| 84 | name = "Test" |
| 85 | command = "go test ./..." |
| 86 | |
| 87 | [[tool]] |
| 88 | name = "Build" |
| 89 | command = "go build ./..." |
| 90 | `) |
| 91 | |
| 92 | got := summary(load(t, dir)) |
| 93 | want := []string{"Test=go test ./...", "Build=go build ./..."} |
| 94 | if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { |
| 95 | t.Errorf("got %v, want %v", got, want) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestAProjectWithNoToolsFileIsNotAnError(t *testing.T) { |
| 100 | list := load(t, t.TempDir()) |
| 101 | |
| 102 | if list.Len() != 0 { |
| 103 | t.Errorf("Len() = %d, want 0", list.Len()) |
| 104 | } |
| 105 | if got := list.Tools(); got != nil { |
| 106 | t.Errorf("Tools() = %v, want nothing", got) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestAFileThatIsNotTOMLIsReported(t *testing.T) { |
| 111 | // A typo must be reported rather than silently leaving the menu empty. |
| 112 | dir := project(t, "[[tool]\nname = ") |
| 113 | |
| 114 | _, err := Load(testProfile(), dir) |
| 115 | if err == nil { |
| 116 | t.Fatal("Load() accepted a file that is not TOML") |
| 117 | } |
| 118 | if !strings.Contains(err.Error(), Path(testProfile(), dir)) { |
| 119 | t.Errorf("the error does not name the file: %v", err) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestAToolWithNoNameOrNoCommandIsRefused(t *testing.T) { |
| 124 | for name, contents := range map[string]string{ |
| 125 | "no name": "[[tool]]\ncommand = \"go build\"\n", |
| 126 | "no command": "[[tool]]\nname = \"Build\"\n", |
| 127 | } { |
| 128 | t.Run(name, func(t *testing.T) { |
| 129 | if _, err := Load(testProfile(), project(t, contents)); err == nil { |
| 130 | t.Errorf("Load() accepted a tool with %s", name) |
| 131 | } |
| 132 | }) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestACommandMayBeAWholeSequence(t *testing.T) { |
| 137 | // It goes to sh -c, so one entry can be several commands. |
| 138 | dir := project(t, "[[tool]]\nname = \"Check\"\ncommand = \"go vet ./... && go test ./...\"\n") |
| 139 | |
| 140 | list := load(t, dir) |
| 141 | if list.Len() != 1 { |
| 142 | t.Fatalf("Len() = %d", list.Len()) |
| 143 | } |
| 144 | if got := list.Tools()[0].Command; got != "go vet ./... && go test ./..." { |
| 145 | t.Errorf("Command = %q, want the sequence unchanged", got) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestADirectoryWhereTheFileGoesCountsAsAbsent(t *testing.T) { |
| 150 | dir := t.TempDir() |
| 151 | if err := os.MkdirAll(Path(testProfile(), dir), 0o755); err != nil { |
| 152 | t.Fatalf("creating a directory where the file goes: %v", err) |
| 153 | } |
| 154 | |
| 155 | if Exists(testProfile(), dir) { |
| 156 | t.Error("Exists() called a directory a tools file") |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestPathIsUnderTheEditorsOwnDirectory(t *testing.T) { |
| 161 | // The directory is the editor's, so two editors in one repository keep |
| 162 | // their tools apart rather than fighting over one file. |
| 163 | want := filepath.Join("/src/p", ".turbo-test", "tools.toml") |
| 164 | if got := Path(testProfile(), "/src/p"); got != want { |
| 165 | t.Errorf("Path() = %q, want %q", got, want) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestCreateWritesAFileThatLoadsBack(t *testing.T) { |
| 170 | dir := t.TempDir() |
| 171 | |
| 172 | path, err := Create(testProfile(), dir) |
| 173 | if err != nil { |
| 174 | t.Fatalf("Create() error = %v", err) |
| 175 | } |
| 176 | if path != Path(testProfile(), dir) { |
| 177 | t.Errorf("Create() returned %q, want %q", path, Path(testProfile(), dir)) |
| 178 | } |
| 179 | if !Exists(testProfile(), dir) { |
| 180 | t.Fatal("Create() reported success but wrote no file") |
| 181 | } |
| 182 | if load(t, dir).Len() == 0 { |
| 183 | t.Error("the created file holds no tools") |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestCreateRefusesToOverwriteAnExistingFile(t *testing.T) { |
| 188 | original := "[[tool]]\nname = \"Mine\"\ncommand = \"make\"\n" |
| 189 | dir := project(t, original) |
| 190 | |
| 191 | _, err := Create(testProfile(), dir) |
| 192 | if !errors.Is(err, ErrExists) { |
| 193 | t.Fatalf("Create() error = %v, want ErrExists", err) |
| 194 | } |
| 195 | if got := readFile(t, Path(testProfile(), dir)); got != original { |
| 196 | t.Errorf("the existing file was changed:\n%s", got) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // plain strips the tilde hot-key markers from a label. |
| 201 | func plain(label string) string { return strings.ReplaceAll(label, "~", "") } |
| 202 | |
| 203 | // hotKey returns the character between the tildes, or 0 when there is none. |
| 204 | func hotKey(label string) rune { |
| 205 | first := strings.IndexByte(label, '~') |
| 206 | if first < 0 || first+1 >= len(label) { |
| 207 | return 0 |
| 208 | } |
| 209 | return rune(label[first+1]) |
| 210 | } |
| 211 | |
| 212 | // readFile returns a file's contents. |
| 213 | func readFile(t *testing.T, path string) string { |
| 214 | t.Helper() |
| 215 | |
| 216 | data, err := os.ReadFile(path) |
| 217 | if err != nil { |
| 218 | t.Fatalf("reading %s: %v", path, err) |
| 219 | } |
| 220 | return string(data) |
| 221 | } |
| 222 | |
| 223 | func TestAToolWithNoOutputDefaultsToAPopup(t *testing.T) { |
| 224 | dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test ./...\"\n") |
| 225 | |
| 226 | tool := load(t, dir).Tools()[0] |
| 227 | if tool.Output != "" { |
| 228 | t.Errorf("Output = %q, want it left empty in the file", tool.Output) |
| 229 | } |
| 230 | if got := tool.Where(); got != OutputPopup { |
| 231 | t.Errorf("Where() = %q, want %q", got, OutputPopup) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestEveryOutputTheFileMayNameIsAccepted(t *testing.T) { |
| 236 | for _, output := range outputs { |
| 237 | t.Run(string(output), func(t *testing.T) { |
| 238 | dir := project(t, "[[tool]]\nname = \"X\"\ncommand = \"true\"\noutput = \""+string(output)+"\"\n") |
| 239 | |
| 240 | if got := load(t, dir).Tools()[0].Where(); got != output { |
| 241 | t.Errorf("Where() = %q, want %q", got, output) |
| 242 | } |
| 243 | }) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | func TestAnUnknownOutputIsRefusedRatherThanCorrected(t *testing.T) { |
| 248 | // Silently falling back would send the output somewhere the file did not |
| 249 | // ask for, and "termnial" would look like it worked. |
| 250 | dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"true\"\noutput = \"termnial\"\n") |
| 251 | |
| 252 | _, err := Load(testProfile(), dir) |
| 253 | if err == nil { |
| 254 | t.Fatal("Load() accepted an output nobody defined") |
| 255 | } |
| 256 | for _, want := range []string{"Test", "termnial", "popup", "terminal", "editor"} { |
| 257 | if !strings.Contains(err.Error(), want) { |
| 258 | t.Errorf("the error never mentions %q: %v", want, err) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestAToolWithNoMenuGoesIntoTheEditorsToolchainMenu(t *testing.T) { |
| 264 | dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test ./...\"\n") |
| 265 | |
| 266 | if got := load(t, dir).Tools()[0].Menu; got != menu { |
| 267 | t.Errorf("MenuName() = %q, want %q", got, menu) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestAToolCanNameItsOwnMenu(t *testing.T) { |
| 272 | dir := project(t, "[[tool]]\nname = \"Echo\"\ncommand = \"echo x\"\nmenu = \"Tools\"\n") |
| 273 | |
| 274 | if got := load(t, dir).Tools()[0].Menu; got != "Tools" { |
| 275 | t.Errorf("MenuName() = %q, want Tools", got) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func TestMenuNamesStartWithTheToolchainMenuAndFollowTheFile(t *testing.T) { |
| 280 | // Go is always first, whether or not a tool named it: the item that creates |
| 281 | // the tools file has to live somewhere even when there is no file. |
| 282 | dir := project(t, ` |
| 283 | [[tool]] |
| 284 | name = "Up" |
| 285 | command = "docker compose up" |
| 286 | menu = "Docker" |
| 287 | |
| 288 | [[tool]] |
| 289 | name = "Echo" |
| 290 | command = "echo x" |
| 291 | menu = "Tools" |
| 292 | |
| 293 | [[tool]] |
| 294 | name = "Down" |
| 295 | command = "docker compose down" |
| 296 | menu = "Docker" |
| 297 | `) |
| 298 | |
| 299 | got := load(t, dir).MenuNames() |
| 300 | want := []string{menu, "Docker", "Tools"} |
| 301 | if len(got) != len(want) { |
| 302 | t.Fatalf("MenuNames() = %v, want %v", got, want) |
| 303 | } |
| 304 | for i := range want { |
| 305 | if got[i] != want[i] { |
| 306 | t.Errorf("menu %d = %q, want %q", i, got[i], want[i]) |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | func TestTheToolchainMenuIsTheOnlyOneWhenNothingNamesAnother(t *testing.T) { |
| 312 | dir := project(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test\"\n") |
| 313 | |
| 314 | if got := load(t, dir).MenuNames(); len(got) != 1 || got[0] != menu { |
| 315 | t.Errorf("MenuNames() = %v, want just %q", got, menu) |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestTheToolchainMenuExistsEvenWithNoToolsAtAll(t *testing.T) { |
| 320 | // A project with no tools file still gets the menu, because that is where |
| 321 | // the item creating one lives. |
| 322 | list, err := Load(testProfile(), t.TempDir()) |
| 323 | if err != nil { |
| 324 | t.Fatalf("Load() error = %v", err) |
| 325 | } |
| 326 | |
| 327 | if got := list.MenuNames(); len(got) != 1 || got[0] != menu { |
| 328 | t.Errorf("MenuNames() = %v, want just %q", got, menu) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestInReturnsOneMenusToolsInFileOrder(t *testing.T) { |
| 333 | dir := project(t, ` |
| 334 | [[tool]] |
| 335 | name = "a" |
| 336 | command = "true" |
| 337 | menu = "Tools" |
| 338 | |
| 339 | [[tool]] |
| 340 | name = "b" |
| 341 | command = "true" |
| 342 | |
| 343 | [[tool]] |
| 344 | name = "c" |
| 345 | command = "true" |
| 346 | menu = "Tools" |
| 347 | `) |
| 348 | list := load(t, dir) |
| 349 | |
| 350 | mine := list.In("Tools") |
| 351 | if len(mine) != 2 || mine[0].Name != "a" || mine[1].Name != "c" { |
| 352 | t.Errorf("In(\"Tools\") = %v", mine) |
| 353 | } |
| 354 | if goTools := list.In(menu); len(goTools) != 1 || goTools[0].Name != "b" { |
| 355 | t.Errorf("In(%q) = %v", menu, goTools) |
| 356 | } |
| 357 | if none := list.In("Nowhere"); none != nil { |
| 358 | t.Errorf("In(\"Nowhere\") = %v, want nothing", none) |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | func TestCreateWritesTheProfilesTemplateVerbatim(t *testing.T) { |
| 363 | // The commands themselves belong to the editor, not to this package: what |
| 364 | // is checked here is that the file written is the one the profile carries, |
| 365 | // byte for byte. What is *in* Turbo Go's template is Turbo Go's own test. |
| 366 | dir := t.TempDir() |
| 367 | |
| 368 | if _, err := Create(testProfile(), dir); err != nil { |
| 369 | t.Fatalf("Create() error = %v", err) |
| 370 | } |
| 371 | |
| 372 | if got := readFile(t, Path(testProfile(), dir)); got != testToolsTemplate { |
| 373 | t.Errorf("Create() wrote:\n%s\nwant the profile's template:\n%s", got, testToolsTemplate) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | func TestDefaultMenuNameStripsTheHotKeyMarkers(t *testing.T) { |
| 378 | // A tools file writes the plain name; the hot key is the editor's to |
| 379 | // assign, because only it knows which letters the other menus have taken. |
| 380 | tests := map[string]string{"~G~o": "Go", "Rus~t~": "Rust", "Zig": "Zig"} |
| 381 | |
| 382 | for label, want := range tests { |
| 383 | if got := DefaultMenuName(profile.Profile{ToolsMenu: label}); got != want { |
| 384 | t.Errorf("DefaultMenuName(%q) = %q, want %q", label, got, want) |
| 385 | } |
| 386 | } |
| 387 | } |