ui_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/vev/internal/adapters/uiterm"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/bnema/vev/internal/protocol"
12	"github.com/stretchr/testify/require"
13)
14
15func newUITestService(t *testing.T) (*UI, *uiterm.Terminal, *attachPaletteClock, context.Context) {
16	t.Helper()
17	ctx, cancel := context.WithCancel(context.Background())
18	t.Cleanup(cancel)
19	terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
20	require.NoError(t, err)
21	t.Cleanup(terminal.Close)
22	clock := newAttachPaletteClock()
23	u := NewUI(terminal, clock)
24	input := newTerminalInputPump(nil)
25	consumer, ok := input.tryClaim()
26	require.True(t, ok)
27	generation := u.bindForeground(ctx, input, consumer)
28	view := ports.UIContext{AttachmentHandle: u.Handle(), Generation: generation, Status: ports.UIStatusAttached, OutputEpoch: 1, OutputState: 1, ViewPublication: 1, TabID: "tab", FocusedPaneID: "pane"}
29	view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}
30	terminal.BeginOutput(view)
31	_, err = terminal.Write([]byte("ready"))
32	require.NoError(t, err)
33	require.NoError(t, terminal.Flush())
34	terminal.EndOutput(true)
35	u.published(generation)
36	return u, terminal, clock, ctx
37}
38
39func TestUIWaitConjunctiveCurrentSnapshot(t *testing.T) {
40	u, terminal, _, ctx := newUITestService(t)
41	snapshot, err := terminal.Snapshot()
42	require.NoError(t, err)
43	text := "ready"
44	status := ports.UIStatusAttached
45	result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text, Session: &snapshot.Context.Route.Target, Focus: &ports.UIFocus{TabID: "tab", PaneID: "pane"}, Status: &status}})
46	require.NoError(t, err)
47	require.Equal(t, snapshot.Revision, result.Revision)
48	require.Equal(t, "ready   \n        ", uiSnapshotText(snapshot))
49	wrong := "wrong"
50	require.False(t, uiMatches(snapshot, ports.UIExpect{TextContains: &text, Focus: &ports.UIFocus{TabID: "tab", PaneID: domain.PaneStableID(wrong)}}))
51}
52
53func TestUIActionTimeoutRetainsExactReceiptBoundary(t *testing.T) {
54	u, terminal, clock, ctx := newUITestService(t)
55	errors := make(chan error, 1)
56	go func() {
57		_, err := u.Action(ctx, ports.UIActionRequest{Attachment: u.Handle(), Generation: 1, Text: "x"})
58		errors <- err
59	}()
60	var batch terminalAutomationRequest
61	select {
62	case batch = <-u.input.automation:
63	case <-time.After(time.Second):
64		t.Fatal("action did not reach owner")
65	}
66	requestTimer := requireTimer(t, clock.timers, "missing request deadline")
67	require.True(t, batch.owner.accept(batch.record.actionID, batch.record.generation))
68	batch.admitted <- true
69	batch.dispatched <- true
70	requireTimer(t, clock.timers, "missing retained action expiry")
71	requestTimer.fire()
72	var actionErr *ports.UIError
73	select {
74	case err := <-errors:
75		require.ErrorAs(t, err, &actionErr)
76	case <-time.After(time.Second):
77		t.Fatal("request deadline stalled")
78	}
79	require.Equal(t, ports.UIErrTimeout, actionErr.Code)
80	require.True(t, actionErr.Accepted)
81	require.Equal(t, batch.record.actionID, actionErr.ActionID)
82	// A receipt for another publication cannot use an earlier matching screen.
83	u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
84	u.mu.Lock()
85	require.Equal(t, ports.UIActionPending, u.records[actionErr.ActionID].Status)
86	u.mu.Unlock()
87	snapshot, err := terminal.Snapshot()
88	require.NoError(t, err)
89	view := snapshot.Context
90	view.ViewPublication = 2
91	require.NoError(t, terminal.PublishContext(view))
92	u.published(1)
93	boundary, err := terminal.Snapshot()
94	require.NoError(t, err)
95	// A later committed publication at the same generation must not shift the
96	// action's lower bound: the retained receipt still matches the boundary it
97	// named, never the newer revision.
98	view.Status = ports.UIStatusAttached
99	require.NoError(t, terminal.PublishContext(view))
100	u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
101	u.mu.Lock()
102	record := u.records[actionErr.ActionID]
103	u.mu.Unlock()
104	require.Equal(t, ports.UIActionProcessed, record.Status)
105	require.Equal(t, boundary.Revision, record.Revision)
106	text := "ready"
107	result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: record.ActionID, Expect: ports.UIExpect{TextContains: &text}})
108	require.NoError(t, err)
109	require.GreaterOrEqual(t, result.Revision, record.Revision)
110}
111
112func TestUIWaitBroadcastAndAttachmentQuota(t *testing.T) {
113	u, terminal, clock, ctx := newUITestService(t)
114	observeCtx, cancel := context.WithCancel(ctx)
115	done := make(chan struct{})
116	go func() { defer close(done); u.Observe(observeCtx) }()
117	t.Cleanup(func() { cancel(); requireSignal(t, done, "observation worker did not stop") })
118	status := ports.UIStatusConnecting
119	results := make(chan error, 4)
120	request := ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{Status: &status}}
121	for range 4 {
122		go func() { _, err := u.Wait(ctx, request); results <- err }()
123		requireTimer(t, clock.timers, "wait was not admitted")
124	}
125	_, err := u.Wait(ctx, request)
126	var uiErr *ports.UIError
127	require.ErrorAs(t, err, &uiErr)
128	require.Equal(t, ports.UIErrBusy, uiErr.Code)
129	snapshot, err := terminal.Snapshot()
130	require.NoError(t, err)
131	snapshot.Context.Status = status
132	require.NoError(t, terminal.PublishContext(snapshot.Context))
133	for range 4 {
134		select {
135		case err := <-results:
136			require.NoError(t, err)
137		case <-time.After(time.Second):
138			t.Fatal("coalesced publication did not wake every waiter")
139		}
140	}
141}
142
143func TestUIWaitForSnapshotUsesObservationBroadcast(t *testing.T) {
144	u, terminal, _, ctx := newUITestService(t)
145	observeCtx, cancel := context.WithCancel(ctx)
146	observed := make(chan struct{})
147	go func() {
148		u.Observe(observeCtx)
149		close(observed)
150	}()
151	defer func() {
152		cancel()
153		select {
154		case <-observed:
155		case <-time.After(time.Second):
156			t.Fatal("observation worker did not stop")
157		}
158	}()
159
160	status := ports.UIStatusConnecting
161	result := make(chan ports.UISnapshot, 1)
162	go func() {
163		snapshot, err := u.WaitForSnapshot(ctx, func(snapshot ports.UISnapshot) bool {
164			return snapshot.Context.Status == status
165		})
166		if err != nil {
167			return
168		}
169		result <- snapshot
170	}()
171	current, err := terminal.Snapshot()
172	require.NoError(t, err)
173	current.Context.Status = status
174	require.NoError(t, terminal.PublishContext(current.Context))
175	select {
176	case snapshot := <-result:
177		require.Equal(t, status, snapshot.Context.Status)
178	case <-time.After(time.Second):
179		t.Fatal("UI observation broadcast did not wake snapshot waiter")
180	}
181}
182
183func TestUIActionHistoryEvictsOnlyOnAcceptance(t *testing.T) {
184	u, _, _, _ := newUITestService(t)
185	for id := uint64(1); id <= uiActionHistory; id++ {
186		u.pending = id
187		u.reservedContext = ports.UIContext{Generation: 1}
188		require.True(t, u.accept(id, 1))
189	}
190	u.pending = 65
191	require.False(t, u.accept(65, 2))
192	require.Len(t, u.records, uiActionHistory)
193	require.Contains(t, u.records, uint64(1), "a refused generation cannot evict a retained result")
194	require.True(t, u.accept(65, 1))
195	require.Len(t, u.records, uiActionHistory)
196	require.NotContains(t, u.records, uint64(1))
197}
198
199func TestUIWaitFakeDeadlineAndExpiredAction(t *testing.T) {
200	u, _, clock, ctx := newUITestService(t)
201	text := "not present"
202	errors := make(chan error, 1)
203	go func() {
204		_, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text}})
205		errors <- err
206	}()
207	requireTimer(t, clock.timers, "missing wait timer").fire()
208	var uiErr *ports.UIError
209	select {
210	case err := <-errors:
211		require.ErrorAs(t, err, &uiErr)
212	case <-time.After(time.Second):
213		t.Fatal("wait deadline stalled")
214	}
215	require.Equal(t, ports.UIErrTimeout, uiErr.Code)
216	_, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: 99, Expect: ports.UIExpect{TextContains: &text}})
217	require.ErrorAs(t, err, &uiErr)
218	require.Equal(t, ports.UIErrActionExpired, uiErr.Code)
219}

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 12

Tags 11

↑↓ moveenter openesc close