main 33a243c

shell_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"testing"
11)
12
13func fakeBin(t *testing.T, names ...string) string {
14	t.Helper()
15	dir := t.TempDir()
16	for _, n := range names {
17		if err := os.WriteFile(filepath.Join(dir, n), []byte("#!/bin/sh\n"), 0o755); err != nil {
18			t.Fatal(err)
19		}
20	}
21	return dir
22}
23
24func TestBuiltinFormatsAndExtensions(t *testing.T) {
25	// ScriptHandlerHelpers.cs _defaultArguments and _extensions (Linux rows).
26	for shell, want := range map[string][2]string{
27		"bash":       {"--noprofile --norc -e -o pipefail {0}", ".sh"},
28		"sh":         {"-e {0}", ".sh"},
29		"python":     {"{0}", ".py"},
30		"pwsh":       {`-command ". '{0}'"`, ".ps1"},
31		"powershell": {`-command ". '{0}'"`, ".ps1"},
32		"cmd":        {`/D /E:ON /V:OFF /S /C "CALL "{0}""`, ".cmd"},
33		"BASH":       {"--noprofile --norc -e -o pipefail {0}", ".sh"}, // case-insensitive tables
34		"python3":    {"", ""},
35		"ruby":       {"", ""},
36	} {
37		if got := ScriptArgumentsFormat(shell); got != want[0] {
38			t.Errorf("format(%s) = %q", shell, got)
39		}
40		if got := ScriptFileExtension(shell); got != want[1] {
41			t.Errorf("extension(%s) = %q", shell, got)
42		}
43	}
44}
45
46func TestFixUpScriptContents(t *testing.T) {
47	if got := FixUpScriptContents("cmd", "echo hi"); got != "@echo off\necho hi" {
48		t.Fatal(got)
49	}
50	want := "$ErrorActionPreference = 'stop'\nGet-Date\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }"
51	for _, s := range []string{"pwsh", "powershell"} {
52		if got := FixUpScriptContents(s, "Get-Date"); got != want {
53			t.Fatalf("%s: %q", s, got)
54		}
55	}
56	// The switch is case-sensitive: PWSH is left alone.
57	for _, s := range []string{"bash", "sh", "python", "PWSH", "Cmd"} {
58		if got := FixUpScriptContents(s, "x"); got != "x" {
59			t.Fatalf("%s: %q", s, got)
60		}
61	}
62}
63
64func TestParseShellOption(t *testing.T) {
65	for in, want := range map[string][2]string{
66		"bash":                {"bash", ""},
67		"bash -x {0}":         {"bash", "-x {0}"},
68		"python3 {0}":         {"python3", "{0}"},
69		"perl  -e {0}":        {"perl", " -e {0}"}, // Split(" ", 2) keeps the second space
70		"":                    {"", ""},
71		"node -e \"x y\" {0}": {"node", "-e \"x y\" {0}"},
72	} {
73		c, a := ParseShellOption(in)
74		if c != want[0] || a != want[1] {
75			t.Errorf("%q: %q %q", in, c, a)
76		}
77	}
78}
79
80func TestPlanShell(t *testing.T) {
81	both := fakeBin(t, "bash", "sh", "python", "python3", "perl")
82	onlySh := fakeBin(t, "sh")
83	l := Lookup{Path: both}
84	tests := []struct {
85		name  string
86		shell string
87		l     Lookup
88		want  ShellPlan
89		err   string
90	}{
91		{"default uses bash with the arguments of sh", "", l, ShellPlan{"sh", filepath.Join(both, "bash"), "-e {0}"}, ""},
92		{"default falls back to sh", "", Lookup{Path: onlySh}, ShellPlan{"sh", filepath.Join(onlySh, "sh"), "-e {0}"}, ""},
93		{"bash", "bash", l, ShellPlan{"bash", filepath.Join(both, "bash"), "--noprofile --norc -e -o pipefail {0}"}, ""},
94		{"sh", "sh", l, ShellPlan{"sh", filepath.Join(both, "sh"), "-e {0}"}, ""},
95		{"python", "python", l, ShellPlan{"python", filepath.Join(both, "python"), "{0}"}, ""},
96		{"custom with args", "python3 {0}", l, ShellPlan{"python3", filepath.Join(both, "python3"), "{0}"}, ""},
97		{"custom args are left-trimmed", "perl   -w {0}", l, ShellPlan{"perl", filepath.Join(both, "perl"), "-w {0}"}, ""},
98		{"bash with explicit args", "bash -x {0}", l, ShellPlan{"bash", filepath.Join(both, "bash"), "-x {0}"}, ""},
99		{"unknown command", "nope {0}", l, ShellPlan{}, "nope: command not found"},
100		{"pwsh not installed", "pwsh", l, ShellPlan{}, "pwsh: command not found"},
101		{"no sh at all", "", Lookup{Path: t.TempDir()}, ShellPlan{}, "sh: command not found"},
102		{"custom without {0}", "perl -w", l, ShellPlan{}, "Invalid shell option"},
103		{"no format for an unknown shell", "perl", l, ShellPlan{}, "Invalid shell option"},
104	}
105	for _, tc := range tests {
106		t.Run(tc.name, func(t *testing.T) {
107			got, err := PlanShell(tc.shell, tc.l)
108			if tc.err != "" {
109				if err == nil || !strings.Contains(err.Error(), tc.err) {
110					t.Fatalf("err = %v, want %q", err, tc.err)
111				}
112				return
113			}
114			if err != nil || got != tc.want {
115				t.Fatalf("got %+v, %v; want %+v", got, err, tc.want)
116			}
117		})
118	}
119}
120
121func TestPlanShellUsesPrependedPath(t *testing.T) {
122	sys, extra := fakeBin(t, "sh"), fakeBin(t, "bash")
123	p, err := PlanShell("", Lookup{Path: sys, Prepend: []string{extra}})
124	if err != nil || p.CommandPath != filepath.Join(extra, "bash") {
125		t.Fatalf("%+v %v", p, err)
126	}
127}
128
129func TestShellLine(t *testing.T) {
130	p := ShellPlan{"sh", "/usr/bin/bash", "-e {0}"}
131	if p.ShellLine() != "/usr/bin/bash -e {0}" || p.Extension() != ".sh" {
132		t.Fatal(p.ShellLine(), p.Extension())
133	}
134}
135
136func TestFormatArguments(t *testing.T) {
137	for _, tc := range []struct{ format, path, want, err string }{
138		{"-e {0}", "/t/a.sh", "-e /t/a.sh", ""},
139		{"{0}", "/t/a b.sh", "/t/a b.sh", ""},
140		{"-c '{0}' {0}", "/t/x", "-c '/t/x' /t/x", ""},
141		{"{{0}} {0}", "/t/x", "{0} /t/x", ""},
142		{"x }}", "/t/x", "x }", ""},
143		{"{0}", `/t/a"b`, `/t/a\"b`, ""}, // double quotes in the path are escaped
144		{"{0:D}", "/t/x", "/t/x", ""},
145		{"{1}", "/t/x", "", "Index"},
146		{"{0", "/t/x", "", "not in a correct format"},
147		{"a } b", "/t/x", "", "not in a correct format"},
148	} {
149		got, err := FormatArguments(tc.format, tc.path)
150		if tc.err != "" {
151			if err == nil || !strings.Contains(err.Error(), tc.err) {
152				t.Errorf("%q: err = %v", tc.format, err)
153			}
154			continue
155		}
156		if err != nil || got != tc.want {
157			t.Errorf("%q: %q %v", tc.format, got, err)
158		}
159	}
160}
161
162func TestParseArguments(t *testing.T) {
163	for in, want := range map[string][]string{
164		"":           nil,
165		"   ":        nil,
166		"-e /t/a.sh": {"-e", "/t/a.sh"},
167		"--noprofile --norc -e -o pipefail /t/a.sh": {"--noprofile", "--norc", "-e", "-o", "pipefail", "/t/a.sh"},
168		`-c "a b" c`:              {"-c", "a b", "c"},
169		`a\"b`:                    {`a"b`},
170		`a\\"b c"`:                {`a\b c`},
171		`a\\b`:                    {`a\\b`},
172		`"a""b"`:                  {`a"b`},
173		"a\tb":                    {"a", "b"},
174		`-command ". '/t/x.ps1'"`: {"-command", ". '/t/x.ps1'"},
175		`""`:                      {""},
176	} {
177		if got := ParseArguments(in); !reflect.DeepEqual(got, want) {
178			t.Errorf("%q: %q, want %q", in, got, want)
179		}
180	}
181}
182
183func TestWriteScript(t *testing.T) {
184	dir := t.TempDir()
185	root, err := os.OpenRoot(dir)
186	if err != nil {
187		t.Fatal(err)
188	}
189	defer func() { _ = root.Close() }()
190	bin := fakeBin(t, "bash", "sh", "python")
191	l := Lookup{Path: bin}
192	for _, tc := range []struct {
193		shell, ext string
194		args       []string
195	}{
196		{"", ".sh", []string{"-e"}},
197		{"bash", ".sh", []string{"--noprofile", "--norc", "-e", "-o", "pipefail"}},
198		{"python", ".py", nil},
199	} {
200		plan, err := PlanShell(tc.shell, l)
201		if err != nil {
202			t.Fatal(err)
203		}
204		s, err := WriteScript(root, plan, "echo hi\n")
205		if err != nil {
206			t.Fatal(err)
207		}
208		if filepath.Dir(s.Path) != dir || filepath.Ext(s.Path) != tc.ext {
209			t.Fatalf("%s: path %s", tc.shell, s.Path)
210		}
211		want := append(append([]string{plan.CommandPath}, tc.args...), s.Path)
212		if !reflect.DeepEqual(s.Argv, want) {
213			t.Fatalf("%s: argv %q, want %q", tc.shell, s.Argv, want)
214		}
215		b, _ := os.ReadFile(s.Path)
216		if string(b) != "echo hi\n" {
217			t.Fatalf("contents %q", b)
218		}
219	}
220	// A custom shell without an extension in the table: no extension.
221	plan, _ := PlanShell("python {0}", l)
222	if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != ".py" {
223		t.Fatalf("python {0}: %s", s.Path)
224	}
225	bin2 := fakeBin(t, "perl")
226	plan, _ = PlanShell("perl {0}", Lookup{Path: bin2})
227	if s, _ := WriteScript(root, plan, "x"); filepath.Ext(s.Path) != "" {
228		t.Fatalf("perl: %s", s.Path)
229	}
230}
231
232func TestResolveWorkingDirectory(t *testing.T) {
233	for _, tc := range [][3]string{
234		{"/w/r/r", "", "/w/r/r"},
235		{"/w/r/r", "sub", "/w/r/r/sub"},
236		{"/w/r/r", "a/b", "/w/r/r/a/b"},
237		{"/w/r/r", "/tmp", "/tmp"},
238		{"/w/r/r", "..", "/w/r/r/.."},
239	} {
240		if got := ResolveWorkingDirectory(tc[0], tc[1]); got != tc[2] {
241			t.Errorf("%v: %q", tc, got)
242		}
243	}
244}
245
246// The script goes through the os.Root of RUNNER_TEMP: a directory swapped for a
247// link to elsewhere after the root was opened does not redirect it (GO-002).
248func TestWriteScriptStaysInTheOpenedDirectory(t *testing.T) {
249	parent := t.TempDir()
250	dir := filepath.Join(parent, "tmp")
251	if err := os.Mkdir(dir, 0o755); err != nil {
252		t.Fatal(err)
253	}
254	root, err := os.OpenRoot(dir)
255	if err != nil {
256		t.Fatal(err)
257	}
258	defer func() { _ = root.Close() }()
259	outside := filepath.Join(parent, "outside")
260	if err := os.Mkdir(outside, 0o755); err != nil {
261		t.Fatal(err)
262	}
263	// The step swaps the directory for a link to another one.
264	if err := os.Rename(dir, dir+".moved"); err != nil {
265		t.Fatal(err)
266	}
267	if err := os.Symlink(outside, dir); err != nil {
268		t.Fatal(err)
269	}
270	bin := fakeBin(t, "bash", "sh")
271	plan, _ := PlanShell("", Lookup{Path: bin})
272	if _, err := WriteScript(root, plan, "echo hi\n"); err != nil {
273		t.Fatal(err)
274	}
275	if ents, _ := os.ReadDir(outside); len(ents) != 0 {
276		t.Fatalf("the script was written through the swapped link: %v", ents)
277	}
278	if ents, _ := os.ReadDir(dir + ".moved"); len(ents) != 1 {
279		t.Fatalf("the script is not in the directory that root was opened on: %v", ents)
280	}
281}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close