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}