v0.7.0 45bfc5b

e2e_helpers_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"net"
9	"net/http"
10	"net/http/httptest"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/bnema/zerowrap"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/config"
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24)
25
26// harness is a forge wired like `ermite serve`, behind an httptest server,
27// and a git client isolated from the machine's configuration.
28type harness struct {
29	t         *testing.T
30	forge     *forge
31	srv       *httptest.Server
32	data      string
33	logs      *syncBuffer
34	home      string // HOME of the git client
35	ctx       context.Context
36	cancel    context.CancelFunc
37	hooksDone chan error
38}
39
40type harnessOpts struct {
41	trustedProxies string
42	maintenance    time.Duration
43	// hookExe is the executable the hook scripts exec; empty builds ermite.
44	hookExe string
45	// maxBody overrides the limit of a push body when positive.
46	maxBody int64
47	// eventBuffer overrides the size of the event ring when positive.
48	eventBuffer int
49	// revalidateEvery overrides how often a watcher's token is re-checked.
50	revalidateEvery time.Duration
51	// publicURL overrides ERMITE_PUBLIC_URL (http://example.test) when set.
52	publicURL string
53}
54
55// newHarness starts a forge. The data directory is short: the hook socket
56// lives in it and Unix socket paths are limited to about 100 bytes.
57func newHarness(t *testing.T, o harnessOpts) *harness {
58	t.Helper()
59	return newHarnessWith(t, o, nil)
60}
61
62// newHarnessWith is newHarness with a change of the wiring options.
63func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
64	t.Helper()
65	gitBin := requireGit(t)
66	data, err := os.MkdirTemp("", "ermite")
67	require.NoError(t, err)
68	t.Cleanup(func() { _ = os.RemoveAll(data) })
69
70	env := map[string]string{
71		config.EnvDataDir:    data,
72		config.EnvPublicURL:  "http://example.test",
73		config.EnvAdminToken: adminToken,
74		config.EnvGit:        gitBin,
75		config.EnvAddr:       "127.0.0.1:1",
76	}
77	if o.publicURL != "" {
78		env[config.EnvPublicURL] = o.publicURL
79	}
80	if o.trustedProxies != "" {
81		env[config.EnvTrustedProxies] = o.trustedProxies
82	}
83	// The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app
84	if r := os.Getenv(config.EnvGitReader); r != "" {
85		env[config.EnvGitReader] = r
86	}
87	cfg, err := config.Load(func(k string) string { return env[k] })
88	require.NoError(t, err)
89
90	logs := &syncBuffer{}
91	log := zerowrap.New(zerowrap.Config{Level: "trace", Format: "json", Output: logs})
92	ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), log))
93
94	require.NoError(t, prepareDataDir(data))
95	hookExe := o.hookExe
96	if hookExe == "" {
97		hookExe = binary(t)
98	}
99	opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
100	if tweak != nil {
101		tweak(&opts)
102	}
103	f, err := newForge(ctx, cfg, opts)
104	require.NoError(t, err)
105
106	h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
107	go func() { h.hooksDone <- f.hooks.Serve(ctx); close(h.hooksDone) }()
108	h.srv = httptest.NewUnstartedServer(f.server.Handler())
109	h.srv.Config.BaseContext = func(net.Listener) context.Context { return ctx }
110	h.srv.Start()
111
112	h.home = t.TempDir()
113	t.Cleanup(func() {
114		// Event streams never end by themselves: the real server closes them
115		// when its shutdown begins, the test server needs the same push.
116		f.EventLog.Close(context.WithoutCancel(ctx))
117		h.srv.Close()
118		cancel()
119		select {
120		case <-h.hooksDone:
121		case <-time.After(10 * time.Second):
122			t.Error("hook server did not stop")
123		}
124		f.Close(context.WithoutCancel(ctx))
125	})
126	return h
127}
128
129func (h *harness) url(path string) string { return h.srv.URL + path }
130
131// urlAuth returns a URL with the token as the password.
132func (h *harness) urlAuth(secret, path string) string {
133	return strings.Replace(h.srv.URL, "http://", "http://user:"+secret+"@", 1) + path
134}
135
136func (h *harness) name(s string) domain.RepoName {
137	h.t.Helper()
138	n, err := domain.ParseRepoName(s)
139	require.NoError(h.t, err)
140	return n
141}
142
143// clientEnv is the complete environment of the git client in tests: nothing
144// of the machine's configuration, a fixed identity and locale.
145func clientEnv(home string) []string {
146	return []string{
147		"PATH=" + os.Getenv("PATH"),
148		"HOME=" + home,
149		"GIT_CONFIG_GLOBAL=/dev/null",
150		"GIT_CONFIG_NOSYSTEM=1",
151		"GIT_TERMINAL_PROMPT=0",
152		"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@example.test",
153		"GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@example.test",
154		"LC_ALL=C",
155	}
156}
157
158// git runs the git client in dir with an isolated configuration.
159func (h *harness) git(dir string, args ...string) (string, error) {
160	h.t.Helper()
161	return h.gitEnv(nil, dir, args...)
162}
163
164func (h *harness) mustGit(dir string, args ...string) string {
165	h.t.Helper()
166	out, err := h.git(dir, args...)
167	require.NoError(h.t, err, "git %s\n%s", strings.Join(args, " "), out)
168	return out
169}
170
171// workTree makes a work tree with one commit per file on branch.
172func (h *harness) workTree(branch string, files ...string) string {
173	h.t.Helper()
174	dir := h.t.TempDir()
175	h.mustGit(dir, "init", "-q", "--initial-branch="+branch)
176	for _, f := range files {
177		require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(dir, f)), 0o755))
178		require.NoError(h.t, os.WriteFile(filepath.Join(dir, f), []byte("content of "+f+"\n"), 0o644))
179		h.mustGit(dir, "add", f)
180		h.mustGit(dir, "commit", "-q", "-m", "add "+f)
181	}
182	return dir
183}
184
185// status does a plain HTTP request and returns the status code.
186func (h *harness) status(method, path string, hdr http.Header) int {
187	h.t.Helper()
188	req, err := http.NewRequest(method, h.url(path), nil)
189	require.NoError(h.t, err)
190	for k, vs := range hdr {
191		for _, v := range vs {
192			req.Header.Add(k, v)
193		}
194	}
195	resp, err := http.DefaultClient.Do(req)
196	require.NoError(h.t, err)
197	_ = resp.Body.Close()
198	return resp.StatusCode
199}
200
201// token creates a stored token through the use case, as root.
202func (h *harness) token(scopes []domain.Scope, repos ...string) string {
203	h.t.Helper()
204	p := ports.CreateTokenParams{Label: "e2e", Scopes: scopes}
205	for _, r := range repos {
206		p.Repos = append(p.Repos, h.name(r))
207	}
208	got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
209	require.NoError(h.t, err)
210	return got.Secret
211}
212
213// gitEnv runs git like git() with extra environment variables.
214func (h *harness) gitEnv(extra []string, dir string, args ...string) (string, error) {
215	h.t.Helper()
216	cmd := exec.Command("git", args...)
217	cmd.Dir = dir
218	cmd.Env = append(clientEnv(h.home), extra...)
219	var out bytes.Buffer
220	cmd.Stdout, cmd.Stderr = &out, &out
221	err := cmd.Run()
222	return out.String(), err
223}
224
225// repoDir is the bare repository on disk.
226func (h *harness) repoDir(name string) string {
227	return filepath.Join(h.data, "repos", name+".git")
228}
229
230// bareGit runs git against the bare repository on disk.
231func (h *harness) bareGit(name string, args ...string) string {
232	h.t.Helper()
233	return strings.TrimSpace(h.mustGit(h.repoDir(name), args...))
234}

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 2

Tags 44

↑↓ moveenter openesc close