detached ece7682

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 environment; a test
318	// drives the broker client with it unset.
319	t.Setenv("VEV", "")
320	settings, err := webterm.ParseSettings("", webterm.Origin)
321	require.NoError(t, err)
322	fixture := &webGatewayFixture{
323		broker:        broker,
324		token:         strings.Repeat("w", 43),
325		states:        make(chan client.State, 256),
326		notices:       make(chan client.LifecycleNotice, 64),
327		failures:      make(chan error, 64),
328		presentations: make(map[client.Presentation]int),
329	}
330	previousConnector := webTerminalConnector
331	previousCallbacks := terminalBrokerCallbacks
332	webTerminalConnector = connector
333	terminalBrokerCallbacks = func() terminalBrokerSeams {
334		return terminalBrokerSeams{
335			OnState: func(state client.State) {
336				// Keep a durable observation as well as the bounded notification.
337				// Awaiters intentionally consume notifications, so a later barrier
338				// must not infer that a presentation never happened merely because
339				// an earlier await already dequeued it.
340				fixture.mu.Lock()
341				fixture.presentations[state.Presentation]++
342				fixture.mu.Unlock()
343				select {
344				case fixture.states <- state:
345				default:
346				}
347			},
348			OnLifecycle: func(notice client.LifecycleNotice) {
349				select {
350				case fixture.notices <- notice:
351				default:
352				}
353			},
354			OnFailure: func(err error) {
355				select {
356				case fixture.failures <- err:
357				default:
358				}
359			},
360		}
361	}
362
363	ctx, cancel := context.WithCancel(context.Background())
364	handler, err := webterm.NewServer(ctx, settings, fixture.token, func(runCtx context.Context, terminal *webterm.Terminal) error {
365		return runWebTerminalClient(runCtx, terminal)
366	})
367	require.NoError(t, err)
368	fixture.handler = handler
369	fixture.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
370		r.Host = webterm.Address
371		handler.ServeHTTP(w, r)
372	}))
373	fixture.wsURL = "ws" + strings.TrimPrefix(fixture.server.URL, "http") + "/ws"
374	t.Cleanup(func() {
375		// Tabs first: the broker and daemon the fixture created are torn down by
376		// their own cleanup, which runs after this one.
377		fixture.closeTabs()
378		fixture.server.Close()
379		cancel()
380		handler.Wait()
381		webTerminalConnector = previousConnector
382		terminalBrokerCallbacks = previousCallbacks
383	})
384	return fixture
385}
386
387// openTab dials one authenticated browser tab and starts its single reader.
388func (f *webGatewayFixture) openTab(t *testing.T) *webBrowserTab {
389	t.Helper()
390	ctx, cancel := context.WithCancel(context.Background())
391	headers := http.Header{"Origin": {webterm.Origin}, "Cookie": {webGatewayCookieName + "=" + f.token}}
392	conn, _, err := websocket.Dial(ctx, f.wsURL, &websocket.DialOptions{HTTPHeader: headers})
393	if err != nil {
394		cancel()
395		require.NoError(t, err)
396	}
397	conn.SetReadLimit(64 << 20)
398	tab := &webBrowserTab{conn: conn, cancel: cancel, aborted: make(chan struct{}), changed: make(chan struct{}, 1)}
399	f.mu.Lock()
400	f.tabs = append(f.tabs, tab)
401	f.mu.Unlock()
402	go tab.run(ctx)
403	return tab
404}
405
406// closeTabs ends every tab the fixture started.
407func (f *webGatewayFixture) closeTabs() {
408	f.mu.Lock()
409	tabs := append([]*webBrowserTab(nil), f.tabs...)
410	f.mu.Unlock()
411	for _, tab := range tabs {
412		tab.close()
413	}
414}
415
416// awaitNotice waits for one bounded lifecycle notice.
417func (f *webGatewayFixture) awaitNotice(t *testing.T) client.LifecycleNotice {
418	t.Helper()
419	select {
420	case notice := <-f.notices:
421		return notice
422	case <-time.After(brokerTestWait):
423		t.Fatal("the browser run reported no lifecycle notice")
424		return client.LifecycleNotice{}
425	}
426}
427
428// awaitFailure waits for one typed failure surfaced without ending the run.
429func (f *webGatewayFixture) awaitFailure(t *testing.T) error {
430	t.Helper()
431	select {
432	case err := <-f.failures:
433		require.Error(t, err)
434		return err
435	case <-time.After(brokerTestWait):
436		t.Fatal("the browser run reported no failure")
437		return nil
438	}
439}
440
441// awaitPresentation waits for one supervisor presentation.
442func (f *webGatewayFixture) awaitPresentation(t *testing.T, want client.Presentation) client.State {
443	t.Helper()
444	deadline := time.NewTimer(brokerTestWait)
445	defer deadline.Stop()
446	for {
447		select {
448		case state := <-f.states:
449			if state.Presentation == want {
450				return state
451			}
452		case <-deadline.C:
453			t.Fatalf("the browser run never presented %v", want)
454			return client.State{}
455		}
456	}
457}
458
459// observedPresentations returns a defensive snapshot of all presentations
460// reported by every browser run in this fixture. Unlike states, this is a
461// durable barrier and therefore remains valid after awaitPresentation consumes
462// its edge-triggered notification.
463func (f *webGatewayFixture) observedPresentations() map[client.Presentation]int {
464	f.mu.Lock()
465	defer f.mu.Unlock()
466	observed := make(map[client.Presentation]int, len(f.presentations))
467	for presentation, count := range f.presentations {
468		observed[presentation] = count
469	}
470	return observed
471}
472
473// TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices pins the
474// product contract of two browser tabs: each tab owns exactly one broker
475// service and one logical stream (so detaching one is isolated), the two
476// sessions are distinct and render their own real content, a real session
477// action round-trips through the browser path, and the broker still dials
478// exactly one shared physical carriage for both.
479func TestWebGatewayTabsShareOneBrokerWithIndependentStreamsAndServices(t *testing.T) {
480	fixture := startWebGatewayFixture(t)
481
482	first := fixture.openTab(t)
483	awaitLogicalStream(t, fixture.broker.streams)
484	fixture.awaitPresentation(t, client.PresentAttached)
485	first.awaitText(t, "READY-")
486	firstPID := first.readyPID()
487	require.NotEmpty(t, firstPID, "an ephemeral creation renders its session shell")
488	carriageAfterFirst := fixture.broker.physicalCount()
489
490	second := fixture.openTab(t)
491	awaitLogicalStream(t, fixture.broker.streams)
492	fixture.awaitPresentation(t, client.PresentAttached)
493	second.awaitText(t, "READY-")
494	secondPID := second.readyPID()
495	require.NotEmpty(t, secondPID, "the second tab renders its own session shell")
496
497	// Independent services and streams: two admissions, and two distinct
498	// sessions even though both tabs share the broker process.
499	require.NotEqual(t, firstPID, secondPID, "two tabs must attach distinct sessions")
500	require.Equal(t, carriageAfterFirst, fixture.broker.physicalCount(),
501		"the second tab opened another daemonmux carriage instead of reusing the pooled one")
502
503	// The identical state machine: each browser run's initial creation presents
504	// Connecting then Attached exactly like every other frontend, never a
505	// picker flash first. Consult the durable observation barrier: the two
506	// Attached notifications above were consumed while synchronizing startup
507	// and must still count here.
508	require.Eventually(t, func() bool {
509		presentations := fixture.observedPresentations()
510		return presentations[client.PresentConnecting] >= 2 &&
511			presentations[client.PresentAttached] >= 2
512	}, brokerTestWait, 5*time.Millisecond, "both browser runs must present connecting and attached; observed %v", fixture.observedPresentations())
513	require.Zero(t, fixture.observedPresentations()[client.PresentPicker], "an initial creation never flashes the picker")
514
515	// Real content and real actions, and each tab renders only its own session.
516	first.submit(t, "PING-FIRST")
517	second.submit(t, "PING-SECOND")
518	first.awaitText(t, "PONG:PING-FIRST")
519	second.awaitText(t, "PONG:PING-SECOND")
520	first.requireTextAbsent(t, "PONG:PING-SECOND", 300*time.Millisecond)
521	second.requireTextAbsent(t, "PONG:PING-FIRST", 300*time.Millisecond)
522
523	require.NoError(t, first.readerErr())
524	require.NoError(t, second.readerErr())
525}
526
527// TestWebGatewayTabClosingIsIsolated pins that disconnecting one tab cancels
528// only that tab's supervisor, service, and logical stream: the shared daemon,
529// the other tab, and the pooled physical carriage survive, and a new tab still
530// reaches a session through the same broker.
531func TestWebGatewayTabClosingIsIsolated(t *testing.T) {
532	fixture := startWebGatewayFixture(t)
533
534	first := fixture.openTab(t)
535	awaitLogicalStream(t, fixture.broker.streams)
536	fixture.awaitPresentation(t, client.PresentAttached)
537	first.awaitText(t, "READY-")
538	carriage := fixture.broker.physicalCount()
539	first.submit(t, "PING-ONE")
540	first.awaitText(t, "PONG:PING-ONE")
541
542	second := fixture.openTab(t)
543	awaitLogicalStream(t, fixture.broker.streams)
544	fixture.awaitPresentation(t, client.PresentAttached)
545	second.awaitText(t, "READY-")
546
547	// Close the first tab and join that run before asserting on the survivor.
548	first.close()
549	select {
550	case <-first.aborted:
551	case <-time.After(brokerTestWait):
552		t.Fatal("the closed browser tab kept reading")
553	}
554
555	// The surviving tab keeps its own session, its own content, and its own
556	// service: the closed tab's session output never reaches the survivor.
557	second.submit(t, "PING-TWO")
558	second.awaitText(t, "PONG:PING-TWO")
559	second.requireTextAbsent(t, "PONG:PING-ONE", 150*time.Millisecond)
560	require.NoError(t, second.readerErr())
561
562	// The daemon and broker outlive the closed tab: a fresh tab reaches a new
563	// session through the same broker without opening another carriage.
564	third := fixture.openTab(t)
565	awaitLogicalStream(t, fixture.broker.streams)
566	fixture.awaitPresentation(t, client.PresentAttached)
567	third.awaitText(t, "READY-")
568	require.NotEmpty(t, third.readyPID())
569	require.Equal(t, carriage, fixture.broker.physicalCount(),
570		"a later tab dialed a second physical carriage instead of reusing the pooled one")
571}
572
573// TestWebGatewayTabStaysPickerWhenDestinationIsRefused pins the failure
574// contract of the browser composition: a broker publication that carries no
575// local destination is a local refusal. The tab reports the same bounded
576// selection-unavailable notice, opens no stream, stays usable in the picker, and
577// keeps serving frames.
578func TestWebGatewayTabStaysPickerWhenDestinationIsRefused(t *testing.T) {
579	service := newTerminalCompositionService(ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1})
580	connector := portsmocks.NewMockBrokerConnector(t)
581	connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
582	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, func() ports.BrokerConnector {
583		return connector
584	})
585
586	tab := fixture.openTab(t)
587	fixture.awaitPresentation(t, client.PresentPicker)
588	require.Equal(t, client.LifecycleNoticeSelectionUnavailable, fixture.awaitNotice(t).Kind)
589	fixture.awaitFailure(t)
590
591	require.Empty(t, service.openedRequests(), "a refused destination never opens a broker stream")
592
593	// The run stays alive in the picker: it keeps painting and never ends the
594	// socket, exactly like the ordinary terminal run.
595	before := tab.frameCount()
596	tab.awaitMoreFrames(t, before+1)
597	select {
598	case <-tab.aborted:
599		t.Fatalf("the refused destination ended the browser run: %v", tab.readerErr())
600	default:
601	}
602}
603
604// TestWebGatewayTabReturnsToPickerOnBrokerLoss pins that losing the established
605// broker connection does not end the browser run and does not leave the tab
606// attached: the supervisor reports the typed broker loss, returns to the picker,
607// and keeps serving frames while it retries.
608func TestWebGatewayTabReturnsToPickerOnBrokerLoss(t *testing.T) {
609	service := newTerminalCompositionService(terminalCompositionSnapshot([]string{"work"}, nil))
610	connector := portsmocks.NewMockBrokerConnector(t)
611	var calls atomic.Int32
612	connector.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (ports.BrokerNavigator, error) {
613		if calls.Add(1) == 1 {
614			return service, nil
615		}
616		return nil, errors.New("broker is gone")
617	}).Maybe()
618	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, func() ports.BrokerConnector { return connector })
619
620	tab := fixture.openTab(t)
621	fixture.awaitPresentation(t, client.PresentAttached)
622
623	// Losing the established connection is a broker loss, not an exit.
624	require.NoError(t, service.Close())
625
626	require.Equal(t, client.LifecycleNoticeBrokerLost, fixture.awaitNotice(t).Kind)
627	fixture.awaitPresentation(t, client.PresentPicker)
628	fixture.awaitFailure(t)
629
630	// The tab is still live and still serving: the reconnect cadence repaints the
631	// picker, so frames keep arriving and the socket is never closed by the loss.
632	before := tab.frameCount()
633	tab.awaitMoreFrames(t, before+1)
634	select {
635	case <-tab.aborted:
636		t.Fatalf("broker loss ended the browser run: %v", tab.readerErr())
637	default:
638	}
639	// A repaint can land before the first reconnect attempt, so wait for it.
640	require.Eventually(t, func() bool { return calls.Load() >= 2 }, brokerTestWait, 5*time.Millisecond,
641		"a lost connection must be retried, never fatal")
642}
643
644// TestWebCompositionUsesTheBrokerConnector pins the production composition
645// source: the gateway delegates every WebSocket to the shared broker client
646// over the lazy production connector, and keeps no direct daemon dialer, no
647// sessionwire carriage, no remote factory, and no attachment cache of its own.
648func TestWebCompositionUsesTheBrokerConnector(t *testing.T) {
649	sources := loadAppSources(t, false)
650	body := sources["web_daemon.go"]
651	require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
652	require.Contains(t, body, "newProductionBrokerConnector")
653	require.Contains(t, body, "localEphemeralNavigation()")
654	for _, forbidden := range []string{
655		"runAttachWithDeps", "runAttachDeps", "sessionwire", "localDaemonDialer", "dialOnlyLocalDialer",
656		"remoteDialerFactory", "launchConfig", "launchEndpoint", "AttachmentCache",
657		"createDetachedLocalSession", "platform.StateDir", "HostRegistry",
658	} {
659		require.NotContains(t, body, forbidden, "the web composition must not keep %q", forbidden)
660	}
661}
662
663// TestWebGatewayTerminalIsNotDialedBeforeConnect pins the laziness of the
664// production connector through the gateway composition: constructing the
665// connector performs no I/O, so an absent broker is an ordinary attempt failure
666// under the supervisor, never a gateway startup error.
667func TestWebGatewayTerminalIsNotDialedBeforeConnect(t *testing.T) {
668	var connects atomic.Int64
669	previous := connectProductionClientBroker
670	connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
671		connects.Add(1)
672		return nil, errors.New("broker not reachable")
673	}
674	t.Cleanup(func() { connectProductionClientBroker = previous })
675
676	fixture := startWebGatewayFixtureOver(t, offlineClientFixture{}, newProductionBrokerConnector)
677	tab := fixture.openTab(t)
678	// The tab's initial creation is pending, so it presents Connecting.
679	fixture.awaitPresentation(t, client.PresentConnecting)
680	// Construction performs no I/O: the gateway reaches the broker only through
681	// Connect, and an unreachable broker is an ordinary attempt failure.
682	require.Eventually(t, func() bool { return connects.Load() >= 1 }, brokerTestWait, 5*time.Millisecond,
683		"the gateway must reach the broker only through Connect")
684	// A broker that is never reachable keeps the tab alive and repainting.
685	before := tab.frameCount()
686	tab.awaitMoreFrames(t, before+1)
687	select {
688	case <-tab.aborted:
689		t.Fatalf("an unreachable broker ended the browser run: %v", tab.readerErr())
690	default:
691	}
692}

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