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}