refactor(client): unify session connections
- commit
- 004db2e8c3293f3e8a67c5d0d4ed4452ac3ce7d4
- author
- brice · · 2026-08-03 10:49 UTC
- committer
- brice · · 2026-08-04 07:16 UTC
- parent
- ee0f20d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +228 -14
M internal/app/run.go +11 -1
| @@ -208,6 +208,14 @@ func parseArgs(args []string) (command, error) { | |||
| 208 | 208 | } | |
| 209 | 209 | cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]} | |
| 210 | 210 | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | |
| 211 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |
| 212 | + | return command{}, err | |
| 213 | + | } | |
| 214 | + | if session != "" { | |
| 215 | + | if err := domain.ValidateSessionName(session); err != nil { | |
| 216 | + | return command{}, err | |
| 217 | + | } | |
| 218 | + | } | |
| 211 | 219 | cmd.remoteTarget = target | |
| 212 | 220 | cmd.name = session | |
| 213 | 221 | if session == "" { | |
| @@ -840,7 +848,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 840 | 848 | Logger: log, | |
| 841 | 849 | RuntimeObserver: deps.runtimeObserver, | |
| 842 | 850 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | |
| 843 | - | }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true}) | |
| 851 | + | Remote: true, | |
| 852 | + | }, client.AttachRequest{Intent: intent, SessionName: name}) | |
| 844 | 853 | } | |
| 845 | 854 | ||
| 846 | 855 | localDialer := deps.localDialer | |
| @@ -859,6 +868,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 859 | 868 | Clock: clock.New(), | |
| 860 | 869 | Logger: log, | |
| 861 | 870 | RuntimeObserver: deps.runtimeObserver, | |
| 871 | + | Remote: false, | |
| 862 | 872 | }, client.AttachRequest{Intent: intent, SessionName: name}) | |
| 863 | 873 | }, | |
| 864 | 874 | killDaemon: requestDaemonStop, | |
| @@ -208,6 +208,14 @@ func parseArgs(args []string) (command, error) { | |||||
| 208 | } | 208 | } | ||
| 209 | cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]} | 209 | cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]} | ||
| 210 | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | 210 | if target, session, ok := parseRemoteAttachTarget(args[1]); ok { | ||
| 211 | + | if err := domain.ValidateRemoteHostTarget(target); err != nil { | |||
| 212 | + | return command{}, err | |||
| 213 | + | } | |||
| 214 | + | if session != "" { | |||
| 215 | + | if err := domain.ValidateSessionName(session); err != nil { | |||
| 216 | + | return command{}, err | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 211 | cmd.remoteTarget = target | 219 | cmd.remoteTarget = target | ||
| 212 | cmd.name = session | 220 | cmd.name = session | ||
| 213 | if session == "" { | 221 | if session == "" { | ||
| @@ -840,7 +848,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 840 | Logger: log, | 848 | Logger: log, | ||
| 841 | RuntimeObserver: deps.runtimeObserver, | 849 | RuntimeObserver: deps.runtimeObserver, | ||
| 842 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | 850 | RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log), | ||
| 843 | - | }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true}) | 851 | + | Remote: true, |
| 852 | + | }, client.AttachRequest{Intent: intent, SessionName: name}) | |||
| 844 | } | 853 | } | ||
| 845 | 854 | ||||
| 846 | localDialer := deps.localDialer | 855 | localDialer := deps.localDialer | ||
| @@ -859,6 +868,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 859 | Clock: clock.New(), | 868 | Clock: clock.New(), | ||
| 860 | Logger: log, | 869 | Logger: log, | ||
| 861 | RuntimeObserver: deps.runtimeObserver, | 870 | RuntimeObserver: deps.runtimeObserver, | ||
| 871 | + | Remote: false, | |||
| 862 | }, client.AttachRequest{Intent: intent, SessionName: name}) | 872 | }, client.AttachRequest{Intent: intent, SessionName: name}) | ||
| 863 | }, | 873 | }, | ||
| 864 | killDaemon: requestDaemonStop, | 874 | killDaemon: requestDaemonStop, | ||
M internal/app/run_test.go +39 -5
| @@ -579,9 +579,12 @@ func TestParseArgs(t *testing.T) { | |||
| 579 | 579 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 580 | 580 | {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"}, | |
| 581 | 581 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 582 | - | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 583 | - | {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 584 | - | {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, | |
| 582 | + | {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 583 | + | {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 584 | + | {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, | |
| 585 | + | {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"}, | |
| 586 | + | {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"}, | |
| 587 | + | {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true}, | |
| 585 | 588 | {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true}, | |
| 586 | 589 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | |
| 587 | 590 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| @@ -689,6 +692,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |||
| 689 | 692 | } | |
| 690 | 693 | } | |
| 691 | 694 | ||
| 695 | + | func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) { | |
| 696 | + | tests := []struct { | |
| 697 | + | name string | |
| 698 | + | arg string | |
| 699 | + | want client.AttachRequest | |
| 700 | + | }{ | |
| 701 | + | {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |
| 702 | + | {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |
| 703 | + | {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |
| 704 | + | {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |
| 705 | + | } | |
| 706 | + | for _, tt := range tests { | |
| 707 | + | t.Run(tt.name, func(t *testing.T) { | |
| 708 | + | cmd, err := parseArgs([]string{"attach", tt.arg}) | |
| 709 | + | require.NoError(t, err) | |
| 710 | + | connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{ | |
| 711 | + | Intent: cmd.intent, | |
| 712 | + | SessionName: cmd.name, | |
| 713 | + | }) | |
| 714 | + | require.NoError(t, err) | |
| 715 | + | require.Equal(t, tt.want, connection.AttachRequest()) | |
| 716 | + | }) | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | ||
| 692 | 720 | func TestListShowsBroken(t *testing.T) { | |
| 693 | 721 | var out bytes.Buffer | |
| 694 | 722 | printSessions(&out, []ports.SessionInfo{ | |
| @@ -1073,7 +1101,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 1073 | 1101 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | |
| 1074 | 1102 | } | |
| 1075 | 1103 | gotDialer = nd.name | |
| 1076 | - | gotRemote = request.Remote | |
| 1104 | + | gotRemote = deps.Remote | |
| 1105 | + | if request.Remote { | |
| 1106 | + | t.Fatal("remote carriage metadata must not be in the attach request") | |
| 1107 | + | } | |
| 1077 | 1108 | gotClipboard = deps.Clipboard | |
| 1078 | 1109 | if request.Intent != ports.IntentAttach || request.SessionName != "work" { | |
| 1079 | 1110 | t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName) | |
| @@ -1153,7 +1184,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 1153 | 1184 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | |
| 1154 | 1185 | } | |
| 1155 | 1186 | gotDialer = nd.name | |
| 1156 | - | gotRemote = request.Remote | |
| 1187 | + | gotRemote = deps.Remote | |
| 1188 | + | if request.Remote { | |
| 1189 | + | t.Fatal("local carriage metadata must not be in the attach request") | |
| 1190 | + | } | |
| 1157 | 1191 | gotClipboard = deps.Clipboard | |
| 1158 | 1192 | if request.Intent != ports.IntentEphemeral || request.SessionName != "" { | |
| 1159 | 1193 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName) | |
| @@ -579,9 +579,12 @@ func TestParseArgs(t *testing.T) { | |||||
| 579 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 579 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 580 | {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"}, | 580 | {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"}, | ||
| 581 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 581 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 582 | - | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | 582 | + | {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, |
| 583 | - | {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | 583 | + | {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, |
| 584 | - | {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, | 584 | + | {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"}, |
| 585 | + | {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"}, | |||
| 586 | + | {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"}, | |||
| 587 | + | {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true}, | |||
| 585 | {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true}, | 588 | {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true}, | ||
| 586 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | 589 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | ||
| 587 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | 590 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | ||
| @@ -689,6 +692,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |||||
| 689 | } | 692 | } | ||
| 690 | } | 693 | } | ||
| 691 | 694 | ||||
| 695 | + | func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) { | |||
| 696 | + | tests := []struct { | |||
| 697 | + | name string | |||
| 698 | + | arg string | |||
| 699 | + | want client.AttachRequest | |||
| 700 | + | }{ | |||
| 701 | + | {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |||
| 702 | + | {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |||
| 703 | + | {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |||
| 704 | + | {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}}, | |||
| 705 | + | } | |||
| 706 | + | for _, tt := range tests { | |||
| 707 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 708 | + | cmd, err := parseArgs([]string{"attach", tt.arg}) | |||
| 709 | + | require.NoError(t, err) | |||
| 710 | + | connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{ | |||
| 711 | + | Intent: cmd.intent, | |||
| 712 | + | SessionName: cmd.name, | |||
| 713 | + | }) | |||
| 714 | + | require.NoError(t, err) | |||
| 715 | + | require.Equal(t, tt.want, connection.AttachRequest()) | |||
| 716 | + | }) | |||
| 717 | + | } | |||
| 718 | + | } | |||
| 719 | + | ||||
| 692 | func TestListShowsBroken(t *testing.T) { | 720 | func TestListShowsBroken(t *testing.T) { | ||
| 693 | var out bytes.Buffer | 721 | var out bytes.Buffer | ||
| 694 | printSessions(&out, []ports.SessionInfo{ | 722 | printSessions(&out, []ports.SessionInfo{ | ||
| @@ -1073,7 +1101,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||||
| 1073 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | 1101 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | ||
| 1074 | } | 1102 | } | ||
| 1075 | gotDialer = nd.name | 1103 | gotDialer = nd.name | ||
| 1076 | - | gotRemote = request.Remote | 1104 | + | gotRemote = deps.Remote |
| 1105 | + | if request.Remote { | |||
| 1106 | + | t.Fatal("remote carriage metadata must not be in the attach request") | |||
| 1107 | + | } | |||
| 1077 | gotClipboard = deps.Clipboard | 1108 | gotClipboard = deps.Clipboard | ||
| 1078 | if request.Intent != ports.IntentAttach || request.SessionName != "work" { | 1109 | if request.Intent != ports.IntentAttach || request.SessionName != "work" { | ||
| 1079 | t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName) | 1110 | t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName) | ||
| @@ -1153,7 +1184,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||||
| 1153 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | 1184 | t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer) | ||
| 1154 | } | 1185 | } | ||
| 1155 | gotDialer = nd.name | 1186 | gotDialer = nd.name | ||
| 1156 | - | gotRemote = request.Remote | 1187 | + | gotRemote = deps.Remote |
| 1188 | + | if request.Remote { | |||
| 1189 | + | t.Fatal("local carriage metadata must not be in the attach request") | |||
| 1190 | + | } | |||
| 1157 | gotClipboard = deps.Clipboard | 1191 | gotClipboard = deps.Clipboard | ||
| 1158 | if request.Intent != ports.IntentEphemeral || request.SessionName != "" { | 1192 | if request.Intent != ports.IntentEphemeral || request.SessionName != "" { | ||
| 1159 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName) | 1193 | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName) | ||
M internal/usecase/client/client.go +31 -8
| @@ -155,9 +155,13 @@ type Dependencies struct { | |||
| 155 | 155 | Logger *slog.Logger | |
| 156 | 156 | RuntimeObserver ports.SerializedRuntimeObserver | |
| 157 | 157 | RemoteHostLearner ports.RemoteHostLearner | |
| 158 | + | // Remote selects client-side carriage presentation only; it never enters | |
| 159 | + | // the daemon-facing session request. | |
| 160 | + | Remote bool | |
| 158 | 161 | } | |
| 159 | 162 | ||
| 160 | - | // AttachRequest identifies the session and transport mode for one client run. | |
| 163 | + | // AttachRequest identifies the session for one client run. Remote is | |
| 164 | + | // client-only presentation metadata; it is never serialized into Hello. | |
| 161 | 165 | type AttachRequest struct { | |
| 162 | 166 | Intent uint8 | |
| 163 | 167 | SessionName string | |
| @@ -173,6 +177,7 @@ type Runner struct { | |||
| 173 | 177 | logger *slog.Logger | |
| 174 | 178 | runtimeObserver ports.SerializedRuntimeObserver | |
| 175 | 179 | remoteHostLearner ports.RemoteHostLearner | |
| 180 | + | remote bool | |
| 176 | 181 | ||
| 177 | 182 | inputMu sync.Mutex | |
| 178 | 183 | input *terminalInputPump | |
| @@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 192 | 197 | logger: log, | |
| 193 | 198 | runtimeObserver: deps.RuntimeObserver, | |
| 194 | 199 | remoteHostLearner: deps.RemoteHostLearner, | |
| 200 | + | remote: deps.Remote, | |
| 195 | 201 | } | |
| 196 | 202 | } | |
| 197 | 203 | ||
| @@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 282 | 288 | } | |
| 283 | 289 | } | |
| 284 | 290 | }() | |
| 291 | + | remote := request.Remote || r.remote | |
| 285 | 292 | reconnect := &reconnectUI{ | |
| 286 | 293 | term: r.term, | |
| 287 | - | remote: request.Remote, | |
| 294 | + | remote: remote, | |
| 288 | 295 | rawEntered: &rawEntered, | |
| 289 | 296 | stage: reconnectStageOfflineRetrying, | |
| 290 | 297 | } | |
| @@ -333,14 +340,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 333 | 340 | continue | |
| 334 | 341 | } | |
| 335 | 342 | ms.dialed = true | |
| 343 | + | connection, err := NewSessionConnection(transport, SessionTarget{ | |
| 344 | + | Intent: attemptRequest.Intent, | |
| 345 | + | SessionName: attemptRequest.SessionName, | |
| 346 | + | }) | |
| 347 | + | if err != nil { | |
| 348 | + | _ = transport.Close() | |
| 349 | + | return err | |
| 350 | + | } | |
| 336 | 351 | ||
| 337 | 352 | var linkEvents <-chan ports.LinkEvent | |
| 338 | - | if reporter, ok := transport.(ports.LinkStateReporter); ok { | |
| 353 | + | if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok { | |
| 339 | 354 | linkEvents = reporter.LinkEvents() | |
| 340 | 355 | } | |
| 341 | 356 | result := (&attachAttempt{ | |
| 342 | 357 | runner: r, | |
| 343 | - | transport: transport, | |
| 358 | + | connection: connection, | |
| 359 | + | remote: remote, | |
| 344 | 360 | request: attemptRequest, | |
| 345 | 361 | resumeToken: resumeToken, | |
| 346 | 362 | clientID: processClientID, | |
| @@ -358,7 +374,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 358 | 374 | backoff = defaultReconnectBackoff.initial | |
| 359 | 375 | } | |
| 360 | 376 | if !result.transportClosed { | |
| 361 | - | _ = transport.Close() | |
| 377 | + | _ = connection.Close() | |
| 362 | 378 | } | |
| 363 | 379 | if result.resumeToken != 0 { | |
| 364 | 380 | resumeToken = result.resumeToken | |
| @@ -515,6 +531,8 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||
| 515 | 531 | ||
| 516 | 532 | type attachAttempt struct { | |
| 517 | 533 | runner *Runner | |
| 534 | + | connection *SessionConnection | |
| 535 | + | remote bool | |
| 518 | 536 | transport ports.Transport | |
| 519 | 537 | request AttachRequest | |
| 520 | 538 | resumeToken uint64 | |
| @@ -601,10 +619,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||
| 601 | 619 | ||
| 602 | 620 | func (a *attachAttempt) run(ctx context.Context) attachResult { | |
| 603 | 621 | transport := a.transport | |
| 622 | + | request := a.request | |
| 623 | + | if a.connection != nil { | |
| 624 | + | transport = a.connection.Transport() | |
| 625 | + | request = a.connection.AttachRequest() | |
| 626 | + | } | |
| 604 | 627 | term := a.runner.term | |
| 605 | 628 | clk := a.runner.clock | |
| 606 | - | intent := a.request.Intent | |
| 607 | - | name := a.request.SessionName | |
| 629 | + | intent := request.Intent | |
| 630 | + | name := request.SessionName | |
| 608 | 631 | resumeToken := a.resumeToken | |
| 609 | 632 | clientID := a.clientID | |
| 610 | 633 | ms := a.milestones | |
| @@ -612,7 +635,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 612 | 635 | enterRaw := a.enterRaw | |
| 613 | 636 | reconnect := a.reconnect | |
| 614 | 637 | linkEvents := a.linkEvents | |
| 615 | - | remote := a.request.Remote | |
| 638 | + | remote := a.remote || a.request.Remote | |
| 616 | 639 | clipboard := a.runner.clipboard | |
| 617 | 640 | log := a.runner.logger | |
| 618 | 641 | observer := a.runner.runtimeObserver | |
| @@ -155,9 +155,13 @@ type Dependencies struct { | |||||
| 155 | Logger *slog.Logger | 155 | Logger *slog.Logger | ||
| 156 | RuntimeObserver ports.SerializedRuntimeObserver | 156 | RuntimeObserver ports.SerializedRuntimeObserver | ||
| 157 | RemoteHostLearner ports.RemoteHostLearner | 157 | RemoteHostLearner ports.RemoteHostLearner | ||
| 158 | + | // Remote selects client-side carriage presentation only; it never enters | |||
| 159 | + | // the daemon-facing session request. | |||
| 160 | + | Remote bool | |||
| 158 | } | 161 | } | ||
| 159 | 162 | ||||
| 160 | - | // AttachRequest identifies the session and transport mode for one client run. | 163 | + | // AttachRequest identifies the session for one client run. Remote is |
| 164 | + | // client-only presentation metadata; it is never serialized into Hello. | |||
| 161 | type AttachRequest struct { | 165 | type AttachRequest struct { | ||
| 162 | Intent uint8 | 166 | Intent uint8 | ||
| 163 | SessionName string | 167 | SessionName string | ||
| @@ -173,6 +177,7 @@ type Runner struct { | |||||
| 173 | logger *slog.Logger | 177 | logger *slog.Logger | ||
| 174 | runtimeObserver ports.SerializedRuntimeObserver | 178 | runtimeObserver ports.SerializedRuntimeObserver | ||
| 175 | remoteHostLearner ports.RemoteHostLearner | 179 | remoteHostLearner ports.RemoteHostLearner | ||
| 180 | + | remote bool | |||
| 176 | 181 | ||||
| 177 | inputMu sync.Mutex | 182 | inputMu sync.Mutex | ||
| 178 | input *terminalInputPump | 183 | input *terminalInputPump | ||
| @@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner { | |||||
| 192 | logger: log, | 197 | logger: log, | ||
| 193 | runtimeObserver: deps.RuntimeObserver, | 198 | runtimeObserver: deps.RuntimeObserver, | ||
| 194 | remoteHostLearner: deps.RemoteHostLearner, | 199 | remoteHostLearner: deps.RemoteHostLearner, | ||
| 200 | + | remote: deps.Remote, | |||
| 195 | } | 201 | } | ||
| 196 | } | 202 | } | ||
| 197 | 203 | ||||
| @@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 282 | } | 288 | } | ||
| 283 | } | 289 | } | ||
| 284 | }() | 290 | }() | ||
| 291 | + | remote := request.Remote || r.remote | |||
| 285 | reconnect := &reconnectUI{ | 292 | reconnect := &reconnectUI{ | ||
| 286 | term: r.term, | 293 | term: r.term, | ||
| 287 | - | remote: request.Remote, | 294 | + | remote: remote, |
| 288 | rawEntered: &rawEntered, | 295 | rawEntered: &rawEntered, | ||
| 289 | stage: reconnectStageOfflineRetrying, | 296 | stage: reconnectStageOfflineRetrying, | ||
| 290 | } | 297 | } | ||
| @@ -333,14 +340,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 333 | continue | 340 | continue | ||
| 334 | } | 341 | } | ||
| 335 | ms.dialed = true | 342 | ms.dialed = true | ||
| 343 | + | connection, err := NewSessionConnection(transport, SessionTarget{ | |||
| 344 | + | Intent: attemptRequest.Intent, | |||
| 345 | + | SessionName: attemptRequest.SessionName, | |||
| 346 | + | }) | |||
| 347 | + | if err != nil { | |||
| 348 | + | _ = transport.Close() | |||
| 349 | + | return err | |||
| 350 | + | } | |||
| 336 | 351 | ||||
| 337 | var linkEvents <-chan ports.LinkEvent | 352 | var linkEvents <-chan ports.LinkEvent | ||
| 338 | - | if reporter, ok := transport.(ports.LinkStateReporter); ok { | 353 | + | if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok { |
| 339 | linkEvents = reporter.LinkEvents() | 354 | linkEvents = reporter.LinkEvents() | ||
| 340 | } | 355 | } | ||
| 341 | result := (&attachAttempt{ | 356 | result := (&attachAttempt{ | ||
| 342 | runner: r, | 357 | runner: r, | ||
| 343 | - | transport: transport, | 358 | + | connection: connection, |
| 359 | + | remote: remote, | |||
| 344 | request: attemptRequest, | 360 | request: attemptRequest, | ||
| 345 | resumeToken: resumeToken, | 361 | resumeToken: resumeToken, | ||
| 346 | clientID: processClientID, | 362 | clientID: processClientID, | ||
| @@ -358,7 +374,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 358 | backoff = defaultReconnectBackoff.initial | 374 | backoff = defaultReconnectBackoff.initial | ||
| 359 | } | 375 | } | ||
| 360 | if !result.transportClosed { | 376 | if !result.transportClosed { | ||
| 361 | - | _ = transport.Close() | 377 | + | _ = connection.Close() |
| 362 | } | 378 | } | ||
| 363 | if result.resumeToken != 0 { | 379 | if result.resumeToken != 0 { | ||
| 364 | resumeToken = result.resumeToken | 380 | resumeToken = result.resumeToken | ||
| @@ -515,6 +531,8 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||||
| 515 | 531 | ||||
| 516 | type attachAttempt struct { | 532 | type attachAttempt struct { | ||
| 517 | runner *Runner | 533 | runner *Runner | ||
| 534 | + | connection *SessionConnection | |||
| 535 | + | remote bool | |||
| 518 | transport ports.Transport | 536 | transport ports.Transport | ||
| 519 | request AttachRequest | 537 | request AttachRequest | ||
| 520 | resumeToken uint64 | 538 | resumeToken uint64 | ||
| @@ -601,10 +619,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||||
| 601 | 619 | ||||
| 602 | func (a *attachAttempt) run(ctx context.Context) attachResult { | 620 | func (a *attachAttempt) run(ctx context.Context) attachResult { | ||
| 603 | transport := a.transport | 621 | transport := a.transport | ||
| 622 | + | request := a.request | |||
| 623 | + | if a.connection != nil { | |||
| 624 | + | transport = a.connection.Transport() | |||
| 625 | + | request = a.connection.AttachRequest() | |||
| 626 | + | } | |||
| 604 | term := a.runner.term | 627 | term := a.runner.term | ||
| 605 | clk := a.runner.clock | 628 | clk := a.runner.clock | ||
| 606 | - | intent := a.request.Intent | 629 | + | intent := request.Intent |
| 607 | - | name := a.request.SessionName | 630 | + | name := request.SessionName |
| 608 | resumeToken := a.resumeToken | 631 | resumeToken := a.resumeToken | ||
| 609 | clientID := a.clientID | 632 | clientID := a.clientID | ||
| 610 | ms := a.milestones | 633 | ms := a.milestones | ||
| @@ -612,7 +635,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 612 | enterRaw := a.enterRaw | 635 | enterRaw := a.enterRaw | ||
| 613 | reconnect := a.reconnect | 636 | reconnect := a.reconnect | ||
| 614 | linkEvents := a.linkEvents | 637 | linkEvents := a.linkEvents | ||
| 615 | - | remote := a.request.Remote | 638 | + | remote := a.remote || a.request.Remote |
| 616 | clipboard := a.runner.clipboard | 639 | clipboard := a.runner.clipboard | ||
| 617 | log := a.runner.logger | 640 | log := a.runner.logger | ||
| 618 | observer := a.runner.runtimeObserver | 641 | observer := a.runner.runtimeObserver | ||
A internal/usecase/client/session_connection.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var errNilSessionTransport = errors.New("client: nil session transport") | |
| 12 | + | ||
| 13 | + | // SessionTarget is the validated daemon-facing session selection. Endpoint | |
| 14 | + | // details are resolved before a transport is opened and never enter this type. | |
| 15 | + | type SessionTarget struct { | |
| 16 | + | Intent uint8 | |
| 17 | + | SessionName string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (t SessionTarget) validate() error { | |
| 21 | + | switch t.Intent { | |
| 22 | + | case ports.IntentEphemeral: | |
| 23 | + | if t.SessionName == "" { | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | case ports.IntentNew, ports.IntentAttach, ports.IntentResume: | |
| 27 | + | if t.SessionName == "" { | |
| 28 | + | return domain.ErrInvalidSessionName | |
| 29 | + | } | |
| 30 | + | default: | |
| 31 | + | return fmt.Errorf("invalid session intent %d", t.Intent) | |
| 32 | + | } | |
| 33 | + | return domain.ValidateSessionName(t.SessionName) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SessionConnection owns one already-open transport and its daemon-facing | |
| 37 | + | // session target. Local sockets and remote carriages use this same owner. | |
| 38 | + | type SessionConnection struct { | |
| 39 | + | transport ports.Transport | |
| 40 | + | target SessionTarget | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // NewSessionConnection creates a connection for an already-selected transport. | |
| 44 | + | // Target validation happens before the connection is used for a Hello. | |
| 45 | + | func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) { | |
| 46 | + | if transport == nil { | |
| 47 | + | return nil, errNilSessionTransport | |
| 48 | + | } | |
| 49 | + | if err := target.validate(); err != nil { | |
| 50 | + | return nil, fmt.Errorf("invalid session target: %w", err) | |
| 51 | + | } | |
| 52 | + | return &SessionConnection{transport: transport, target: target}, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Transport returns the owned carriage for this connection. | |
| 56 | + | func (c *SessionConnection) Transport() ports.Transport { | |
| 57 | + | if c == nil { | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | return c.transport | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Target returns the immutable daemon-facing session selection. | |
| 64 | + | func (c *SessionConnection) Target() SessionTarget { | |
| 65 | + | if c == nil { | |
| 66 | + | return SessionTarget{} | |
| 67 | + | } | |
| 68 | + | return c.target | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // AttachRequest returns the common request shape used after transport | |
| 72 | + | // selection. It contains no remote/proxy semantic. | |
| 73 | + | func (c *SessionConnection) AttachRequest() AttachRequest { | |
| 74 | + | if c == nil { | |
| 75 | + | return AttachRequest{} | |
| 76 | + | } | |
| 77 | + | return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Request is the short form of AttachRequest for connection callers. | |
| 81 | + | func (c *SessionConnection) Request() AttachRequest { return c.AttachRequest() } | |
| 82 | + | ||
| 83 | + | // Close releases the owned transport. | |
| 84 | + | func (c *SessionConnection) Close() error { | |
| 85 | + | if c == nil || c.transport == nil { | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | return c.transport.Close() | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | var errNilSessionTransport = errors.New("client: nil session transport") | |||
| 12 | + | ||||
| 13 | + | // SessionTarget is the validated daemon-facing session selection. Endpoint | |||
| 14 | + | // details are resolved before a transport is opened and never enter this type. | |||
| 15 | + | type SessionTarget struct { | |||
| 16 | + | Intent uint8 | |||
| 17 | + | SessionName string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (t SessionTarget) validate() error { | |||
| 21 | + | switch t.Intent { | |||
| 22 | + | case ports.IntentEphemeral: | |||
| 23 | + | if t.SessionName == "" { | |||
| 24 | + | return nil | |||
| 25 | + | } | |||
| 26 | + | case ports.IntentNew, ports.IntentAttach, ports.IntentResume: | |||
| 27 | + | if t.SessionName == "" { | |||
| 28 | + | return domain.ErrInvalidSessionName | |||
| 29 | + | } | |||
| 30 | + | default: | |||
| 31 | + | return fmt.Errorf("invalid session intent %d", t.Intent) | |||
| 32 | + | } | |||
| 33 | + | return domain.ValidateSessionName(t.SessionName) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // SessionConnection owns one already-open transport and its daemon-facing | |||
| 37 | + | // session target. Local sockets and remote carriages use this same owner. | |||
| 38 | + | type SessionConnection struct { | |||
| 39 | + | transport ports.Transport | |||
| 40 | + | target SessionTarget | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // NewSessionConnection creates a connection for an already-selected transport. | |||
| 44 | + | // Target validation happens before the connection is used for a Hello. | |||
| 45 | + | func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) { | |||
| 46 | + | if transport == nil { | |||
| 47 | + | return nil, errNilSessionTransport | |||
| 48 | + | } | |||
| 49 | + | if err := target.validate(); err != nil { | |||
| 50 | + | return nil, fmt.Errorf("invalid session target: %w", err) | |||
| 51 | + | } | |||
| 52 | + | return &SessionConnection{transport: transport, target: target}, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Transport returns the owned carriage for this connection. | |||
| 56 | + | func (c *SessionConnection) Transport() ports.Transport { | |||
| 57 | + | if c == nil { | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | return c.transport | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Target returns the immutable daemon-facing session selection. | |||
| 64 | + | func (c *SessionConnection) Target() SessionTarget { | |||
| 65 | + | if c == nil { | |||
| 66 | + | return SessionTarget{} | |||
| 67 | + | } | |||
| 68 | + | return c.target | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // AttachRequest returns the common request shape used after transport | |||
| 72 | + | // selection. It contains no remote/proxy semantic. | |||
| 73 | + | func (c *SessionConnection) AttachRequest() AttachRequest { | |||
| 74 | + | if c == nil { | |||
| 75 | + | return AttachRequest{} | |||
| 76 | + | } | |||
| 77 | + | return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Request is the short form of AttachRequest for connection callers. | |||
| 81 | + | func (c *SessionConnection) Request() AttachRequest { return c.AttachRequest() } | |||
| 82 | + | ||||
| 83 | + | // Close releases the owned transport. | |||
| 84 | + | func (c *SessionConnection) Close() error { | |||
| 85 | + | if c == nil || c.transport == nil { | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | return c.transport.Close() | |||
| 89 | + | } | |||
A internal/usecase/client/session_connection_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) { | |
| 13 | + | target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"} | |
| 14 | + | local := portsmocks.NewMockTransport(t) | |
| 15 | + | remote := portsmocks.NewMockTransport(t) | |
| 16 | + | ||
| 17 | + | localConnection, err := client.NewSessionConnection(local, target) | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | remoteConnection, err := client.NewSessionConnection(remote, target) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | ||
| 22 | + | require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest()) | |
| 23 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest()) | |
| 24 | + | require.Same(t, local, localConnection.Transport()) | |
| 25 | + | require.Same(t, remote, remoteConnection.Transport()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestSessionTargetValidation(t *testing.T) { | |
| 29 | + | tests := []struct { | |
| 30 | + | name string | |
| 31 | + | target client.SessionTarget | |
| 32 | + | wantErr bool | |
| 33 | + | }{ | |
| 34 | + | {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}}, | |
| 35 | + | {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}}, | |
| 36 | + | {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}}, | |
| 37 | + | {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}}, | |
| 38 | + | {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true}, | |
| 39 | + | {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true}, | |
| 40 | + | {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true}, | |
| 41 | + | } | |
| 42 | + | for _, tt := range tests { | |
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |
| 44 | + | transport := portsmocks.NewMockTransport(t) | |
| 45 | + | _, err := client.NewSessionConnection(transport, tt.target) | |
| 46 | + | if tt.wantErr { | |
| 47 | + | require.Error(t, err) | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestSessionConnectionRequiresTransport(t *testing.T) { | |
| 56 | + | _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral}) | |
| 57 | + | require.Error(t, err) | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) { | |||
| 13 | + | target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"} | |||
| 14 | + | local := portsmocks.NewMockTransport(t) | |||
| 15 | + | remote := portsmocks.NewMockTransport(t) | |||
| 16 | + | ||||
| 17 | + | localConnection, err := client.NewSessionConnection(local, target) | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | remoteConnection, err := client.NewSessionConnection(remote, target) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | ||||
| 22 | + | require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest()) | |||
| 23 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest()) | |||
| 24 | + | require.Same(t, local, localConnection.Transport()) | |||
| 25 | + | require.Same(t, remote, remoteConnection.Transport()) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestSessionTargetValidation(t *testing.T) { | |||
| 29 | + | tests := []struct { | |||
| 30 | + | name string | |||
| 31 | + | target client.SessionTarget | |||
| 32 | + | wantErr bool | |||
| 33 | + | }{ | |||
| 34 | + | {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}}, | |||
| 35 | + | {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}}, | |||
| 36 | + | {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}}, | |||
| 37 | + | {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}}, | |||
| 38 | + | {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true}, | |||
| 39 | + | {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true}, | |||
| 40 | + | {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true}, | |||
| 41 | + | } | |||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | transport := portsmocks.NewMockTransport(t) | |||
| 45 | + | _, err := client.NewSessionConnection(transport, tt.target) | |||
| 46 | + | if tt.wantErr { | |||
| 47 | + | require.Error(t, err) | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSessionConnectionRequiresTransport(t *testing.T) { | |||
| 56 | + | _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral}) | |||
| 57 | + | require.Error(t, err) | |||
| 58 | + | } | |||