refactor(app): wire concrete client runner
- commit
- 843fa9c65cea3ebbbbb27d3b84743e7adabb5253
- author
- brice · · 2026-07-14 19:35 UTC
- parent
- d2997ba
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +171 -142
M internal/app/run.go +37 -23
| @@ -250,7 +250,13 @@ var ( | |||
| 250 | 250 | newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { | |
| 251 | 251 | return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer) | |
| 252 | 252 | } | |
| 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 | + | } | |
| 254 | 260 | ) | |
| 255 | 261 | ||
| 256 | 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 | 491 | remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer), | |
| 486 | 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 { | |
| 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, | |
| 492 | 497 | }) | |
| 493 | 498 | } | |
| 494 | 499 | ||
| @@ -500,13 +505,15 @@ func defaultRemoteDialerFactory() ports.RemoteDialerFactory { | |||
| 500 | 505 | return remoteadapter.NewDialerFactory() | |
| 501 | 506 | } | |
| 502 | 507 | ||
| 508 | + | type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error | |
| 509 | + | ||
| 503 | 510 | type runAttachDeps struct { | |
| 504 | - | attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests | |
| 505 | 511 | localDialer func() ports.Dialer | |
| 506 | 512 | remoteDialerFactory ports.RemoteDialerFactory | |
| 507 | 513 | selectedRemoteTransport string | |
| 508 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | |
| 514 | + | runClient runClientFunc | |
| 509 | 515 | createDetached func(context.Context, string) error | |
| 516 | + | runtimeObserver ports.SerializedRuntimeObserver | |
| 510 | 517 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | |
| 511 | 518 | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 512 | 519 | // 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 | 543 | runClient := deps.runClient | |
| 537 | 544 | if runClient == nil { | |
| 538 | - | runClient = client.Run | |
| 545 | + | runClient = runClientWithDeps | |
| 539 | 546 | } | |
| 540 | 547 | if remoteTarget != "" { | |
| 541 | 548 | mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport) | |
| @@ -553,26 +560,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 553 | 560 | if err != nil { | |
| 554 | 561 | 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.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 | |
| 571 | 576 | } | |
| 572 | 577 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| 573 | 578 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | |
| 574 | 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 | 591 | killDaemon: requestDaemonStop, | |
| 578 | 592 | 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.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 | + | } | |||
| 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.Dialer | 511 | localDialer func() ports.Dialer | ||
| 506 | remoteDialerFactory ports.RemoteDialerFactory | 512 | remoteDialerFactory ports.RemoteDialerFactory | ||
| 507 | selectedRemoteTransport string | 513 | selectedRemoteTransport string | ||
| 508 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | 514 | + | runClient runClientFunc |
| 509 | createDetached func(context.Context, string) error | 515 | createDetached func(context.Context, string) error | ||
| 516 | + | runtimeObserver ports.SerializedRuntimeObserver | |||
| 510 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | 517 | // 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 never | 519 | // 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.runClient | 543 | runClient := deps.runClient | ||
| 537 | if runClient == nil { | 544 | if runClient == nil { | ||
| 538 | - | runClient = client.Run | 545 | + | 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 err | 561 | 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.attachLocal | 573 | + | localDialer := deps.localDialer |
| 560 | - | if attachLocal == nil { | 574 | + | if localDialer == nil { |
| 561 | - | localDialer := deps.localDialer | 575 | + | 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | - | "log/slog" | |
| 9 | 8 | "net" | |
| 10 | 9 | "os" | |
| 11 | 10 | "os/exec" | |
| @@ -401,10 +400,6 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||
| 401 | 400 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 402 | 401 | called := false | |
| 403 | 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 | 403 | createDetached: func(context.Context, string) error { | |
| 409 | 404 | called = true | |
| 410 | 405 | return nil | |
| @@ -424,10 +419,6 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |||
| 424 | 419 | func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |
| 425 | 420 | var gotName string | |
| 426 | 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 | 422 | createDetached: func(_ context.Context, name string) error { | |
| 432 | 423 | gotName = name | |
| 433 | 424 | return nil | |
| @@ -488,16 +479,16 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||
| 488 | 479 | remoteDialerFactory: factory, | |
| 489 | 480 | selectedRemoteTransport: tt.selectedTransport, | |
| 490 | 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 { | |
| 492 | - | nd, ok := d.(namedDialer) | |
| 482 | + | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 483 | + | nd, ok := deps.Dialer.(namedDialer) | |
| 493 | 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 | 487 | 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) | |
| 501 | 492 | } | |
| 502 | 493 | return nil | |
| 503 | 494 | }, | |
| @@ -525,7 +516,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing. | |||
| 525 | 516 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 526 | 517 | remoteDialerFactory: factory, | |
| 527 | 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 | 520 | runClientCalled = true | |
| 530 | 521 | return nil | |
| 531 | 522 | }, | |
| @@ -546,7 +537,7 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |||
| 546 | 537 | ||
| 547 | 538 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| 548 | 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 | 541 | runClientCalled = true | |
| 551 | 542 | return nil | |
| 552 | 543 | }, | |
| @@ -568,16 +559,16 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 568 | 559 | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 569 | 560 | remoteDialerFactory: factory, | |
| 570 | 561 | 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) | |
| 573 | 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 | 567 | 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) | |
| 581 | 572 | } | |
| 582 | 573 | return nil | |
| 583 | 574 | }, | |
| @@ -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 := false | 401 | 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 = true | 404 | called = true | ||
| 410 | return nil | 405 | 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 string | 420 | 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 = name | 423 | gotName = name | ||
| 433 | return nil | 424 | 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.name | 487 | gotDialer = nd.name | ||
| 497 | - | gotRemote = remote | 488 | + | gotRemote = request.Remote |
| 498 | - | gotClipboard = clipboard | 489 | + | 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 nil | 493 | 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 = true | 520 | runClientCalled = true | ||
| 530 | return nil | 521 | 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 = true | 541 | runClientCalled = true | ||
| 551 | return nil | 542 | 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 attach | 561 | 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.name | 567 | gotDialer = nd.name | ||
| 577 | - | gotRemote = remote | 568 | + | gotRemote = request.Remote |
| 578 | - | gotClipboard = clipboard | 569 | + | 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 nil | 573 | return nil | ||
| 583 | }, | 574 | }, | ||
M internal/app/transport_observability_test.go +8 -8
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | - | "log/slog" | |
| 10 | 9 | "os" | |
| 11 | 10 | "path/filepath" | |
| 12 | 11 | "strconv" | |
| @@ -16,6 +15,7 @@ import ( | |||
| 16 | 15 | ||
| 17 | 16 | "github.com/bnema/vev/internal/ports" | |
| 18 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 19 | 19 | "github.com/stretchr/testify/mock" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| @@ -229,17 +229,17 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) { | |||
| 229 | 229 | } | |
| 230 | 230 | t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory }) | |
| 231 | 231 | ||
| 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) | |
| 236 | 236 | } | |
| 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) | |
| 239 | 239 | } | |
| 240 | 240 | return nil | |
| 241 | 241 | } | |
| 242 | - | t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient }) | |
| 242 | + | t.Cleanup(func() { runClientWithDeps = originalRunClient }) | |
| 243 | 243 | ||
| 244 | 244 | if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil { | |
| 245 | 245 | 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 := runClientWithRuntimeObserver | 232 | + | 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 nil | 240 | 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 ( | |||
| 143 | 143 | statusClear = "\r\x1b[2K" | |
| 144 | 144 | ) | |
| 145 | 145 | ||
| 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 | |
| 179 | 176 | if log == nil { | |
| 180 | 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 | 202 | ms := milestones{} | |
| 183 | 203 | ||
| 184 | 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 | 387 | type attachResult struct { | |
| 389 | 388 | resumeToken uint64 | |
| 390 | 389 | 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 terminal | 146 | + | // Dependencies supplies the collaborators required by a Runner. |
| 147 | - | // lifecycle above attach attempts so raw mode remains active while a live | 147 | + | 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 feature | 150 | + | 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 a | 152 | + | Logger *slog.Logger |
| 153 | - | // ClipboardReader configured (clipboard non-nil) — local attaches forward | 153 | + | RuntimeObserver ports.SerializedRuntimeObserver |
| 154 | - | // 0x16 untouched so the locally running agent's own clipboard handling | 154 | + | } |
| 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.SerializedRuntimeObserver | 160 | + | Remote bool |
| 161 | - | } | 161 | + | } |
| 162 | - | 162 | + | ||
| 163 | - | // WithRuntimeObserver accepts only a composition-root serialized observer, so | 163 | + | // 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 uint64 | 388 | resumeToken uint64 | ||
| 390 | sessionName string | 389 | sessionName string | ||
M internal/usecase/client/client_test.go +44 -18
| @@ -155,6 +155,32 @@ func isType(typ ports.MsgType) any { | |||
| 155 | 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 | 184 | const testPreWelcomeTimeout = 15 * time.Second | |
| 159 | 185 | ||
| 160 | 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 | 253 | errCh := make(chan error, 1) | |
| 228 | 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 | 258 | var timerC chan time.Time | |
| @@ -294,7 +320,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||
| 294 | 320 | defer unblock() | |
| 295 | 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 | 324 | require.NoError(t, err) | |
| 299 | 325 | ||
| 300 | 326 | select { | |
| @@ -328,7 +354,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) { | |||
| 328 | 354 | defer unblock() | |
| 329 | 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 | 360 | func TestAttachHappyPath(t *testing.T) { | |
| @@ -348,7 +374,7 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 348 | 374 | defer unblock() | |
| 349 | 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 | 378 | require.NoError(t, err) | |
| 353 | 379 | require.Equal(t, "hello world", out.String()) | |
| 354 | 380 | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |
| @@ -367,7 +393,7 @@ func TestAttachVersionMismatch(t *testing.T) { | |||
| 367 | 393 | ).Once() | |
| 368 | 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 | 397 | require.Error(t, err) | |
| 372 | 398 | var pe *client.ProtocolError | |
| 373 | 399 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -391,7 +417,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 391 | 417 | defer unblock() | |
| 392 | 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 | 421 | require.Error(t, err) | |
| 396 | 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 | 438 | defer unblock() | |
| 413 | 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 | 442 | require.Error(t, err) | |
| 417 | 443 | require.ErrorIs(t, err, io.EOF) | |
| 418 | 444 | require.Equal(t, int32(1), restoreCount.Load()) | |
| @@ -484,7 +510,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 484 | 510 | }).Maybe() | |
| 485 | 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 | 514 | require.NoError(t, err) | |
| 489 | 515 | require.Equal(t, int32(1), restoreCount.Load()) | |
| 490 | 516 | ||
| @@ -563,7 +589,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |||
| 563 | 589 | }).Maybe() | |
| 564 | 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 | 593 | require.NoError(t, err) | |
| 568 | 594 | select { | |
| 569 | 595 | case th := <-gotTheme: | |
| @@ -623,7 +649,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) { | |||
| 623 | 649 | }).Maybe() | |
| 624 | 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 | 653 | require.NoError(t, err) | |
| 628 | 654 | select { | |
| 629 | 655 | case got := <-gotInput: | |
| @@ -682,7 +708,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 682 | 708 | }).Maybe() | |
| 683 | 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 | 712 | require.NoError(t, err) | |
| 687 | 713 | select { | |
| 688 | 714 | case got := <-gotInput: | |
| @@ -742,7 +768,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) { | |||
| 742 | 768 | }).Maybe() | |
| 743 | 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 | 772 | require.NoError(t, err) | |
| 747 | 773 | select { | |
| 748 | 774 | case got := <-gotInput: | |
| @@ -805,7 +831,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 805 | 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 | 835 | require.NoError(t, err) | |
| 810 | 836 | select { | |
| 811 | 837 | case r := <-gotResize: | |
| @@ -834,7 +860,7 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 834 | 860 | d := portsmocks.NewMockDialer(t) | |
| 835 | 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 | 864 | require.Error(t, err) | |
| 839 | 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 | 880 | d := portsmocks.NewMockDialer(t) | |
| 855 | 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 | 884 | require.Error(t, err) | |
| 859 | 885 | var pe *client.ProtocolError | |
| 860 | 886 | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| @@ -866,7 +892,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 866 | 892 | d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once() | |
| 867 | 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 | 896 | require.ErrorIs(t, err, dialErr) | |
| 871 | 897 | } | |
| 872 | 898 | ||
| @@ -956,7 +982,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 956 | 982 | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 957 | 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 | 986 | require.NoError(t, err) | |
| 961 | 987 | require.Equal(t, int32(3), d.calls.Load()) | |
| 962 | 988 | require.Equal(t, int32(1), term.rawCount.Load()) | |
| @@ -983,7 +1009,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 983 | 1009 | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 984 | 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 | 1013 | require.Error(t, err) | |
| 988 | 1014 | var de *client.DetachedError | |
| 989 | 1015 | 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.Second | 184 | 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.Time | 258 | 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.ProtocolError | 398 | 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.ProtocolError | 885 | 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.DetachedError | 1014 | 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "io" | |
| 7 | - | "log/slog" | |
| 8 | 7 | "testing" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| @@ -86,7 +85,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 86 | 85 | d := portsmocks.NewMockDialer(t) | |
| 87 | 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 | 89 | require.NoError(t, err) | |
| 91 | 90 | return gotInput, gotImage | |
| 92 | 91 | } | |
| @@ -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, gotImage | 90 | return gotInput, gotImage | ||
| 92 | } | 91 | } | ||
M internal/usecase/client/reconnect_toast_test.go +5 -5
| @@ -412,7 +412,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||
| 412 | 412 | }} | |
| 413 | 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 | 416 | require.ErrorIs(t, err, context.Canceled) | |
| 417 | 417 | require.True(t, out.failed) | |
| 418 | 418 | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | |
| @@ -447,7 +447,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 447 | 447 | }} | |
| 448 | 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 | 451 | require.NoError(t, err) | |
| 452 | 452 | require.True(t, tr1.closed) | |
| 453 | 453 | require.True(t, tr2.closed) | |
| @@ -615,7 +615,7 @@ func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) { | |||
| 615 | 615 | tr2 := newReconnectThemeTransport(55, false) | |
| 616 | 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 | 619 | require.NoError(t, err) | |
| 620 | 620 | require.True(t, tr2.observedTheme.HasForeground) | |
| 621 | 621 | require.True(t, tr2.observedTheme.HasBackground) | |
| @@ -649,7 +649,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |||
| 649 | 649 | }} | |
| 650 | 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 | 653 | require.NoError(t, err) | |
| 654 | 654 | require.Equal(t, 2, dialer.calls) | |
| 655 | 655 | ||
| @@ -734,7 +734,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) { | |||
| 734 | 734 | dialer := portsmocks.NewMockDialer(t) | |
| 735 | 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 | 738 | tt.wantErr(t, err) | |
| 739 | 739 | out := term.out.String() | |
| 740 | 740 | 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 ( | |||
| 17 | 17 | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 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]()) | |
| 25 | 25 | } | |
| 26 | 26 | } | |
| 27 | 27 | ||
| @@ -57,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 57 | 57 | ctx, cancel := context.WithCancel(context.Background()) | |
| 58 | 58 | result := make(chan error, 1) | |
| 59 | 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 | 62 | awaitClientRuntime(t, observer.entered, "blocking observer") | |
| 63 | 63 | awaitClientRuntime(t, flushed, "terminal flush") | |
| @@ -95,7 +95,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 95 | 95 | transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe() | |
| 96 | 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 | 99 | require.NoError(t, err) | |
| 100 | 100 | reporter.Flush() | |
| 101 | 101 | 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") | ||