detached 843fa9c

refactor(app): wire concrete client runner

commit
843fa9c65cea3ebbbbb27d3b84743e7adabb5253
author
brice · · 2026-07-14 19:35 UTC
parent
d2997ba
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +171 -142
M internal/app/run.go +37 -23
@@ -250,7 +250,13 @@ var (
250250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
251251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
252252 }
253- runClientWithRuntimeObserver = client.RunWithRuntimeObserver
253+ runClientWithDeps runClientFunc = func(
254+ ctx context.Context,
255+ deps client.Dependencies,
256+ request client.AttachRequest,
257+ ) error {
258+ return client.NewRunner(deps).Run(ctx, request)
259+ }
254260 )
255261
256262 func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
@@ -484,11 +490,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
484490 },
485491 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
486492 selectedRemoteTransport: os.Getenv(envRemoteTransport),
487- runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
488- return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)
489- },
490- createDetached: createDetachedLocalSession,
491- clipboard: clipboard.New(),
493+ runClient: runClientWithDeps,
494+ createDetached: createDetachedLocalSession,
495+ clipboard: clipboard.New(),
496+ runtimeObserver: observer,
492497 })
493498 }
494499
@@ -500,13 +505,15 @@ func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
500505 return remoteadapter.NewDialerFactory()
501506 }
502507
508+type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
509+
503510 type runAttachDeps struct {
504- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
505511 localDialer func() ports.Dialer
506512 remoteDialerFactory ports.RemoteDialerFactory
507513 selectedRemoteTransport string
508- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
514+ runClient runClientFunc
509515 createDetached func(context.Context, string) error
516+ runtimeObserver ports.SerializedRuntimeObserver
510517 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
511518 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
512519 // Only used for the remote-dialer branch below; local attaches never
@@ -535,7 +542,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
535542
536543 runClient := deps.runClient
537544 if runClient == nil {
538- runClient = client.Run
545+ runClient = runClientWithDeps
539546 }
540547 if remoteTarget != "" {
541548 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
@@ -553,26 +560,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
553560 if err != nil {
554561 return err
555562 }
556- return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
563+ return runClient(ctx, client.Dependencies{
564+ Dialer: dialer,
565+ Terminal: term.New(),
566+ Clock: clock.New(),
567+ Clipboard: deps.clipboard,
568+ Logger: log,
569+ RuntimeObserver: deps.runtimeObserver,
570+ }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
557571 }
558572
559- attachLocal := deps.attachLocal
560- if attachLocal == nil {
561- localDialer := deps.localDialer
562- if localDialer == nil {
563- localDialer = defaultLocalDialer
564- }
565- attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
566- if log != nil {
567- log.Info("attaching to local session", "intent", intent, "name", name)
568- }
569- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
570- }
573+ localDialer := deps.localDialer
574+ if localDialer == nil {
575+ localDialer = defaultLocalDialer
571576 }
572577 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
573578 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
574579 attach: func(ctx context.Context, intent uint8, name string) error {
575- return attachLocal(ctx, intent, name, log)
580+ if log != nil {
581+ log.Info("attaching to local session", "intent", intent, "name", name)
582+ }
583+ return runClient(ctx, client.Dependencies{
584+ Dialer: localDialer(),
585+ Terminal: term.New(),
586+ Clock: clock.New(),
587+ Logger: log,
588+ RuntimeObserver: deps.runtimeObserver,
589+ }, client.AttachRequest{Intent: intent, SessionName: name})
576590 },
577591 killDaemon: requestDaemonStop,
578592 settleAfterKill: waitForDaemonStop,
@@ -250,7 +250,13 @@ var (
250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
252 }252 }
253- runClientWithRuntimeObserver = client.RunWithRuntimeObserver253+ runClientWithDeps runClientFunc = func(
254+ ctx context.Context,
255+ deps client.Dependencies,
256+ request client.AttachRequest,
257+ ) error {
258+ return client.NewRunner(deps).Run(ctx, request)
259+ }
254 )260 )
255 261
256 func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {262 func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
@@ -484,11 +490,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
484 },490 },
485 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),491 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
486 selectedRemoteTransport: os.Getenv(envRemoteTransport),492 selectedRemoteTransport: os.Getenv(envRemoteTransport),
487- runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {493+ runClient: runClientWithDeps,
488- return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)494+ createDetached: createDetachedLocalSession,
489- },495+ clipboard: clipboard.New(),
490- createDetached: createDetachedLocalSession,496+ runtimeObserver: observer,
491- clipboard: clipboard.New(),
492 })497 })
493 }498 }
494 499
@@ -500,13 +505,15 @@ func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
500 return remoteadapter.NewDialerFactory()505 return remoteadapter.NewDialerFactory()
501 }506 }
502 507
508+type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
509+
503 type runAttachDeps struct {510 type runAttachDeps struct {
504- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
505 localDialer func() ports.Dialer511 localDialer func() ports.Dialer
506 remoteDialerFactory ports.RemoteDialerFactory512 remoteDialerFactory ports.RemoteDialerFactory
507 selectedRemoteTransport string513 selectedRemoteTransport string
508- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error514+ runClient runClientFunc
509 createDetached func(context.Context, string) error515 createDetached func(context.Context, string) error
516+ runtimeObserver ports.SerializedRuntimeObserver
510 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see517 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
511 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).518 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
512 // Only used for the remote-dialer branch below; local attaches never519 // Only used for the remote-dialer branch below; local attaches never
@@ -535,7 +542,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
535 542
536 runClient := deps.runClient543 runClient := deps.runClient
537 if runClient == nil {544 if runClient == nil {
538- runClient = client.Run545+ runClient = runClientWithDeps
539 }546 }
540 if remoteTarget != "" {547 if remoteTarget != "" {
541 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)548 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
@@ -553,26 +560,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
553 if err != nil {560 if err != nil {
554 return err561 return err
555 }562 }
556- return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)563+ return runClient(ctx, client.Dependencies{
564+ Dialer: dialer,
565+ Terminal: term.New(),
566+ Clock: clock.New(),
567+ Clipboard: deps.clipboard,
568+ Logger: log,
569+ RuntimeObserver: deps.runtimeObserver,
570+ }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
557 }571 }
558 572
559- attachLocal := deps.attachLocal573+ localDialer := deps.localDialer
560- if attachLocal == nil {574+ if localDialer == nil {
561- localDialer := deps.localDialer575+ localDialer = defaultLocalDialer
562- if localDialer == nil {
563- localDialer = defaultLocalDialer
564- }
565- attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
566- if log != nil {
567- log.Info("attaching to local session", "intent", intent, "name", name)
568- }
569- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
570- }
571 }576 }
572 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{577 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
573 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),578 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
574 attach: func(ctx context.Context, intent uint8, name string) error {579 attach: func(ctx context.Context, intent uint8, name string) error {
575- return attachLocal(ctx, intent, name, log)580+ if log != nil {
581+ log.Info("attaching to local session", "intent", intent, "name", name)
582+ }
583+ return runClient(ctx, client.Dependencies{
584+ Dialer: localDialer(),
585+ Terminal: term.New(),
586+ Clock: clock.New(),
587+ Logger: log,
588+ RuntimeObserver: deps.runtimeObserver,
589+ }, client.AttachRequest{Intent: intent, SessionName: name})
576 },590 },
577 killDaemon: requestDaemonStop,591 killDaemon: requestDaemonStop,
578 settleAfterKill: waitForDaemonStop,592 settleAfterKill: waitForDaemonStop,
M internal/app/run_test.go +16 -25
@@ -5,7 +5,6 @@ import (
55 "context"
66 "errors"
77 "io"
8- "log/slog"
98 "net"
109 "os"
1110 "os/exec"
@@ -401,10 +400,6 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
401400 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
402401 called := false
403402 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
404- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
405- called = true
406- return nil
407- },
408403 createDetached: func(context.Context, string) error {
409404 called = true
410405 return nil
@@ -424,10 +419,6 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
424419 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
425420 var gotName string
426421 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
427- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
428- t.Fatal("nested new should not attach to the session")
429- return nil
430- },
431422 createDetached: func(_ context.Context, name string) error {
432423 gotName = name
433424 return nil
@@ -488,16 +479,16 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
488479 remoteDialerFactory: factory,
489480 selectedRemoteTransport: tt.selectedTransport,
490481 clipboard: clip,
491- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
492- nd, ok := d.(namedDialer)
482+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
483+ nd, ok := deps.Dialer.(namedDialer)
493484 if !ok {
494- t.Fatalf("dialer type = %T, want namedDialer", d)
485+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
495486 }
496487 gotDialer = nd.name
497- gotRemote = remote
498- gotClipboard = clipboard
499- if intent != ports.IntentAttach || name != "work" {
500- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
488+ gotRemote = request.Remote
489+ gotClipboard = deps.Clipboard
490+ if request.Intent != ports.IntentAttach || request.SessionName != "work" {
491+ t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
501492 }
502493 return nil
503494 },
@@ -525,7 +516,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
525516 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
526517 remoteDialerFactory: factory,
527518 selectedRemoteTransport: "serial",
528- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
519+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
529520 runClientCalled = true
530521 return nil
531522 },
@@ -546,7 +537,7 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
546537
547538 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
548539 remoteDialerFactory: factory,
549- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
540+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
550541 runClientCalled = true
551542 return nil
552543 },
@@ -568,16 +559,16 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
568559 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
569560 remoteDialerFactory: factory,
570561 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
571- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
572- nd, ok := d.(namedDialer)
562+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
563+ nd, ok := deps.Dialer.(namedDialer)
573564 if !ok {
574- t.Fatalf("dialer type = %T, want namedDialer", d)
565+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
575566 }
576567 gotDialer = nd.name
577- gotRemote = remote
578- gotClipboard = clipboard
579- if intent != ports.IntentEphemeral || name != "" {
580- t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
568+ gotRemote = request.Remote
569+ gotClipboard = deps.Clipboard
570+ if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
571+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
581572 }
582573 return nil
583574 },
@@ -5,7 +5,6 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8- "log/slog"
9 "net"8 "net"
10 "os"9 "os"
11 "os/exec"10 "os/exec"
@@ -401,10 +400,6 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
401 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {400 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
402 called := false401 called := false
403 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{402 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
404- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
405- called = true
406- return nil
407- },
408 createDetached: func(context.Context, string) error {403 createDetached: func(context.Context, string) error {
409 called = true404 called = true
410 return nil405 return nil
@@ -424,10 +419,6 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
424 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {419 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
425 var gotName string420 var gotName string
426 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{421 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
427- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
428- t.Fatal("nested new should not attach to the session")
429- return nil
430- },
431 createDetached: func(_ context.Context, name string) error {422 createDetached: func(_ context.Context, name string) error {
432 gotName = name423 gotName = name
433 return nil424 return nil
@@ -488,16 +479,16 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
488 remoteDialerFactory: factory,479 remoteDialerFactory: factory,
489 selectedRemoteTransport: tt.selectedTransport,480 selectedRemoteTransport: tt.selectedTransport,
490 clipboard: clip,481 clipboard: clip,
491- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {482+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
492- nd, ok := d.(namedDialer)483+ nd, ok := deps.Dialer.(namedDialer)
493 if !ok {484 if !ok {
494- t.Fatalf("dialer type = %T, want namedDialer", d)485+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
495 }486 }
496 gotDialer = nd.name487 gotDialer = nd.name
497- gotRemote = remote488+ gotRemote = request.Remote
498- gotClipboard = clipboard489+ gotClipboard = deps.Clipboard
499- if intent != ports.IntentAttach || name != "work" {490+ if request.Intent != ports.IntentAttach || request.SessionName != "work" {
500- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)491+ t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
501 }492 }
502 return nil493 return nil
503 },494 },
@@ -525,7 +516,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
525 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{516 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
526 remoteDialerFactory: factory,517 remoteDialerFactory: factory,
527 selectedRemoteTransport: "serial",518 selectedRemoteTransport: "serial",
528- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {519+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
529 runClientCalled = true520 runClientCalled = true
530 return nil521 return nil
531 },522 },
@@ -546,7 +537,7 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
546 537
547 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{538 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
548 remoteDialerFactory: factory,539 remoteDialerFactory: factory,
549- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {540+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
550 runClientCalled = true541 runClientCalled = true
551 return nil542 return nil
552 },543 },
@@ -568,16 +559,16 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
568 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },559 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
569 remoteDialerFactory: factory,560 remoteDialerFactory: factory,
570 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach561 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
571- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {562+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
572- nd, ok := d.(namedDialer)563+ nd, ok := deps.Dialer.(namedDialer)
573 if !ok {564 if !ok {
574- t.Fatalf("dialer type = %T, want namedDialer", d)565+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
575 }566 }
576 gotDialer = nd.name567 gotDialer = nd.name
577- gotRemote = remote568+ gotRemote = request.Remote
578- gotClipboard = clipboard569+ gotClipboard = deps.Clipboard
579- if intent != ports.IntentEphemeral || name != "" {570+ if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
580- t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)571+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
581 }572 }
582 return nil573 return nil
583 },574 },
M internal/app/transport_observability_test.go +8 -8
@@ -6,7 +6,6 @@ import (
66 "encoding/json"
77 "errors"
88 "io"
9- "log/slog"
109 "os"
1110 "path/filepath"
1211 "strconv"
@@ -16,6 +15,7 @@ import (
1615
1716 "github.com/bnema/vev/internal/ports"
1817 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/client"
1919 "github.com/stretchr/testify/mock"
2020 )
2121
@@ -229,17 +229,17 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
229229 }
230230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
231231
232- originalRunClient := runClientWithRuntimeObserver
233- runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error {
234- if got != observer {
235- t.Fatalf("client transport observer = %v, want process observer %v", got, observer)
232+ originalRunClient := runClientWithDeps
233+ runClientWithDeps = func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
234+ if deps.RuntimeObserver != observer {
235+ t.Fatalf("client transport observer = %v, want process observer %v", deps.RuntimeObserver, observer)
236236 }
237- if gotDialer != (namedDialer{name: "remote"}) {
238- t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer)
237+ if deps.Dialer != (namedDialer{name: "remote"}) {
238+ t.Fatalf("remote transport dialer = %v, want configured factory dialer", deps.Dialer)
239239 }
240240 return nil
241241 }
242- t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient })
242+ t.Cleanup(func() { runClientWithDeps = originalRunClient })
243243
244244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {
245245 t.Fatal(err)
@@ -6,7 +6,6 @@ import (
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8 "io"8 "io"
9- "log/slog"
10 "os"9 "os"
11 "path/filepath"10 "path/filepath"
12 "strconv"11 "strconv"
@@ -16,6 +15,7 @@ import (
16 15
17 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/client"
19 "github.com/stretchr/testify/mock"19 "github.com/stretchr/testify/mock"
20 )20 )
21 21
@@ -229,17 +229,17 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
229 }229 }
230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
231 231
232- originalRunClient := runClientWithRuntimeObserver232+ originalRunClient := runClientWithDeps
233- runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error {233+ runClientWithDeps = func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
234- if got != observer {234+ if deps.RuntimeObserver != observer {
235- t.Fatalf("client transport observer = %v, want process observer %v", got, observer)235+ t.Fatalf("client transport observer = %v, want process observer %v", deps.RuntimeObserver, observer)
236 }236 }
237- if gotDialer != (namedDialer{name: "remote"}) {237+ if deps.Dialer != (namedDialer{name: "remote"}) {
238- t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer)238+ t.Fatalf("remote transport dialer = %v, want configured factory dialer", deps.Dialer)
239 }239 }
240 return nil240 return nil
241 }241 }
242- t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient })242+ t.Cleanup(func() { runClientWithDeps = originalRunClient })
243 243
244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {
245 t.Fatal(err)245 t.Fatal(err)
M internal/usecase/client/client.go +53 -54
@@ -143,42 +143,62 @@ const (
143143 statusClear = "\r\x1b[2K"
144144 )
145145
146-// Run connects via dialer and runs the attach client. It owns the terminal
147-// lifecycle above attach attempts so raw mode remains active while a live
148-// client process redials a lost link.
149-//
150-// remote and clipboard together gate the clipboard-image-transfer feature
151-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
152-// Ctrl+V is only intercepted on a remote attach (remote true) with a
153-// ClipboardReader configured (clipboard non-nil) — local attaches forward
154-// 0x16 untouched so the locally running agent's own clipboard handling
155-// applies.
156-// Option configures opt-in client runtime observation.
157-type Option func(*runtimeOptions)
158-
159-type runtimeOptions struct {
160- observer ports.SerializedRuntimeObserver
161-}
162-
163-// WithRuntimeObserver accepts only a composition-root serialized observer, so
164-// terminal and transport progress can never wait for a raw trace sink.
165-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
166- return func(opts *runtimeOptions) { opts.observer = observer }
167-}
168-
169-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
170- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)
171-}
172-
173-// RunWithRuntimeObserver is the application wiring entry point.
174-func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) error {
175- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
176-}
177-
178-func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) (retErr error) {
146+// Dependencies supplies the collaborators required by a Runner.
147+type Dependencies struct {
148+ Dialer ports.Dialer
149+ Terminal ports.Terminal
150+ Clock ports.Clock
151+ Clipboard ports.ClipboardReader
152+ Logger *slog.Logger
153+ RuntimeObserver ports.SerializedRuntimeObserver
154+}
155+
156+// AttachRequest identifies the session and transport mode for one client run.
157+type AttachRequest struct {
158+ Intent uint8
159+ SessionName string
160+ Remote bool
161+}
162+
163+// Runner owns the client lifecycle across one or more attachment attempts.
164+type Runner struct {
165+ dialer ports.Dialer
166+ term ports.Terminal
167+ clock ports.Clock
168+ clipboard ports.ClipboardReader
169+ logger *slog.Logger
170+ runtimeObserver ports.SerializedRuntimeObserver
171+}
172+
173+// NewRunner constructs a client runner. A nil logger uses the process default.
174+func NewRunner(deps Dependencies) *Runner {
175+ log := deps.Logger
179176 if log == nil {
180177 log = slog.Default()
181178 }
179+ return &Runner{
180+ dialer: deps.Dialer,
181+ term: deps.Terminal,
182+ clock: deps.Clock,
183+ clipboard: deps.Clipboard,
184+ logger: log,
185+ runtimeObserver: deps.RuntimeObserver,
186+ }
187+}
188+
189+// Run connects and runs the attach client. It owns the terminal lifecycle
190+// above attach attempts so raw mode remains active while a live client process
191+// redials a lost link.
192+func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
193+ dialer := r.dialer
194+ term := r.term
195+ clk := r.clock
196+ intent := request.Intent
197+ name := request.SessionName
198+ remote := request.Remote
199+ clipboard := r.clipboard
200+ log := r.logger
201+ observer := r.runtimeObserver
182202 ms := milestones{}
183203
184204 defer func() {
@@ -364,27 +384,6 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
364384 }
365385 }
366386
367-// Attach connects an already-dialed transport to the controlling terminal
368-// and runs the attach loop until the session detaches, the daemon
369-// disappears, or the context is cancelled.
370-//
371-// It is kept as a compatibility wrapper for callers that still own dialing.
372-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
373- var opts runtimeOptions
374- for _, option := range options {
375- if option != nil {
376- option(&opts)
377- }
378- }
379- return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer)
380-}
381-
382-type singleTransportDialer struct{ transport ports.Transport }
383-
384-func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
385- return d.transport, nil
386-}
387-
388387 type attachResult struct {
389388 resumeToken uint64
390389 sessionName string
@@ -143,42 +143,62 @@ const (
143 statusClear = "\r\x1b[2K"143 statusClear = "\r\x1b[2K"
144 )144 )
145 145
146-// Run connects via dialer and runs the attach client. It owns the terminal146+// Dependencies supplies the collaborators required by a Runner.
147-// lifecycle above attach attempts so raw mode remains active while a live147+type Dependencies struct {
148-// client process redials a lost link.148+ Dialer ports.Dialer
149-//149+ Terminal ports.Terminal
150-// remote and clipboard together gate the clipboard-image-transfer feature150+ Clock ports.Clock
151-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):151+ Clipboard ports.ClipboardReader
152-// Ctrl+V is only intercepted on a remote attach (remote true) with a152+ Logger *slog.Logger
153-// ClipboardReader configured (clipboard non-nil) — local attaches forward153+ RuntimeObserver ports.SerializedRuntimeObserver
154-// 0x16 untouched so the locally running agent's own clipboard handling154+}
155-// applies.155+
156-// Option configures opt-in client runtime observation.156+// AttachRequest identifies the session and transport mode for one client run.
157-type Option func(*runtimeOptions)157+type AttachRequest struct {
158-158+ Intent uint8
159-type runtimeOptions struct {159+ SessionName string
160- observer ports.SerializedRuntimeObserver160+ Remote bool
161-}161+}
162-162+
163-// WithRuntimeObserver accepts only a composition-root serialized observer, so163+// Runner owns the client lifecycle across one or more attachment attempts.
164-// terminal and transport progress can never wait for a raw trace sink.164+type Runner struct {
165-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {165+ dialer ports.Dialer
166- return func(opts *runtimeOptions) { opts.observer = observer }166+ term ports.Terminal
167-}167+ clock ports.Clock
168-168+ clipboard ports.ClipboardReader
169-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {169+ logger *slog.Logger
170- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)170+ runtimeObserver ports.SerializedRuntimeObserver
171-}171+}
172-172+
173-// RunWithRuntimeObserver is the application wiring entry point.173+// NewRunner constructs a client runner. A nil logger uses the process default.
174-func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) error {174+func NewRunner(deps Dependencies) *Runner {
175- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)175+ log := deps.Logger
176-}
177-
178-func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) (retErr error) {
179 if log == nil {176 if log == nil {
180 log = slog.Default()177 log = slog.Default()
181 }178 }
179+ return &Runner{
180+ dialer: deps.Dialer,
181+ term: deps.Terminal,
182+ clock: deps.Clock,
183+ clipboard: deps.Clipboard,
184+ logger: log,
185+ runtimeObserver: deps.RuntimeObserver,
186+ }
187+}
188+
189+// Run connects and runs the attach client. It owns the terminal lifecycle
190+// above attach attempts so raw mode remains active while a live client process
191+// redials a lost link.
192+func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
193+ dialer := r.dialer
194+ term := r.term
195+ clk := r.clock
196+ intent := request.Intent
197+ name := request.SessionName
198+ remote := request.Remote
199+ clipboard := r.clipboard
200+ log := r.logger
201+ observer := r.runtimeObserver
182 ms := milestones{}202 ms := milestones{}
183 203
184 defer func() {204 defer func() {
@@ -364,27 +384,6 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
364 }384 }
365 }385 }
366 386
367-// Attach connects an already-dialed transport to the controlling terminal
368-// and runs the attach loop until the session detaches, the daemon
369-// disappears, or the context is cancelled.
370-//
371-// It is kept as a compatibility wrapper for callers that still own dialing.
372-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
373- var opts runtimeOptions
374- for _, option := range options {
375- if option != nil {
376- option(&opts)
377- }
378- }
379- return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer)
380-}
381-
382-type singleTransportDialer struct{ transport ports.Transport }
383-
384-func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
385- return d.transport, nil
386-}
387-
388 type attachResult struct {387 type attachResult struct {
389 resumeToken uint64388 resumeToken uint64
390 sessionName string389 sessionName string
M internal/usecase/client/client_test.go +44 -18
@@ -155,6 +155,32 @@ func isType(typ ports.MsgType) any {
155155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
156156 }
157157
158+// transportDialer adapts one already-open test transport to the Runner API.
159+type transportDialer struct{ transport ports.Transport }
160+
161+func (d transportDialer) Dial(context.Context) (ports.Transport, error) {
162+ return d.transport, nil
163+}
164+
165+func runTestClient(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
166+ return client.NewRunner(deps).Run(ctx, request)
167+}
168+
169+func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
170+ return client.Dependencies{
171+ Dialer: dialer,
172+ Terminal: terminal,
173+ Clock: clock,
174+ Clipboard: clipboard,
175+ Logger: slog.New(slog.DiscardHandler),
176+ RuntimeObserver: observer,
177+ }
178+}
179+
180+func attachTestDependencies(transport ports.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
181+ return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
182+}
183+
158184 const testPreWelcomeTimeout = 15 * time.Second
159185
160186 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
@@ -226,7 +252,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
226252
227253 errCh := make(chan error, 1)
228254 go func() {
229- errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
255+ errCh <- runTestClient(ctx, testDependencies(dialer, term, clk, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
230256 }()
231257
232258 var timerC chan time.Time
@@ -294,7 +320,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
294320 defer unblock()
295321 tr.EXPECT().Close().Return(nil).Once()
296322
297- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
323+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
298324 require.NoError(t, err)
299325
300326 select {
@@ -328,7 +354,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
328354 defer unblock()
329355 tr.EXPECT().Close().Return(nil).Once()
330356
331- require.NoError(t, client.Attach(context.Background(), markedDatagramTransport{tr}, tm, realClock{}, ports.IntentEphemeral, ""))
357+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(markedDatagramTransport{tr}, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
332358 }
333359
334360 func TestAttachHappyPath(t *testing.T) {
@@ -348,7 +374,7 @@ func TestAttachHappyPath(t *testing.T) {
348374 defer unblock()
349375 tr.EXPECT().Close().Return(nil).Once()
350376
351- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
377+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
352378 require.NoError(t, err)
353379 require.Equal(t, "hello world", out.String())
354380 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -367,7 +393,7 @@ func TestAttachVersionMismatch(t *testing.T) {
367393 ).Once()
368394 tr.EXPECT().Close().Return(nil).Once()
369395
370- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
396+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
371397 require.Error(t, err)
372398 var pe *client.ProtocolError
373399 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -391,7 +417,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
391417 defer unblock()
392418 tr.EXPECT().Close().Return(nil).Once()
393419
394- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
420+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
395421 require.Error(t, err)
396422 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
397423 }
@@ -412,7 +438,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
412438 defer unblock()
413439 tr.EXPECT().Close().Return(nil).Once()
414440
415- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
441+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
416442 require.Error(t, err)
417443 require.ErrorIs(t, err, io.EOF)
418444 require.Equal(t, int32(1), restoreCount.Load())
@@ -484,7 +510,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
484510 }).Maybe()
485511 tr.EXPECT().Close().Return(nil).Once()
486512
487- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
513+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
488514 require.NoError(t, err)
489515 require.Equal(t, int32(1), restoreCount.Load())
490516
@@ -563,7 +589,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
563589 }).Maybe()
564590 tr.EXPECT().Close().Return(nil).Once()
565591
566- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
592+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
567593 require.NoError(t, err)
568594 select {
569595 case th := <-gotTheme:
@@ -623,7 +649,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
623649 }).Maybe()
624650 tr.EXPECT().Close().Return(nil).Once()
625651
626- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
652+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
627653 require.NoError(t, err)
628654 select {
629655 case got := <-gotInput:
@@ -682,7 +708,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
682708 }).Maybe()
683709 tr.EXPECT().Close().Return(nil).Once()
684710
685- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
711+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
686712 require.NoError(t, err)
687713 select {
688714 case got := <-gotInput:
@@ -742,7 +768,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
742768 }).Maybe()
743769 tr.EXPECT().Close().Return(nil).Once()
744770
745- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
771+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
746772 require.NoError(t, err)
747773 select {
748774 case got := <-gotInput:
@@ -805,7 +831,7 @@ func TestAttachForwardsResize(t *testing.T) {
805831 resizeCh <- domain.Size{Cols: 120, Rows: 40}
806832 }()
807833
808- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
834+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
809835 require.NoError(t, err)
810836 select {
811837 case r := <-gotResize:
@@ -834,7 +860,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
834860 d := portsmocks.NewMockDialer(t)
835861 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
836862
837- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
863+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
838864 require.Error(t, err)
839865 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
840866 }
@@ -854,7 +880,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
854880 d := portsmocks.NewMockDialer(t)
855881 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
856882
857- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
883+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
858884 require.Error(t, err)
859885 var pe *client.ProtocolError
860886 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -866,7 +892,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
866892 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
867893 tm := portsmocks.NewMockTerminal(t)
868894
869- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
895+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
870896 require.ErrorIs(t, err, dialErr)
871897 }
872898
@@ -956,7 +982,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
956982 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
957983 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
958984
959- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
985+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
960986 require.NoError(t, err)
961987 require.Equal(t, int32(3), d.calls.Load())
962988 require.Equal(t, int32(1), term.rawCount.Load())
@@ -983,7 +1009,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
9831009 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
9841010 d := &sequenceDialer{trs: []ports.Transport{tr}}
9851011
986- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
1012+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
9871013 require.Error(t, err)
9881014 var de *client.DetachedError
9891015 require.True(t, errors.As(err, &de))
@@ -155,6 +155,32 @@ func isType(typ ports.MsgType) any {
155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
156 }156 }
157 157
158+// transportDialer adapts one already-open test transport to the Runner API.
159+type transportDialer struct{ transport ports.Transport }
160+
161+func (d transportDialer) Dial(context.Context) (ports.Transport, error) {
162+ return d.transport, nil
163+}
164+
165+func runTestClient(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
166+ return client.NewRunner(deps).Run(ctx, request)
167+}
168+
169+func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
170+ return client.Dependencies{
171+ Dialer: dialer,
172+ Terminal: terminal,
173+ Clock: clock,
174+ Clipboard: clipboard,
175+ Logger: slog.New(slog.DiscardHandler),
176+ RuntimeObserver: observer,
177+ }
178+}
179+
180+func attachTestDependencies(transport ports.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
181+ return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
182+}
183+
158 const testPreWelcomeTimeout = 15 * time.Second184 const testPreWelcomeTimeout = 15 * time.Second
159 185
160 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {186 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
@@ -226,7 +252,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
226 252
227 errCh := make(chan error, 1)253 errCh := make(chan error, 1)
228 go func() {254 go func() {
229- errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))255+ errCh <- runTestClient(ctx, testDependencies(dialer, term, clk, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
230 }()256 }()
231 257
232 var timerC chan time.Time258 var timerC chan time.Time
@@ -294,7 +320,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
294 defer unblock()320 defer unblock()
295 tr.EXPECT().Close().Return(nil).Once()321 tr.EXPECT().Close().Return(nil).Once()
296 322
297- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")323+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
298 require.NoError(t, err)324 require.NoError(t, err)
299 325
300 select {326 select {
@@ -328,7 +354,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
328 defer unblock()354 defer unblock()
329 tr.EXPECT().Close().Return(nil).Once()355 tr.EXPECT().Close().Return(nil).Once()
330 356
331- require.NoError(t, client.Attach(context.Background(), markedDatagramTransport{tr}, tm, realClock{}, ports.IntentEphemeral, ""))357+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(markedDatagramTransport{tr}, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
332 }358 }
333 359
334 func TestAttachHappyPath(t *testing.T) {360 func TestAttachHappyPath(t *testing.T) {
@@ -348,7 +374,7 @@ func TestAttachHappyPath(t *testing.T) {
348 defer unblock()374 defer unblock()
349 tr.EXPECT().Close().Return(nil).Once()375 tr.EXPECT().Close().Return(nil).Once()
350 376
351- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")377+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
352 require.NoError(t, err)378 require.NoError(t, err)
353 require.Equal(t, "hello world", out.String())379 require.Equal(t, "hello world", out.String())
354 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")380 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -367,7 +393,7 @@ func TestAttachVersionMismatch(t *testing.T) {
367 ).Once()393 ).Once()
368 tr.EXPECT().Close().Return(nil).Once()394 tr.EXPECT().Close().Return(nil).Once()
369 395
370- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")396+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
371 require.Error(t, err)397 require.Error(t, err)
372 var pe *client.ProtocolError398 var pe *client.ProtocolError
373 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)399 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -391,7 +417,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
391 defer unblock()417 defer unblock()
392 tr.EXPECT().Close().Return(nil).Once()418 tr.EXPECT().Close().Return(nil).Once()
393 419
394- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")420+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
395 require.Error(t, err)421 require.Error(t, err)
396 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")422 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
397 }423 }
@@ -412,7 +438,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
412 defer unblock()438 defer unblock()
413 tr.EXPECT().Close().Return(nil).Once()439 tr.EXPECT().Close().Return(nil).Once()
414 440
415- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")441+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
416 require.Error(t, err)442 require.Error(t, err)
417 require.ErrorIs(t, err, io.EOF)443 require.ErrorIs(t, err, io.EOF)
418 require.Equal(t, int32(1), restoreCount.Load())444 require.Equal(t, int32(1), restoreCount.Load())
@@ -484,7 +510,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
484 }).Maybe()510 }).Maybe()
485 tr.EXPECT().Close().Return(nil).Once()511 tr.EXPECT().Close().Return(nil).Once()
486 512
487- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")513+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
488 require.NoError(t, err)514 require.NoError(t, err)
489 require.Equal(t, int32(1), restoreCount.Load())515 require.Equal(t, int32(1), restoreCount.Load())
490 516
@@ -563,7 +589,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
563 }).Maybe()589 }).Maybe()
564 tr.EXPECT().Close().Return(nil).Once()590 tr.EXPECT().Close().Return(nil).Once()
565 591
566- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")592+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
567 require.NoError(t, err)593 require.NoError(t, err)
568 select {594 select {
569 case th := <-gotTheme:595 case th := <-gotTheme:
@@ -623,7 +649,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
623 }).Maybe()649 }).Maybe()
624 tr.EXPECT().Close().Return(nil).Once()650 tr.EXPECT().Close().Return(nil).Once()
625 651
626- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")652+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
627 require.NoError(t, err)653 require.NoError(t, err)
628 select {654 select {
629 case got := <-gotInput:655 case got := <-gotInput:
@@ -682,7 +708,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
682 }).Maybe()708 }).Maybe()
683 tr.EXPECT().Close().Return(nil).Once()709 tr.EXPECT().Close().Return(nil).Once()
684 710
685- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")711+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
686 require.NoError(t, err)712 require.NoError(t, err)
687 select {713 select {
688 case got := <-gotInput:714 case got := <-gotInput:
@@ -742,7 +768,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
742 }).Maybe()768 }).Maybe()
743 tr.EXPECT().Close().Return(nil).Once()769 tr.EXPECT().Close().Return(nil).Once()
744 770
745- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")771+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
746 require.NoError(t, err)772 require.NoError(t, err)
747 select {773 select {
748 case got := <-gotInput:774 case got := <-gotInput:
@@ -805,7 +831,7 @@ func TestAttachForwardsResize(t *testing.T) {
805 resizeCh <- domain.Size{Cols: 120, Rows: 40}831 resizeCh <- domain.Size{Cols: 120, Rows: 40}
806 }()832 }()
807 833
808- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")834+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
809 require.NoError(t, err)835 require.NoError(t, err)
810 select {836 select {
811 case r := <-gotResize:837 case r := <-gotResize:
@@ -834,7 +860,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
834 d := portsmocks.NewMockDialer(t)860 d := portsmocks.NewMockDialer(t)
835 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()861 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
836 862
837- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))863+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
838 require.Error(t, err)864 require.Error(t, err)
839 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")865 require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
840 }866 }
@@ -854,7 +880,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
854 d := portsmocks.NewMockDialer(t)880 d := portsmocks.NewMockDialer(t)
855 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()881 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
856 882
857- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))883+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
858 require.Error(t, err)884 require.Error(t, err)
859 var pe *client.ProtocolError885 var pe *client.ProtocolError
860 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)886 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -866,7 +892,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
866 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()892 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
867 tm := portsmocks.NewMockTerminal(t)893 tm := portsmocks.NewMockTerminal(t)
868 894
869- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))895+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
870 require.ErrorIs(t, err, dialErr)896 require.ErrorIs(t, err, dialErr)
871 }897 }
872 898
@@ -956,7 +982,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
956 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}982 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
957 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}983 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
958 984
959- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))985+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
960 require.NoError(t, err)986 require.NoError(t, err)
961 require.Equal(t, int32(3), d.calls.Load())987 require.Equal(t, int32(3), d.calls.Load())
962 require.Equal(t, int32(1), term.rawCount.Load())988 require.Equal(t, int32(1), term.rawCount.Load())
@@ -983,7 +1009,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
983 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}1009 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
984 d := &sequenceDialer{trs: []ports.Transport{tr}}1010 d := &sequenceDialer{trs: []ports.Transport{tr}}
985 1011
986- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))1012+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
987 require.Error(t, err)1013 require.Error(t, err)
988 var de *client.DetachedError1014 var de *client.DetachedError
989 require.True(t, errors.As(err, &de))1015 require.True(t, errors.As(err, &de))
M internal/usecase/client/clipboard_intercept_test.go +1 -2
@@ -4,7 +4,6 @@ import (
44 "bytes"
55 "context"
66 "io"
7- "log/slog"
87 "testing"
98 "time"
109
@@ -86,7 +85,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
8685 d := portsmocks.NewMockDialer(t)
8786 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
8887
89- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
88+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, clip, nil), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: remote})
9089 require.NoError(t, err)
9190 return gotInput, gotImage
9291 }
@@ -4,7 +4,6 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "io"6 "io"
7- "log/slog"
8 "testing"7 "testing"
9 "time"8 "time"
10 9
@@ -86,7 +85,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
86 d := portsmocks.NewMockDialer(t)85 d := portsmocks.NewMockDialer(t)
87 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()86 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88 87
89- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))88+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, clip, nil), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: remote})
90 require.NoError(t, err)89 require.NoError(t, err)
91 return gotInput, gotImage90 return gotInput, gotImage
92 }91 }
M internal/usecase/client/reconnect_toast_test.go +5 -5
@@ -412,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
412412 }}
413413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
414414
415- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
415+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
416416 require.ErrorIs(t, err, context.Canceled)
417417 require.True(t, out.failed)
418418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -447,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
447447 }}
448448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
449449
450- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
450+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
451451 require.NoError(t, err)
452452 require.True(t, tr1.closed)
453453 require.True(t, tr2.closed)
@@ -615,7 +615,7 @@ func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
615615 tr2 := newReconnectThemeTransport(55, false)
616616 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617617
618- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
618+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
619619 require.NoError(t, err)
620620 require.True(t, tr2.observedTheme.HasForeground)
621621 require.True(t, tr2.observedTheme.HasBackground)
@@ -649,7 +649,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
649649 }}
650650 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
651651
652- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
652+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: true})
653653 require.NoError(t, err)
654654 require.Equal(t, 2, dialer.calls)
655655
@@ -734,7 +734,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
734734 dialer := portsmocks.NewMockDialer(t)
735735 tt.configure(t, dialer)
736736
737- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
737+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
738738 tt.wantErr(t, err)
739739 out := term.out.String()
740740 require.Contains(t, out, reconnectToastMessage)
@@ -412,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
412 }}412 }}
413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
414 414
415- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))415+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
416 require.ErrorIs(t, err, context.Canceled)416 require.ErrorIs(t, err, context.Canceled)
417 require.True(t, out.failed)417 require.True(t, out.failed)
418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -447,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
447 }}447 }}
448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
449 449
450- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))450+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
451 require.NoError(t, err)451 require.NoError(t, err)
452 require.True(t, tr1.closed)452 require.True(t, tr1.closed)
453 require.True(t, tr2.closed)453 require.True(t, tr2.closed)
@@ -615,7 +615,7 @@ func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
615 tr2 := newReconnectThemeTransport(55, false)615 tr2 := newReconnectThemeTransport(55, false)
616 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}616 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617 617
618- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))618+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
619 require.NoError(t, err)619 require.NoError(t, err)
620 require.True(t, tr2.observedTheme.HasForeground)620 require.True(t, tr2.observedTheme.HasForeground)
621 require.True(t, tr2.observedTheme.HasBackground)621 require.True(t, tr2.observedTheme.HasBackground)
@@ -649,7 +649,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
649 }}649 }}
650 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}650 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
651 651
652- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))652+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: true})
653 require.NoError(t, err)653 require.NoError(t, err)
654 require.Equal(t, 2, dialer.calls)654 require.Equal(t, 2, dialer.calls)
655 655
@@ -734,7 +734,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
734 dialer := portsmocks.NewMockDialer(t)734 dialer := portsmocks.NewMockDialer(t)
735 tt.configure(t, dialer)735 tt.configure(t, dialer)
736 736
737- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))737+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
738 tt.wantErr(t, err)738 tt.wantErr(t, err)
739 out := term.out.String()739 out := term.out.String()
740 require.Contains(t, out, reconnectToastMessage)740 require.Contains(t, out, reconnectToastMessage)
M internal/usecase/client/transport_observability_test.go +7 -7
@@ -17,11 +17,11 @@ import (
1717 "github.com/bnema/vev/internal/usecase/client"
1818 )
1919
20-func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {
21- got := reflect.TypeOf(client.WithRuntimeObserver).In(0)
22- want := reflect.TypeFor[ports.SerializedRuntimeObserver]()
23- if got != want {
24- t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)
20+func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
21+ deps := reflect.TypeFor[client.Dependencies]()
22+ field, ok := deps.FieldByName("RuntimeObserver")
23+ if !ok || field.Type != reflect.TypeFor[ports.SerializedRuntimeObserver]() {
24+ t.Fatalf("Dependencies.RuntimeObserver is %v, want %v; raw blocking sinks must not enter the hot path", field.Type, reflect.TypeFor[ports.SerializedRuntimeObserver]())
2525 }
2626 }
2727
@@ -57,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
5757 ctx, cancel := context.WithCancel(context.Background())
5858 result := make(chan error, 1)
5959 go func() {
60- result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
60+ result <- runTestClient(ctx, testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
6161 }()
6262 awaitClientRuntime(t, observer.entered, "blocking observer")
6363 awaitClientRuntime(t, flushed, "terminal flush")
@@ -95,7 +95,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
9595 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
9696 transport.EXPECT().Close().Return(nil).Once()
9797
98- err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
98+ err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
9999 require.NoError(t, err)
100100 reporter.Flush()
101101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
@@ -17,11 +17,11 @@ import (
17 "github.com/bnema/vev/internal/usecase/client"17 "github.com/bnema/vev/internal/usecase/client"
18 )18 )
19 19
20-func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {20+func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
21- got := reflect.TypeOf(client.WithRuntimeObserver).In(0)21+ deps := reflect.TypeFor[client.Dependencies]()
22- want := reflect.TypeFor[ports.SerializedRuntimeObserver]()22+ field, ok := deps.FieldByName("RuntimeObserver")
23- if got != want {23+ if !ok || field.Type != reflect.TypeFor[ports.SerializedRuntimeObserver]() {
24- t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)24+ t.Fatalf("Dependencies.RuntimeObserver is %v, want %v; raw blocking sinks must not enter the hot path", field.Type, reflect.TypeFor[ports.SerializedRuntimeObserver]())
25 }25 }
26 }26 }
27 27
@@ -57,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
57 ctx, cancel := context.WithCancel(context.Background())57 ctx, cancel := context.WithCancel(context.Background())
58 result := make(chan error, 1)58 result := make(chan error, 1)
59 go func() {59 go func() {
60- result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))60+ result <- runTestClient(ctx, testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
61 }()61 }()
62 awaitClientRuntime(t, observer.entered, "blocking observer")62 awaitClientRuntime(t, observer.entered, "blocking observer")
63 awaitClientRuntime(t, flushed, "terminal flush")63 awaitClientRuntime(t, flushed, "terminal flush")
@@ -95,7 +95,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
95 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()95 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
96 transport.EXPECT().Close().Return(nil).Once()96 transport.EXPECT().Close().Return(nil).Once()
97 97
98- err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))98+ err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
99 require.NoError(t, err)99 require.NoError(t, err)
100 reporter.Flush()100 reporter.Flush()
101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close