v0.28.1 cc7ba64

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

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 45

↑↓ moveenter openesc close