v0.5.0 c37833b

web_daemon_broker_test.go RawHistoryFolder

1//go:build linux
2
3package app
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"net/http"
10	"net/http/httptest"
11	"regexp"
12	"strings"
13	"sync"
14	"sync/atomic"
15	"testing"
16	"time"
17
18	"github.com/bnema/vev-vt/html/browser"
19	"github.com/coder/websocket"
20	"github.com/stretchr/testify/require"
21
22	"github.com/bnema/vev/internal/adapters/brokeripc"
23	"github.com/bnema/vev/internal/adapters/webterm"
24	"github.com/bnema/vev/internal/ports"
25	portsmocks "github.com/bnema/vev/internal/ports/mocks"
26	"github.com/bnema/vev/internal/usecase/client"
27	"github.com/stretchr/testify/mock"
28)
29
30// Browser gateway broker composition tests (Plan 001 P7.4d).
31//
32// The gateway server is one runBrokerClient per authenticated WebSocket. These
33// tests drive the production composition — runWebTerminalClient, the real
34// webterm.Terminal, and the real webterm.Server admission — over a private
35// broker sandbox whose daemon serves a real shell, so each assertion holds for
36// the production entry point. Only the connector composition seam and the
37// observation callbacks are substituted, exactly as the terminal composition
38// tests substitute theirs.
39//
40// Ownership is one-directional: the fixture stops the tabs it started *before*
41// the broker and daemon it created are torn down, and never interprets a tab's
42// disconnect as proof that the shared daemon went away.
43
44const (
45	// webGatewayCookieName mirrors the adapter's authenticated cookie. It is
46	// duplicated here because the adapter keeps it unexported.
47	webGatewayCookieName = "vev-web-session"
48	// webGatewayShellScript prints a per-session ready marker and answers one
49	// PING line with a PONG line, so a tab proves real session content and a
50	// real session action rather than a parked shell.
51	webGatewayShellScript = `printf 'READY-%s\n' "$$"; trap : TERM; while :; do read line || exit; case "$line" in PING*) printf 'PONG:%s\n' "$line";; esac; done`
52)
53
54var webGatewayReadyPID = regexp.MustCompile(`READY-(\d+)`)
55
56// webFrame is one gateway websocket frame: the transactional VT update plus the
57// mouse-tracking flag.
58type webFrame struct {
59	Update json.RawMessage `json:"update"`
60	Mouse  bool            `json:"mouse"`
61}
62
63// webUpdate is the adapter's rendered update, decoded only far enough to read
64// the committed cell text.
65type webUpdate struct {
66	SchemaVersion uint16 `json:"schemaVersion"`
67	Width         int    `json:"width"`
68	Height        int    `json:"height"`
69	Snapshot      bool   `json:"snapshot"`
70	Rows          []struct {
71		Row   int `json:"row"`
72		Cells []struct {
73			Column int    `json:"column"`
74			Width  int    `json:"width"`
75			Text   string `json:"text"`
76		} `json:"cells"`
77	} `json:"rows"`
78}
79
80// text returns the whole rendered frame as row-major text, which is exactly
81// what the browser displays.
82func (u webUpdate) text() string {
83	var builder strings.Builder
84	for _, row := range u.Rows {
85		for _, cell := range row.Cells {
86			builder.WriteString(cell.Text)
87		}
88		builder.WriteByte('\n')
89	}
90	return builder.String()
91}
92
93// maxWebTabText bounds the accumulated frame text so a long test cannot grow
94// without bound.
95const maxWebTabText = 1 << 20
96
97// webBrowserTab is one authenticated browser terminal connection plus its
98// single owned reader. The reader is the only consumer of the socket, so an
99// assertion that is not reading can never cancel another read or lose a frame.
100type webBrowserTab struct {
101	conn      *websocket.Conn
102	cancel    context.CancelFunc
103	aborted   chan struct{}
104	changed   chan struct{}
105	closeOnce sync.Once
106
107	mu      sync.Mutex
108	text    string
109	frames  int
110	readErr error
111}
112
113func (tab *webBrowserTab) run(ctx context.Context) {
114	defer close(tab.aborted)
115	for {
116		kind, data, err := tab.conn.Read(ctx)
117		if err != nil {
118			tab.setReadErr(err)
119			return
120		}
121		if kind != websocket.MessageText {
122			continue
123		}
124		var frame webFrame
125		if err := json.Unmarshal(data, &frame); err != nil {
126			tab.setReadErr(err)
127			return
128		}
129		if len(frame.Update) == 0 {
130			continue
131		}
132		var update webUpdate
133		if err := json.Unmarshal(frame.Update, &update); err != nil {
134			tab.setReadErr(err)
135			return
136		}
137		tab.append(update.text())
138	}
139}
140
141func (tab *webBrowserTab) append(text string) {
142	tab.mu.Lock()
143	tab.text += text
144	if len(tab.text) > maxWebTabText {
145		tab.text = tab.text[len(tab.text)-maxWebTabText:]
146	}
147	tab.frames++
148	tab.mu.Unlock()
149	select {
150	case tab.changed <- struct{}{}:
151	default:
152	}
153}
154
155func (tab *webBrowserTab) setReadErr(err error) {
156	tab.mu.Lock()
157	tab.readErr = err
158	tab.mu.Unlock()
159	select {
160	case tab.changed <- struct{}{}:
161	default:
162	}
163}
164
165func (tab *webBrowserTab) accumulated() string {
166	tab.mu.Lock()
167	defer tab.mu.Unlock()
168	return tab.text
169}
170
171func (tab *webBrowserTab) frameCount() int {
172	tab.mu.Lock()
173	defer tab.mu.Unlock()
174	return tab.frames
175}
176
177func (tab *webBrowserTab) readerErr() error {
178	tab.mu.Lock()
179	defer tab.mu.Unlock()
180	return tab.readErr
181}
182
183// close ends exactly this tab: the socket and this run's own context.
184func (tab *webBrowserTab) close() {
185	tab.closeOnce.Do(func() {
186		_ = tab.conn.CloseNow()
187		tab.cancel()
188	})
189}
190
191// sendText types one browser text event at the tab.
192func (tab *webBrowserTab) sendText(t *testing.T, text string) {
193	t.Helper()
194	payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "text", "text": text})
195	require.NoError(t, err)
196	require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
197}
198
199// sendEnter sends one browser Enter key event, which the adapter encodes as CR.
200// A text event deliberately cannot carry a control byte.
201func (tab *webBrowserTab) sendEnter(t *testing.T) {
202	t.Helper()
203	payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "key", "key": "Enter", "code": "Enter"})
204	require.NoError(t, err)
205	require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
206}
207
208// submit types one command line and presses Enter: one real session action.
209func (tab *webBrowserTab) submit(t *testing.T, line string) {
210	t.Helper()
211	tab.sendText(t, line)
212	tab.sendEnter(t)
213}
214
215// awaitText waits until the accumulated frames contain marker.
216func (tab *webBrowserTab) awaitText(t *testing.T, marker string) {
217	t.Helper()
218	deadline := time.NewTimer(brokerTestWait)
219	defer deadline.Stop()
220	for {
221		if strings.Contains(tab.accumulated(), marker) {
222			return
223		}
224		select {
225		case <-tab.changed:
226		case <-tab.aborted:
227			t.Fatalf("browser tab ended before rendering %q (reader error: %v); accumulated: %q", marker, tab.readerErr(), tab.accumulated())
228		case <-deadline.C:
229			t.Fatalf("browser tab never rendered %q; accumulated: %q", marker, tab.accumulated())
230		}
231	}
232}
233
234// requireTextAbsent asserts marker is not rendered within the observation
235// window. It is the independence assertion: one tab's session content must
236// never appear in another tab's terminal.
237func (tab *webBrowserTab) requireTextAbsent(t *testing.T, marker string, window time.Duration) {
238	t.Helper()
239	deadline := time.NewTimer(window)
240	defer deadline.Stop()
241	for {
242		require.NotContains(t, tab.accumulated(), marker, "an independent tab rendered another tab's session content")
243		select {
244		case <-tab.changed:
245		case <-tab.aborted:
246			return
247		case <-deadline.C:
248			return
249		}
250	}
251}
252
253// awaitMoreFrames waits until at least count frames were rendered, proving the
254// run is still serving after a failure.
255func (tab *webBrowserTab) awaitMoreFrames(t *testing.T, count int) {
256	t.Helper()
257	deadline := time.NewTimer(brokerTestWait)
258	defer deadline.Stop()
259	for {
260		if tab.frameCount() >= count {
261			return
262		}
263		select {
264		case <-tab.changed:
265		case <-tab.aborted:
266			t.Fatalf("browser tab ended before rendering %d frames (reader error: %v)", count, tab.readerErr())
267		case <-deadline.C:
268			t.Fatalf("browser tab rendered only %d frames, want %d", tab.frameCount(), count)
269		}
270	}
271}
272
273// readyPID returns the session shell PID the tab rendered, or "" while no ready
274// marker arrived. Two tabs must report distinct PIDs: their sessions are
275// separate even though they share one broker and one physical carriage.
276func (tab *webBrowserTab) readyPID() string {
277	match := webGatewayReadyPID.FindStringSubmatch(tab.accumulated())
278	if len(match) != 2 {
279		return ""
280	}
281	return match[1]
282}
283
284// webGatewayFixture is the shared private broker, server, and tab registry of
285// one browser composition test family.
286type webGatewayFixture struct {
287	broker  offlineClientFixture
288	handler *webterm.Server
289	server  *httptest.Server
290	token   string
291	wsURL   string
292
293	states   chan client.State
294	notices  chan client.LifecycleNotice
295	failures chan error
296
297	mu            sync.Mutex
298	tabs          []*webBrowserTab
299	presentations map[client.Presentation]int
300}
301
302// startWebGatewayFixture provisions one private broker sandbox with a real
303// daemon shell, then one authenticated gateway server whose every WebSocket runs
304// the production runWebTerminalClient composition over that sandbox.
305func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
306	t.Helper()
307	broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript})
308	return startWebGatewayFixtureOver(t, broker, func() ports.BrokerConnector {
309		return brokeripc.NewConnector(broker.socket, brokeripc.Config{}).Navigator()
310	})
311}
312
313// startWebGatewayFixtureOver starts one gateway over an explicitly supplied
314// connector composition, so a test can script the broker without a process.
315func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture {
316	t.Helper()
317	// The gateway composition reads the nested-session and transport environment;
318	// a test drives the broker client with both unset.
319	t.Setenv("VEV", "")
320	t.Setenv(envRemoteTransport, "")
321	settings, err := webterm.ParseSettings("", webterm.Origin)
322	require.NoError(t, err)
323	fixture := &webGatewayFixture{
324		broker:        broker,
325		token:         strings.Repeat("w", 43),
326		states:        make(chan client.State, 256),
327		notices:       make(chan client.LifecycleNotice, 64),
328		failures:      make(chan error, 64),
329		presentations: make(map[client.Presentation]int),
330	}
331	previousConnector := webTerminalConnector
332	previousCallbacks := terminalBrokerCallbacks
333	webTerminalConnector = connector
334	terminalBrokerCallbacks = func() terminalBrokerSeams {
335		return terminalBrokerSeams{
336			OnState: func(state client.State) {
337				// Keep a durable observation as well as the bounded notification.
338				// Awaiters intentionally consume notifications, so a later barrier
339				// must not infer that a presentation never happened merely because
340				// an earlier await already dequeued it.
341				fixture.mu.Lock()
342				fixture.presentations[state.Presentation]++
343				fixture.mu.Unlock()
344				select {
345				case fixture.states <- state:
346				default:
347				}
348			},
349			OnLifecycle: func(notice client.LifecycleNotice) {
350				select {
351				case fixture.notices <- notice:
352				default:
353				}
354			},
355			OnFailure: func(err error) {
356				select {
357				case fixture.failures <- err:
358				default:
359				}
360			},
361		}
362	}
363
364	ctx, cancel := context.WithCancel(context.Background())
365	handler, err := webterm.NewServer(ctx, settings, fixture.token, func(runCtx context.Context, terminal *webterm.Terminal) error {
366		return runWebTerminalClient(runCtx, terminal)
367	})
368	require.NoError(t, err)
369	fixture.handler = handler
370	fixture.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
371		r.Host = webterm.Address
372		handler.ServeHTTP(w, r)
373	}))
374	fixture.wsURL = "ws" + strings.TrimPrefix(fixture.server.URL, "http") + "/ws"
375	t.Cleanup(func() {
376		// Tabs first: the broker and daemon the fixture created are torn down by
377		// their own cleanup, which runs after this one.
378		fixture.closeTabs()
379		fixture.server.Close()
380		cancel()
381		handler.Wait()
382		webTerminalConnector = previousConnector
383		terminalBrokerCallbacks = previousCallbacks
384	})
385	return fixture
386}
387
388// openTab dials one authenticated browser tab and starts its single reader.
389func (f *webGatewayFixture) openTab(t *testing.T) *webBrowserTab {
390	t.Helper()
391	ctx, cancel := context.WithCancel(context.Background())
392	headers := http.Header{"Origin": {webterm.Origin}, "Cookie": {webGatewayCookieName + "=" + f.token}}
393	conn, _, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: headers})
394	if err != nil {
395		cancel()
396		require.NoError(t, err)
397	}
398	conn.SetReadLimit(64 << 20)
399	tab := &webBrowserTab{conn: conn, cancel: cancel, aborted: make(chan struct{}), changed: make(chan struct{}, 1)}
400	f.mu.Lock()
401	f.tabs = append(f.tabs, tab)
402	f.mu.Unlock()
403	go tab.run(ctx)
404	return tab
405}
406
407// closeTabs ends every tab the fixture started.
408func (f *webGatewayFixture) closeTabs() {
409	f.mu.Lock()
410	tabs := append([]*webBrowserTab(nil), f.tabs...)
411	f.mu.Unlock()
412	for _, tab := range tabs {
413		tab.close()
414	}
415}
416
417// awaitNotice waits for one bounded lifecycle notice.
418func (f *webGatewayFixture) awaitNotice(t *testing.T) client.LifecycleNotice {
419	t.Helper()
420	select {
421	case notice := <-f.notices:
422		return notice
423	case <-time.After(brokerTestWait):
424		t.Fatal("the browser run reported no lifecycle notice")
425		return client.LifecycleNotice{}
426	}
427}
428
429// awaitFailure waits for one typed failure surfaced without ending the run.
430func (f *webGatewayFixture) awaitFailure(t *testing.T) error {
431	t.Helper()
432	select {
433	case err := <-f.failures:
434		require.Error(t, err)
435		return err
436	case <-time.After(brokerTestWait):
437		t.Fatal("the browser run reported no failure")
438		return nil
439	}
440}
441
442// awaitPresentation waits for one supervisor presentation.
443func (f *webGatewayFixture) awaitPresentation(t *testing.T, want client.Presentation) client.State {
444	t.Helper()
445	deadline := time.NewTimer(brokerTestWait)
446	defer deadline.Stop()
447	for {
448		select {
449		case state := <-f.states:
450			if state.Presentation == want {
451				return state
452			}
453		case <-deadline.C:
454			t.Fatalf("the browser run never presented %v", want)
455			return client.State{}
456		}
457	}
458}
459
460// observedPresentations returns a defensive snapshot of all presentations
461// reported by every browser run in this fixture. Unlike states, this is a
462// durable barrier and therefore remains valid after awaitPresentation consumes
463// its edge-triggered notification.
464func (f *webGatewayFixture) observedPresentations() map[client.Presentation]int {
465	f.mu.Lock()
466	defer f.mu.Unlock()
467	observed := make(map[client.Presentation]int, len(f.presentations))
468	for presentation, count := range f.presentations {
469		observed[presentation] = count
470	}
471	return observed
472}
473
474// TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices pins the
475// product contract of two browser tabs: each tab owns exactly one broker
476// service and one logical stream (so detaching one is isolated), the two
477// sessions are distinct and render their own real content, a real session
478// action round-trips through the browser path, and the broker still dials
479// exactly one shared physical carriage for both.
480func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testing.T) {
481	fixture := startWebGatewayFixture(t)
482
483	first := fixture.openTab(t)
484	awaitLogicalStream(t, fixture.broker.streams)
485	fixture.awaitPresentation(t, client.PresentAttached)
486	first.awaitText(t, "READY-")
487	firstPID := first.readyPID()
488	require.NotEmpty(t, firstPID, "an ephemeral creation renders its session shell")
489	carriageAfterFirst := fixture.broker.physicalCount()
490
491	second := fixture.openTab(t)
492	awaitLogicalStream(t, fixture.broker.streams)
493	fixture.awaitPresentation(t, client.PresentAttached)
494	second.awaitText(t, "READY-")
495	secondPID := second.readyPID()
496	require.NotEmpty(t, secondPID, "the second tab renders its own session shell")
497
498	// Independent services and streams: two admissions, and two distinct
499	// sessions even though both tabs share the broker process.
500	require.NotEqual(t, firstPID, secondPID, "two tabs must attach distinct sessions")
501	require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(),
502		"the second tab opened another daemonmux carriage instead of reusing the pooled one")
503
504	// The identical state machine: each browser run's initial creation presents
505	// Connecting then Attached exactly like every other frontend, never a
506	// picker flash first. Consult the durable observation barrier: the two
507	// Attached notifications above were consumed while synchronizing startup
508	// and must still count here.
509	require.Eventually(t, func() bool {
510		presentations := fixture.observedPresentations()
511		return presentations[client.PresentConnecting] >= 2 &&
512			presentations[client.PresentAttached] >= 2
513	}, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations())
514	require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker")
515
516	// Real content and real actions, and each tab renders only its own session.
517	first.submit(t, "PING-FIRST")
518	second.submit(t, "PING-SECOND")
519	first.awaitText(t, "PONG:PING-FIRST")
520	second.awaitText(t, "PONG:PING-SECOND")
521	first.requireTextAbsent(t, "PONG:PING-SECOND", 300*time.Millisecond)
522	second.requireTextAbsent(t, "PONG:PING-FIRST", 300*time.Millisecond)
523
524	require.NoError(t, first.readerErr())
525	require.NoError(t, second.readerErr())
526}
527
528// TestWebGatewayTabClosingIsIsolated pins that disconnecting one tab cancels
529// only that tab's supervisor, service, and logical stream: the shared daemon,
530// the other tab, and the pooled physical carriage survive, and a new tab still
531// reaches a session through the same broker.
532func TestWebGatewayTabClosingIsIsolated(t *testing.T) {
533	fixture := startWebGatewayFixture(t)
534
535	first := fixture.openTab(t)
536	awaitLogicalStream(t, fixture.broker.streams)
537	fixture.awaitPresentation(t, client.PresentAttached)
538	first.awaitText(t, "READY-")
539	carriage := fixture.broker.physicalCount()
540	first.submit(t, "PING-ONE")
541	first.awaitText(t, "PONG:PING-ONE")
542
543	second := fixture.openTab(t)
544	awaitLogicalStream(t, fixture.broker.streams)
545	fixture.awaitPresentation(t, client.PresentAttached)
546	second.awaitText(t, "READY-")
547
548	// Close the first tab and join that run before asserting on the survivor.
549	first.close()
550	select {
551	case <-first.aborted:
552	case <-time.After(brokerTestWait):
553		t.Fatal("the closed browser tab kept reading")
554	}
555
556	// The surviving tab keeps its own session, its own content, and its own
557	// service: the closed tab's session output never reaches the survivor.
558	second.submit(t, "PING-TWO")
559	second.awaitText(t, "PONG:PING-TWO")
560	second.requireTextAbsent(t, "PONG:PING-ONE", 150*time.Millisecond)
561	require.NoError(t, second.readerErr())
562
563	// The daemon and broker outlive the closed tab: a fresh tab reaches a new
564	// session through the same broker without opening another carriage.
565	third := fixture.openTab(t)
566	awaitLogicalStream(t, fixture.broker.streams)
567	fixture.awaitPresentation(t, client.PresentAttached)
568	third.awaitText(t, "READY-")
569	require.NotEmpty(t, third.readyPID())
570	require.Equal(t, carriage, fixture.broker.physicalCount(),
571		"a later tab dialed a second physical carriage instead of reusing the pooled one")
572}
573
574// TestWebGatewayTabStaysPickerWhenDestinationIsRefused pins the failure
575// contract of the browser composition: a broker publication that carries no
576// local destination is a local refusal. The tab reports the same bounded
577// selection-unavailable notice, opens no stream, stays usable in the picker, and
578// keeps serving frames.
579func TestWebGatewayTabStaysPickerWhenDestinationIsRefused(t *testing.T) {
580	service := newTerminalCompositionService(ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1})
581	connector := portsmocks.NewMockBrokerConnector(t)
582	connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
583	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, func() ports.BrokerConnector {
584		return connector
585	})
586
587	tab := fixture.openTab(t)
588	fixture.awaitPresentation(t, client.PresentPicker)
589	require.Equal(t, client.LifecycleNoticeSelectionUnavailable, fixture.awaitNotice(t).Kind)
590	fixture.awaitFailure(t)
591
592	require.Empty(t, service.openedRequests(), "a refused destination never opens a broker stream")
593
594	// The run stays alive in the picker: it keeps painting and never ends the
595	// socket, exactly like the ordinary terminal run.
596	before := tab.frameCount()
597	tab.awaitMoreFrames(t, before+1)
598	select {
599	case <-tab.aborted:
600		t.Fatalf("the refused destination ended the browser run: %v", tab.readerErr())
601	default:
602	}
603}
604
605// TestWebGatewayTabReturnsToPickerOnBrokerLoss pins that losing the established
606// broker connection does not end the browser run and does not leave the tab
607// attached: the supervisor reports the typed broker loss, returns to the picker,
608// and keeps serving frames while it retries.
609func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
610	service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil))
611	connector := portsmocks.NewMockBrokerConnector(t)
612	var calls atomic.Int32
613	connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerNavigator, error) {
614		if calls.Add(1) == 1 {
615			return service, nil
616		}
617		return nil, errors.New("broker is gone")
618	}).Maybe()
619	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, func() ports.BrokerConnector { return connector })
620
621	tab := fixture.openTab(t)
622	fixture.awaitPresentation(t, client.PresentAttached)
623
624	// Losing the established connection is a broker loss, not an exit.
625	require.NoError(t, service.Close())
626
627	require.Equal(t, client.LifecycleNoticeBrokerLost, fixture.awaitNotice(t).Kind)
628	fixture.awaitPresentation(t, client.PresentPicker)
629	fixture.awaitFailure(t)
630
631	// The tab is still live and still serving: the reconnect cadence repaints the
632	// picker, so frames keep arriving and the socket is never closed by the loss.
633	before := tab.frameCount()
634	tab.awaitMoreFrames(t, before+1)
635	select {
636	case <-tab.aborted:
637		t.Fatalf("broker loss ended the browser run: %v", tab.readerErr())
638	default:
639	}
640	// A repaint can land before the first reconnect attempt, so wait for it.
641	require.Eventually(t, func() bool { return calls.Load() >= 2 }, brokerTestWait, 5*time.Millisecond,
642		"a lost connection must be retried, never fatal")
643}
644
645// TestWebCompositionUsesTheBrokerConnector pins the production composition
646// source: the gateway delegates every WebSocket to the shared broker client
647// over the lazy production connector, and keeps no direct daemon dialer, no
648// sessionwire carriage, no remote factory, and no attachment cache of its own.
649func TestWebCompositionUsesTheBrokerConnector(t *testing.T) {
650	sources := loadAppSources(t, false)
651	body := sources["web_daemon.go"]
652	require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
653	require.Contains(t, body, "newProductionBrokerConnector")
654	require.Contains(t, body, "localEphemeralNavigation()")
655	for _, forbidden := range []string{
656		"runAttachWithDeps", "runAttachDeps", "sessionwire", "localDaemonDialer", "dialOnlyLocalDialer",
657		"remoteDialerFactory", "launchConfig", "launchEndpoint", "AttachmentCache",
658		"createDetachedLocalSession", "platform.StateDir", "HostRegistry",
659	} {
660		require.NotContains(t, body, forbidden, "the web composition must not keep %q", forbidden)
661	}
662}
663
664// TestWebGatewayTerminalIsNotDialedBeforeConnect pins the laziness of the
665// production connector through the gateway composition: constructing the
666// connector performs no I/O, so an absent broker is an ordinary attempt failure
667// under the supervisor, never a gateway startup error.
668func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) {
669	var connects atomic.Int64
670	previous := connectProductionClientBroker
671	connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
672		connects.Add(1)
673		return nil, errors.New("broker not reachable")
674	}
675	t.Cleanup(func() { connectProductionClientBroker = previous })
676
677	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector)
678	tab := fixture.openTab(t)
679	// The tab's initial creation is pending, so it presents Connecting.
680	fixture.awaitPresentation(t, client.PresentConnecting)
681	// Construction performs no I/O: the gateway reaches the broker only through
682	// Connect, and an unreachable broker is an ordinary attempt failure.
683	require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond,
684		"the gateway must reach the broker only through Connect")
685	// A broker that is never reachable keeps the tab alive and repainting.
686	before := tab.frameCount()
687	tab.awaitMoreFrames(t, before+1)
688	select {
689	case <-tab.aborted:
690		t.Fatalf("an unreachable broker ended the browser run: %v", tab.readerErr())
691	default:
692	}
693}

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