package client import ( "context" "strings" "sync" "testing" "time" "github.com/stretchr/testify/require" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/protocol/catalogue" ) // Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real // supervisor, host, foreground, and worker over the in-package broker fakes // with the scripted test clock: no sleeps, and every wait is bounded. // recordOp records one picker decision on the scripted picker, exactly like // the controller's coalesced wake. func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) { p.mu.Lock() p.op = op p.key = key p.request = request p.mu.Unlock() select { case p.opsReady <- struct{}{}: default: } } // opTaken reports whether the supervisor consumed the recorded decision. func (p *attachTestPicker) opTaken() bool { p.mu.Lock() defer p.mu.Unlock() return p.op == (pickerOp{}) } // attachLiveSession commits one attachment on the harness and returns its // stream once the attachment is presented. func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream { t.Helper() stream := newSessionTestStream() harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { return stream, nil }) picker.commit(sessionTestRequest(true)) awaitStreamHello(t, &sync.Mutex{}, &stream) deliverReadyStream(t, stream) awaitAttachedState(t, harness.sup) return stream } // publishLiveLocal publishes a broker catalogue whose local daemon runs // target, so an overlay commit to it may switch in place. func publishLiveLocal(harness *attachTestHarness, target protocol.ExactSessionTarget) { harness.service.publishSnapshot(routeTestSnapshot(routeTestLocal(pickerTestSessionWithLifecycle(target.SessionName, target.LifecycleID)))) } func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) { t.Helper() require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want) } // awaitSent waits for the first client message matching match. func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage { t.Helper() var found protocol.ClientMessage require.Eventually(t, func() bool { for _, message := range stream.messages() { if match(message) { found = message return true } } return false }, 5*time.Second, time.Millisecond, "client never sent %s", what) return found } func countSent[T protocol.ClientMessage](stream *sessionTestStream) int { count := 0 for _, message := range stream.messages() { if _, ok := message.(T); ok { count++ } } return count } func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool { _, ok := message.(T) return ok } // laterOutput is a full frame on a later epoch, which the attachment accepts // after its initial publication. func laterOutput(epoch, publication uint64, data string) protocol.Output { output := sessionTestOutput(publication, data) output.Epoch = epoch return output } func navigationOffer(id uint64) protocol.PickerOffer { return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "} } // TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker // over the live attachment, its input and output stay with the picker, and // every cancel (and a commit to the attached session) returns to the very same // attachment without reconnecting, then asks for an authoritative repaint. func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) { tests := []struct { name string op pickerOp key string }{ {name: "escape or q cancels", op: pickerOp{close: true}}, {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}}, {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) stream := attachLiveSession(t, harness, picker) foreground := harness.sup.attachments.authority.foreground() require.NotNil(t, foreground) require.False(t, picker.owns()) stream.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) require.True(t, picker.owns(), "the picker owns input while it is open") // Input belongs to the picker: it is consumed, never sent as session // Input. harness.reader.push([]byte("j")) require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond) require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session") // Attachment output is applied and acknowledged, never written over // the picker. acks := countSent[protocol.Ack](stream) stream.deliver(laterOutput(2, 2, "hidden-frame")) require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) require.NotContains(t, harness.terminal.written(), "hidden-frame") picker.recordOp(tt.op, tt.key, sessionTestRequest(true)) awaitPresentation(t, harness.sup, PresentAttached) awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment") require.False(t, stream.closedNow()) require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect") require.Zero(t, countSent[protocol.Detach](stream)) require.False(t, picker.owns()) // The session owns input again. harness.reader.push([]byte("z")) input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) require.Equal(t, []byte("z"), input.Data) select { case <-harness.runDone: t.Fatal("cancelling the overlay ended the process") default: } }) } } // TestPickerOverlayCommitElsewhereSwitchesAttachment pins how a commit to // another session leaves the live attachment. A session on the same daemon // moves the attachment in place with no new stream or Hello; if the daemon // refuses, or the session is on another daemon, the attachment detaches // cleanly and the target attaches through Connecting, never through the plain // picker. func TestPickerOverlayCommitElsewhereSwitchesAttachment(t *testing.T) { beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} tests := []struct { name string local bool stopped bool // the catalogue shows the target stopped inPlace bool // the daemon accepts the in-place switch; false refuses it }{ {name: "same daemon switches in place", local: true, inPlace: true}, {name: "a refused in-place switch reconnects", local: true}, {name: "a stopped session reconnects at once", local: true, stopped: true}, {name: "another daemon reconnects"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() notices := make(chan LifecycleNotice, 8) harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice } }) if tt.stopped { harness.service.publishSnapshot(routeTestSnapshot(routeTestLocal(catalogue.RemoteCatalogSession{LifecycleID: beta.LifecycleID, Name: beta.SessionName, State: catalogue.RemoteCatalogSessionDown}))) } else { publishLiveLocal(harness, beta) } first := attachLiveSession(t, harness, picker) foreground := harness.sup.attachments.authority.foreground() first.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) var mu sync.Mutex second := newSessionTestStream() harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { return second, nil }) other := sessionTestRequest(tt.local) other.Target = beta picker.recordOp(pickerOp{commit: true}, "row", other) if tt.local && !tt.stopped { request := awaitSent(t, first, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest) require.Equal(t, beta, request.Target) awaitPresentation(t, harness.sup, PresentAttached) if tt.inPlace { destination := laterOutput(2, 2, "\x1b[Hbeta") destination.Context.Route.Target = beta first.deliver(destination) require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "beta") }, 5*time.Second, time.Millisecond) require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "an in-place switch keeps the attachment") require.Len(t, harness.service.openedRequests(), 1, "an in-place switch never reconnects") require.Zero(t, countSent[protocol.Detach](first)) require.Zero(t, countSent[protocol.OutputResetRequest](first), "an in-place switch never repaints the source") return } first.deliver(protocol.SamePeerSwitchFailure{RequestID: request.RequestID, Code: protocol.SamePeerSwitchStaleTarget}) } awaitSent(t, first, "Detach", isSent[protocol.Detach]) awaitStreamHello(t, &mu, &second) second.deliver(protocol.Welcome{SessionName: "beta"}) output := sessionTestOutput(1, "\x1b[Hbeta") output.Context.Route.Target = other.Target second.deliver(output) awaitAttachedState(t, harness.sup) opened := harness.service.openedRequests() require.Len(t, opened, 2) require.Equal(t, other.Target, opened[1].Target) require.True(t, first.closedNow(), "the previous attachment is released") if tt.stopped || !tt.local { require.Zero(t, countSent[protocol.SamePeerSwitchRequest](first), "only a live session on the serving daemon is tried in place") } select { case notice := <-notices: t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice) default: } }) } } // TestPickerOverlaySettlesUIActions pins UI-driver settlement across the // client picker overlay: the action that opened it settles on the daemon // receipt fenced on output the overlay applied without writing, an action the // picker consumes settles at the supervisor boundary without a daemon fence, // and a picker commit elsewhere follows the swap and settles on the // destination's first committed publication. func TestPickerOverlaySettlesUIActions(t *testing.T) { alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} type actionOutcome struct { result ports.UIActionResult err error } tests := []struct { name string // overlayFirst opens the overlay before the action is admitted. overlayFirst bool keys []string // commit scripts a picker commit elsewhere for the consumed read. commit bool // inPlace commits a session on the serving daemon, which switches the // live attachment instead of reconnecting. inPlace bool wantTarget protocol.ExactSessionTarget wantFence bool }{ {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true}, {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha}, {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta}, {name: "an in-place picker commit settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, inPlace: true, wantTarget: beta}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() var ui *UI harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) { ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock) cfg.UI = ui }) publishLiveLocal(harness, beta) stream := attachLiveSession(t, harness, picker) attached := sessionTestOutput(2, "\x1b[Hattached") attached.Full, attached.Base, attached.New = false, 1, 2 stream.deliver(attached) var snapshot ports.UISnapshot require.Eventually(t, func() bool { var err error snapshot, err = ui.Capture(ui.Handle()) return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2 }, 5*time.Second, time.Millisecond) if tt.overlayFirst { stream.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) // The composition paints the overlay with the live attachment's // context, so the attachment stays the actionable generation. overlayContext, ok := ui.OverlayContext() require.True(t, ok) require.Equal(t, ports.UIStatusAttached, overlayContext.Status) require.Equal(t, snapshot.Context.Generation, overlayContext.Generation) require.Equal(t, alpha, overlayContext.Route.Target) } var mu sync.Mutex second := newSessionTestStream() if tt.commit { harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { return second, nil }) // A remote target is another daemon, so it swaps. other := sessionTestRequest(tt.inPlace) other.Target = beta picker.decideOnConsume(pickerOp{commit: true}, other) } fences := countSent[protocol.UIFence](stream) consumed := picker.consumedCount() done := make(chan actionOutcome, 1) go func() { result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys}) done <- actionOutcome{result: result, err: err} }() if !tt.overlayFirst { // The Enter reaches the daemon, whose command offers the picker; // the palette-close output then arrives under the overlay. fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence) stream.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) hidden := sessionTestOutput(3, "\x1b[Hhidden") hidden.Full, hidden.Base, hidden.New = false, 2, 3 stream.deliver(hidden) require.Eventually(t, func() bool { ui.mu.Lock() defer ui.mu.Unlock() return ui.suppressed.ViewPublication == 3 }, 5*time.Second, time.Millisecond, "the suppressed output was never applied") stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed}) } if tt.inPlace { awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]) destination := laterOutput(2, 3, "\x1b[Hbeta") destination.Context.Route.Target = beta stream.deliver(destination) } else if tt.commit { awaitSent(t, stream, "Detach", isSent[protocol.Detach]) awaitStreamHello(t, &mu, &second) second.deliver(protocol.Welcome{SessionName: "beta"}) output := sessionTestOutput(1, "\x1b[Hbeta") output.Context.Route.Target = beta second.deliver(output) } var outcome actionOutcome select { case outcome = <-done: case <-time.After(5 * time.Second): t.Fatal("the UI action never settled across the picker overlay") } require.NoError(t, outcome.err) require.Equal(t, ports.UIActionProcessed, outcome.result.Status) require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status) require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target) require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker") if tt.overlayFirst { require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input") } if !tt.wantFence { require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon") require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session") } if tt.inPlace { require.Equal(t, snapshot.Context.Generation, outcome.result.Context.Generation, "an in-place switch keeps the generation") require.Len(t, harness.service.openedRequests(), 1, "an in-place switch never reconnects") } else if tt.commit { require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation") opened := harness.service.openedRequests() require.Len(t, opened, 2) require.Equal(t, beta, opened[1].Target) require.Greater(t, opened[1].Stream, picker.lastResolvedStream(), "the swap opens a stream identity allocated at open time, not the one reserved at commit") } }) } } // TestPickerCloseWithoutAttachment pins the unattached decision: with nothing // to return to, Escape and q keep the picker (and the process) alive; only the // explicit Ctrl+C exit ends the run. func TestPickerCloseWithoutAttachment(t *testing.T) { tests := []struct { name string op pickerOp wantExit bool }{ {name: "escape or q keeps the picker", op: pickerOp{close: true}}, {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{}) if tt.wantExit { require.NoError(t, harness.waitRun(t)) return } // Prove the close was consumed, then that the run survived it: a // later commit is still admitted. require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond) stream := newSessionTestStream() harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { return stream, nil }) picker.commit(sessionTestRequest(true)) awaitStreamHello(t, &sync.Mutex{}, &stream) select { case <-harness.runDone: t.Fatal("a cancel with no attachment ended the process") default: } }) } } // moveSnapshot is the two-destination publication a move interaction opens // with; both rows authorise a move. func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot { return protocol.PickerSnapshot{ InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: []protocol.PickerLine{ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove}, {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove}, }, Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0}, } } func moveOffer(interaction uint64) protocol.PickerOffer { return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "} } // openMovePicker opens one move interaction on a live attachment and waits // until its box is drawn. func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) { t.Helper() stream.deliver(moveOffer(interaction)) stream.deliver(moveSnapshot(interaction, 1)) require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn") } // TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer // opens a working destination picker whose input never reaches the session, // and every exit path answers with the typed replies and hands input back. func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) { tests := []struct { name string keys []string check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) }{ { name: "enter commits the cursor destination", keys: []string{"\x1b[B", "\r"}, check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection) require.Equal(t, uint64(7), selection.InteractionID) require.Equal(t, "serving", selection.SourceID) require.Equal(t, uint64(1), selection.SourceRevision) require.Equal(t, "bb/second", selection.Key) require.Equal(t, protocol.PickerActionMove, selection.Action) // The daemon closes the interaction; the terminal returns to the // attachment with an authoritative repaint. stream.deliver(protocol.PickerClosed{InteractionID: 7}) awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) }, }, { name: "q cancels with a typed close", keys: []string{"q"}, check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) require.Equal(t, uint64(7), closed.InteractionID) awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest]) }, }, { name: "a lone escape cancels once its window elapses", keys: []string{"\x1b"}, check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) { // The input path first withholds the Escape as a possible // DECRQM reply prefix; its ambiguity window hands it on. var marker *supervisorTestTimer require.Eventually(t, func() bool { for _, timer := range harness.clock.snapshotTimers() { if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() { marker = timer return true } } return false }, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed") marker.fire() var escape *supervisorTestTimer require.Eventually(t, func() bool { for _, timer := range harness.clock.snapshotTimers() { if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() { escape = timer return true } } return false }, 5*time.Second, time.Millisecond, "the escape window was never armed") require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early") escape.fire() closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) require.Equal(t, uint64(7), closed.InteractionID) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) stream := attachLiveSession(t, harness, picker) openMovePicker(t, harness, stream, 7) // Output under the box is applied and acknowledged, never written. acks := countSent[protocol.Ack](stream) stream.deliver(laterOutput(2, 2, "hidden-frame")) require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond) require.NotContains(t, harness.terminal.written(), "hidden-frame") for _, key := range tt.keys { harness.reader.push([]byte(key)) } tt.check(t, harness, stream) require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session") // Input is never swallowed after the picker ended: the next key is // session Input again. harness.reader.push([]byte("z")) input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input) require.Equal(t, []byte("z"), input.Data) require.Equal(t, PresentAttached, harness.sup.State().Presentation) }) } } // TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the // move picker cannot be presented: the daemon's interaction is closed at once // instead of swallowing input with nothing on screen. func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) stream := attachLiveSession(t, harness, picker) stream.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) stream.deliver(moveOffer(9)) closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose) require.Equal(t, uint64(9), closed.InteractionID) require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation) } // TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and // the paint fence: only a committed attachment hosts the overlay, cancel // returns to Attached, and a settled attachment falls back to the plain // picker. func TestSupervisorReducerPickerOverlay(t *testing.T) { tests := []struct { name string from Presentation event supervisorEventKind want Presentation wantPaint bool }{ {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true}, {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting}, {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true}, {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached}, {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached}, {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true}, {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event}) require.Equal(t, tt.want, state.Presentation) require.Equal(t, ConnectivityReady, state.Connectivity) require.Equal(t, tt.wantPaint, PickerPresentation(state)) }) } } // TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal // presentation pin: the move picker is a bordered box over the session, never // a full-screen takeover, so it neither clears the screen nor spans it. func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) stream := attachLiveSession(t, harness, picker) before := len(harness.terminal.written()) openMovePicker(t, harness, stream, 7) frame := harness.terminal.written()[before:] require.Contains(t, frame, "first", "the destination rows must be drawn") require.Contains(t, frame, "─", "the modal border must be drawn") require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it") require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box") } // TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape // keypress (one lone byte) cancels once the disambiguation window elapses on // the controller clock, and that a following byte disarms the window instead. func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) { tests := []struct { name string follow []byte wantClose bool }{ {name: "window elapses into Escape", wantClose: true}, {name: "a following byte resolves the prefix itself", follow: []byte("[B")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { controller, clock := pickerTestController(t) controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) require.True(t, controller.ConsumeTerminalRead([]byte("\x1b"))) escape := clock.awaitTimer(t) require.Equal(t, pickerEscapeDeadline, escape.delay) if tt.follow != nil { require.True(t, controller.ConsumeTerminalRead(tt.follow)) require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window") op, _ := controller.TakeOp() require.False(t, op.close) return } escape.fire() select { case <-controller.OpsReady(): case <-time.After(5 * time.Second): t.Fatal("the elapsed window never woke the supervisor") } op, _ := controller.TakeOp() require.Equal(t, tt.wantClose, op.close) require.False(t, op.exit, "Escape is a cancel, never the explicit exit") }) } } // TestPickerOverlayInPlaceSupersededByDaemonOffer pins that a daemon same-peer // offer arriving while a picker in-place switch is pending wins: the picker's // switch is dropped, never turned into a reconnect. If the daemon then keeps // the source, the source is repainted over the closed picker box. func TestPickerOverlayInPlaceSupersededByDaemonOffer(t *testing.T) { beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} gamma := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "gamma"} tests := []struct { name string refuseOffer bool wantRepaints int }{ {name: "the daemon offer arrives"}, {name: "the daemon offer is refused", refuseOffer: true, wantRepaints: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { picker := newAttachTestPicker() harness := startAttachHarness(t, picker) publishLiveLocal(harness, beta) first := attachLiveSession(t, harness, picker) first.deliver(navigationOffer(1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) other := sessionTestRequest(true) other.Target = beta picker.recordOp(pickerOp{commit: true}, "row", other) toBeta := awaitSent(t, first, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest) first.deliver(protocol.AttachTarget{Session: gamma.SessionName, Intent: protocol.IntentAttach, ExactTarget: &gamma, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true}) var toGamma protocol.SamePeerSwitchRequest require.Eventually(t, func() bool { for _, message := range first.messages() { if request, ok := message.(protocol.SamePeerSwitchRequest); ok && request.Target == gamma { toGamma = request return true } } return false }, 5*time.Second, time.Millisecond) // The daemon refuses the older choice because its own offer is pending. first.deliver(protocol.SamePeerSwitchFailure{RequestID: toBeta.RequestID, Code: protocol.SamePeerSwitchStaleTarget}) if tt.refuseOffer { first.deliver(protocol.SamePeerSwitchFailure{RequestID: toGamma.RequestID, Code: protocol.SamePeerSwitchStaleTarget}) awaitSent(t, first, "OutputResetRequest", isSent[protocol.OutputResetRequest]) } else { destination := laterOutput(2, 2, "\x1b[Hgamma") destination.Context.Route.Target = gamma first.deliver(destination) require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "gamma") }, 5*time.Second, time.Millisecond) } require.Len(t, harness.service.openedRequests(), 1, "a superseded in-place switch never reconnects") require.Zero(t, countSent[protocol.Detach](first)) require.Equal(t, tt.wantRepaints, countSent[protocol.OutputResetRequest](first)) }) } } // TestPickerOverlayRepeatInPlaceChoice pins that choosing the destination of // an in-flight in-place switch again is a close, not a second switch. func TestPickerOverlayRepeatInPlaceChoice(t *testing.T) { beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} picker := newAttachTestPicker() harness := startAttachHarness(t, picker) publishLiveLocal(harness, beta) first := attachLiveSession(t, harness, picker) other := sessionTestRequest(true) other.Target = beta for id := range uint64(2) { first.deliver(navigationOffer(id + 1)) awaitPresentation(t, harness.sup, PresentAttachedPicker) picker.recordOp(pickerOp{commit: true}, "row", other) awaitPresentation(t, harness.sup, PresentAttached) } destination := laterOutput(2, 2, "\x1b[Hbeta") destination.Context.Route.Target = beta first.deliver(destination) require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "beta") }, 5*time.Second, time.Millisecond) require.Equal(t, 1, countSent[protocol.SamePeerSwitchRequest](first)) require.Len(t, harness.service.openedRequests(), 1) }