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/coder/websocket"
19 "github.com/stretchr/testify/require"
20
21 "github.com/bnema/vev/internal/adapters/brokeripc"
22 "github.com/bnema/vev/internal/adapters/webterm"
23 "github.com/bnema/vev/internal/ports"
24 portsmocks "github.com/bnema/vev/internal/ports/mocks"
25 "github.com/bnema/vev/internal/usecase/client"
26 "github.com/stretchr/testify/mock"
27)
28
29// Browser gateway broker composition tests (Plan 001 P7.4d).
30//
31// The gateway server is one runBrokerClient per authenticated WebSocket. These
32// tests drive the production composition — runWebTerminalClient, the real
33// webterm.Terminal, and the real webterm.Server admission — over a private
34// broker sandbox whose daemon serves a real shell, so each assertion holds for
35// the production entry point. Only the connector composition seam and the
36// observation callbacks are substituted, exactly as the terminal composition
37// tests substitute theirs.
38//
39// Ownership is one-directional: the fixture stops the tabs it started *before*
40// the broker and daemon it created are torn down, and never interprets a tab's
41// disconnect as proof that the shared daemon went away.
42
43const (
44 // webGatewayCookieName mirrors the adapter's authenticated cookie. It is
45 // duplicated here because the adapter keeps it unexported.
46 webGatewayCookieName = "vev-web-session"
47 // webGatewayShellScript prints a per-session ready marker and answers one
48 // PING line with a PONG line, so a tab proves real session content and a
49 // real session action rather than a parked shell.
50 webGatewayShellScript = `printf 'READY-%s\n' "$$"; trap : TERM; while :; do read line || exit; case "$line" in PING*) printf 'PONG:%s\n' "$line";; esac; done`
51)
52
53var webGatewayReadyPID = regexp.MustCompile(`READY-(\d+)`)
54
55// webFrame is one gateway websocket frame: the transactional VT update plus the
56// mouse-tracking flag.
57type webFrame struct {
58 Update json.RawMessage `json:"update"`
59 Mouse bool `json:"mouse"`
60}
61
62// webUpdate is the adapter's rendered update, decoded only far enough to read
63// the committed cell text.
64type webUpdate struct {
65 SchemaVersion uint16 `json:"schemaVersion"`
66 Width int `json:"width"`
67 Height int `json:"height"`
68 Snapshot bool `json:"snapshot"`
69 Rows []struct {
70 Row int `json:"row"`
71 Cells []struct {
72 Column int `json:"column"`
73 Width int `json:"width"`
74 Text string `json:"text"`
75 } `json:"cells"`
76 } `json:"rows"`
77}
78
79// text returns the whole rendered frame as row-major text, which is exactly
80// what the browser displays.
81func (u webUpdate) text() string {
82 var builder strings.Builder
83 for _, row := range u.Rows {
84 for _, cell := range row.Cells {
85 builder.WriteString(cell.Text)
86 }
87 builder.WriteByte('\n')
88 }
89 return builder.String()
90}
91
92// maxWebTabText bounds the accumulated frame text so a long test cannot grow
93// without bound.
94const maxWebTabText = 1 << 20
95
96// webBrowserTab is one authenticated browser terminal connection plus its
97// single owned reader. The reader is the only consumer of the socket, so an
98// assertion that is not reading can never cancel another read or lose a frame.
99type webBrowserTab struct {
100 conn *websocket.Conn
101 cancel context.CancelFunc
102 aborted chan struct{}
103 changed chan struct{}
104 closeOnce sync.Once
105
106 mu sync.Mutex
107 text string
108 frames int
109 readErr error
110}
111
112func (tab *webBrowserTab) run(ctx context.Context) {
113 defer close(tab.aborted)
114 for {
115 kind, data, err := tab.conn.Read(ctx)
116 if err != nil {
117 tab.setReadErr(err)
118 return
119 }
120 if kind != websocket.MessageText {
121 continue
122 }
123 var frame webFrame
124 if err := json.Unmarshal(data, &frame); err != nil {
125 tab.setReadErr(err)
126 return
127 }
128 if len(frame.Update) == 0 {
129 continue
130 }
131 var update webUpdate
132 if err := json.Unmarshal(frame.Update, &update); err != nil {
133 tab.setReadErr(err)
134 return
135 }
136 tab.append(update.text())
137 }
138}
139
140func (tab *webBrowserTab) append(text string) {
141 tab.mu.Lock()
142 tab.text += text
143 if len(tab.text) > maxWebTabText {
144 tab.text = tab.text[len(tab.text)-maxWebTabText:]
145 }
146 tab.frames++
147 tab.mu.Unlock()
148 select {
149 case tab.changed <- struct{}{}:
150 default:
151 }
152}
153
154func (tab *webBrowserTab) setReadErr(err error) {
155 tab.mu.Lock()
156 tab.readErr = err
157 tab.mu.Unlock()
158 select {
159 case tab.changed <- struct{}{}:
160 default:
161 }
162}
163
164func (tab *webBrowserTab) accumulated() string {
165 tab.mu.Lock()
166 defer tab.mu.Unlock()
167 return tab.text
168}
169
170func (tab *webBrowserTab) frameCount() int {
171 tab.mu.Lock()
172 defer tab.mu.Unlock()
173 return tab.frames
174}
175
176func (tab *webBrowserTab) readerErr() error {
177 tab.mu.Lock()
178 defer tab.mu.Unlock()
179 return tab.readErr
180}
181
182// close ends exactly this tab: the socket and this run's own context.
183func (tab *webBrowserTab) close() {
184 tab.closeOnce.Do(func() {
185 _ = tab.conn.CloseNow()
186 tab.cancel()
187 })
188}
189
190// sendText types one browser text event at the tab.
191func (tab *webBrowserTab) sendText(t *testing.T, text string) {
192 t.Helper()
193 payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "text", "text": text})
194 require.NoError(t, err)
195 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
196}
197
198// sendEnter sends one browser Enter key event, which the adapter encodes as CR.
199// A text event deliberately cannot carry a control byte.
200func (tab *webBrowserTab) sendEnter(t *testing.T) {
201 t.Helper()
202 payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "key", "key": "Enter", "code": "Enter"})
203 require.NoError(t, err)
204 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
205}
206
207// submit types one command line and presses Enter: one real session action.
208func (tab *webBrowserTab) submit(t *testing.T, line string) {
209 t.Helper()
210 tab.sendText(t, line)
211 tab.sendEnter(t)
212}
213
214// awaitText waits until the accumulated frames contain marker.
215func (tab *webBrowserTab) awaitText(t *testing.T, marker string) {
216 t.Helper()
217 deadline := time.NewTimer(brokerTestWait)
218 defer deadline.Stop()
219 for {
220 if strings.Contains(tab.accumulated(), marker) {
221 return
222 }
223 select {
224 case <-tab.changed:
225 case <-tab.aborted:
226 t.Fatalf("browser tab ended before rendering %q (reader error: %v); accumulated: %q", marker, tab.readerErr(), tab.accumulated())
227 case <-deadline.C:
228 t.Fatalf("browser tab never rendered %q; accumulated: %q", marker, tab.accumulated())
229 }
230 }
231}
232
233// requireTextAbsent asserts marker is not rendered within the observation
234// window. It is the independence assertion: one tab's session content must
235// never appear in another tab's terminal.
236func (tab *webBrowserTab) requireTextAbsent(t *testing.T, marker string, window time.Duration) {
237 t.Helper()
238 deadline := time.NewTimer(window)
239 defer deadline.Stop()
240 for {
241 require.NotContains(t, tab.accumulated(), marker, "an independent tab rendered another tab's session content")
242 select {
243 case <-tab.changed:
244 case <-tab.aborted:
245 return
246 case <-deadline.C:
247 return
248 }
249 }
250}
251
252// awaitMoreFrames waits until at least count frames were rendered, proving the
253// run is still serving after a failure.
254func (tab *webBrowserTab) awaitMoreFrames(t *testing.T, count int) {
255 t.Helper()
256 deadline := time.NewTimer(brokerTestWait)
257 defer deadline.Stop()
258 for {
259 if tab.frameCount() >= count {
260 return
261 }
262 select {
263 case <-tab.changed:
264 case <-tab.aborted:
265 t.Fatalf("browser tab ended before rendering %d frames (reader error: %v)", count, tab.readerErr())
266 case <-deadline.C:
267 t.Fatalf("browser tab rendered only %d frames, want %d", tab.frameCount(), count)
268 }
269 }
270}
271
272// readyPID returns the session shell PID the tab rendered, or "" while no ready
273// marker arrived. Two tabs must report distinct PIDs: their sessions are
274// separate even though they share one broker and one physical carriage.
275func (tab *webBrowserTab) readyPID() string {
276 match := webGatewayReadyPID.FindStringSubmatch(tab.accumulated())
277 if len(match) != 2 {
278 return ""
279 }
280 return match[1]
281}
282
283// webGatewayFixture is the shared private broker, server, and tab registry of
284// one browser composition test family.
285type webGatewayFixture struct {
286 broker offlineClientFixture
287 handler *webterm.Server
288 server *httptest.Server
289 token string
290 wsURL string
291
292 states chan client.State
293 notices chan client.LifecycleNotice
294 failures chan error
295
296 mu sync.Mutex
297 tabs []*webBrowserTab
298 presentations map[client.Presentation]int
299}
300
301// startWebGatewayFixture provisions one private broker sandbox with a real
302// daemon shell, then one authenticated gateway server whose every WebSocket runs
303// the production runWebTerminalClient composition over that sandbox.
304func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
305 t.Helper()
306 broker := startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", webGatewayShellScript})
307 return startWebGatewayFixtureOver(t, broker, func() ports.BrokerConnector {
308 return brokeripc.NewConnector(broker.socket, brokeripc.Config{}).Navigator()
309 })
310}
311
312// startWebGatewayFixtureOver starts one gateway over an explicitly supplied
313// connector composition, so a test can script the broker without a process.
314func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture {
315 t.Helper()
316 // The gateway composition reads the nested-session and transport environment;
317 // a test drives the broker client with both unset.
318 t.Setenv("VEV", "")
319 t.Setenv(envRemoteTransport, "")
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}