chore(broker): preserve crash logs and tighten review coverage
- commit
- ce4cfbbca2c3c096eec26ddad5813d687cf1c978
- author
- brice · · 2026-09-23 08:35 UTC
- parent
- 472aae1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +172 -13
M internal/app/broker_offline_mux_test.go +12 -0
| @@ -22,6 +22,7 @@ import ( | |||
| 22 | 22 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| 23 | 23 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 24 | 24 | "github.com/bnema/vev/internal/adapters/quic" | |
| 25 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 25 | 26 | "github.com/bnema/vev/internal/ports" | |
| 26 | 27 | "github.com/bnema/vev/internal/protocol" | |
| 27 | 28 | ) | |
| @@ -380,6 +381,17 @@ func TestBrokerQUICPeerAddr(t *testing.T) { | |||
| 380 | 381 | } | |
| 381 | 382 | } | |
| 382 | 383 | ||
| 384 | + | func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) { | |
| 385 | + | isolateSandboxEnv(t) | |
| 386 | + | root := writeMuxRoot(t, map[string]any{"kind": "unix", "path": filepath.Join(t.TempDir(), "missing-mux.sock")}) | |
| 387 | + | parsed, err := parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, []string{"--offline-root", root}) | |
| 388 | + | require.NoError(t, err) | |
| 389 | + | err = runBrokerMuxStdioCommand(context.Background(), parsed.brokerMux) | |
| 390 | + | var coded *exitCoded | |
| 391 | + | require.ErrorAs(t, err, &coded) | |
| 392 | + | require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code) | |
| 393 | + | } | |
| 394 | + | ||
| 383 | 395 | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | |
| 384 | 396 | // refuses any address the immutable configuration did not produce. | |
| 385 | 397 | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | |
| @@ -22,6 +22,7 @@ import ( | |||||
| 22 | "github.com/bnema/vev/internal/adapters/brokerconfig" | 22 | "github.com/bnema/vev/internal/adapters/brokerconfig" | ||
| 23 | "github.com/bnema/vev/internal/adapters/brokeripc" | 23 | "github.com/bnema/vev/internal/adapters/brokeripc" | ||
| 24 | "github.com/bnema/vev/internal/adapters/quic" | 24 | "github.com/bnema/vev/internal/adapters/quic" | ||
| 25 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 25 | "github.com/bnema/vev/internal/ports" | 26 | "github.com/bnema/vev/internal/ports" | ||
| 26 | "github.com/bnema/vev/internal/protocol" | 27 | "github.com/bnema/vev/internal/protocol" | ||
| 27 | ) | 28 | ) | ||
| @@ -380,6 +381,17 @@ func TestBrokerQUICPeerAddr(t *testing.T) { | |||||
| 380 | } | 381 | } | ||
| 381 | } | 382 | } | ||
| 382 | 383 | ||||
| 384 | + | func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) { | |||
| 385 | + | isolateSandboxEnv(t) | |||
| 386 | + | root := writeMuxRoot(t, map[string]any{"kind": "unix", "path": filepath.Join(t.TempDir(), "missing-mux.sock")}) | |||
| 387 | + | parsed, err := parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, []string{"--offline-root", root}) | |||
| 388 | + | require.NoError(t, err) | |||
| 389 | + | err = runBrokerMuxStdioCommand(context.Background(), parsed.brokerMux) | |||
| 390 | + | var coded *exitCoded | |||
| 391 | + | require.ErrorAs(t, err, &coded) | |||
| 392 | + | require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code) | |||
| 393 | + | } | |||
| 394 | + | ||||
| 383 | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | 395 | // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector | ||
| 384 | // refuses any address the immutable configuration did not produce. | 396 | // refuses any address the immutable configuration did not produce. | ||
| 385 | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | 397 | func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) { | ||
M internal/app/broker_offline_status.go +20 -4
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "os" | |
| 13 | 13 | "os/exec" | |
| 14 | 14 | "os/signal" | |
| 15 | + | "path/filepath" | |
| 15 | 16 | "strings" | |
| 16 | 17 | "syscall" | |
| 17 | 18 | "time" | |
| @@ -270,7 +271,7 @@ func parseBrokerStatusArgs(args []string) (command, error) { | |||
| 270 | 271 | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | |
| 271 | 272 | // command and never runs in production flows. | |
| 272 | 273 | func runProductionBrokerLauncherCommand(context.Context) error { | |
| 273 | - | return startDetachedBrokerServe([]string{productionBrokerServeCommand}) | |
| 274 | + | return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log")) | |
| 274 | 275 | } | |
| 275 | 276 | ||
| 276 | 277 | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | |
| @@ -300,7 +301,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error { | |||
| 300 | 301 | if grace > 0 { | |
| 301 | 302 | args = append(args, "--idle-grace", grace.String()) | |
| 302 | 303 | } | |
| 303 | - | return startDetachedBrokerServe(args) | |
| 304 | + | return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log")) | |
| 304 | 305 | } | |
| 305 | 306 | ||
| 306 | 307 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | |
| @@ -308,7 +309,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error { | |||
| 308 | 309 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | |
| 309 | 310 | // the performance-trace inputs, stdio is /dev/null, and the process is | |
| 310 | 311 | // reparented immediately. | |
| 311 | - | func startDetachedBrokerServe(args []string) error { | |
| 312 | + | func startDetachedBrokerServe(args []string, crashPath string) error { | |
| 312 | 313 | exePath, err := selfExePath() | |
| 313 | 314 | if err != nil { | |
| 314 | 315 | return fmt.Errorf("vev: resolving executable path: %w", err) | |
| @@ -324,7 +325,22 @@ func startDetachedBrokerServe(args []string) error { | |||
| 324 | 325 | cmd.Dir = platform.DirOrHome("") | |
| 325 | 326 | cmd.Stdin = devNull | |
| 326 | 327 | cmd.Stdout = devNull | |
| 327 | - | cmd.Stderr = devNull | |
| 328 | + | if crashPath == "" { | |
| 329 | + | cmd.Stderr = devNull | |
| 330 | + | } else { | |
| 331 | + | if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil { | |
| 332 | + | return fmt.Errorf("vev: secure broker crash log directory: %w", err) | |
| 333 | + | } | |
| 334 | + | if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 335 | + | return fmt.Errorf("vev: preserve previous broker crash log: %w", err) | |
| 336 | + | } | |
| 337 | + | crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) | |
| 338 | + | if err != nil { | |
| 339 | + | return fmt.Errorf("vev: open broker crash log: %w", err) | |
| 340 | + | } | |
| 341 | + | defer func() { _ = crashLog.Close() }() | |
| 342 | + | cmd.Stderr = crashLog | |
| 343 | + | } | |
| 328 | 344 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 329 | 345 | if err := cmd.Start(); err != nil { | |
| 330 | 346 | return fmt.Errorf("vev: starting detached broker: %w", err) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "os" | 12 | "os" | ||
| 13 | "os/exec" | 13 | "os/exec" | ||
| 14 | "os/signal" | 14 | "os/signal" | ||
| 15 | + | "path/filepath" | |||
| 15 | "strings" | 16 | "strings" | ||
| 16 | "syscall" | 17 | "syscall" | ||
| 17 | "time" | 18 | "time" | ||
| @@ -270,7 +271,7 @@ func parseBrokerStatusArgs(args []string) (command, error) { | |||||
| 270 | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | 271 | // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary | ||
| 271 | // command and never runs in production flows. | 272 | // command and never runs in production flows. | ||
| 272 | func runProductionBrokerLauncherCommand(context.Context) error { | 273 | func runProductionBrokerLauncherCommand(context.Context) error { | ||
| 273 | - | return startDetachedBrokerServe([]string{productionBrokerServeCommand}) | 274 | + | return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log")) |
| 274 | } | 275 | } | ||
| 275 | 276 | ||||
| 276 | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | 277 | func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error { | ||
| @@ -300,7 +301,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error { | |||||
| 300 | if grace > 0 { | 301 | if grace > 0 { | ||
| 301 | args = append(args, "--idle-grace", grace.String()) | 302 | args = append(args, "--idle-grace", grace.String()) | ||
| 302 | } | 303 | } | ||
| 303 | - | return startDetachedBrokerServe(args) | 304 | + | return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log")) |
| 304 | } | 305 | } | ||
| 305 | 306 | ||||
| 306 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | 307 | // startDetachedBrokerServe starts one `_broker-serve` in a new session and | ||
| @@ -308,7 +309,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error { | |||||
| 308 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | 309 | // daemon launcher's double-fork boundary: no ambient variable is dropped beyond | ||
| 309 | // the performance-trace inputs, stdio is /dev/null, and the process is | 310 | // the performance-trace inputs, stdio is /dev/null, and the process is | ||
| 310 | // reparented immediately. | 311 | // reparented immediately. | ||
| 311 | - | func startDetachedBrokerServe(args []string) error { | 312 | + | func startDetachedBrokerServe(args []string, crashPath string) error { |
| 312 | exePath, err := selfExePath() | 313 | exePath, err := selfExePath() | ||
| 313 | if err != nil { | 314 | if err != nil { | ||
| 314 | return fmt.Errorf("vev: resolving executable path: %w", err) | 315 | return fmt.Errorf("vev: resolving executable path: %w", err) | ||
| @@ -324,7 +325,22 @@ func startDetachedBrokerServe(args []string) error { | |||||
| 324 | cmd.Dir = platform.DirOrHome("") | 325 | cmd.Dir = platform.DirOrHome("") | ||
| 325 | cmd.Stdin = devNull | 326 | cmd.Stdin = devNull | ||
| 326 | cmd.Stdout = devNull | 327 | cmd.Stdout = devNull | ||
| 327 | - | cmd.Stderr = devNull | 328 | + | if crashPath == "" { |
| 329 | + | cmd.Stderr = devNull | |||
| 330 | + | } else { | |||
| 331 | + | if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil { | |||
| 332 | + | return fmt.Errorf("vev: secure broker crash log directory: %w", err) | |||
| 333 | + | } | |||
| 334 | + | if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 335 | + | return fmt.Errorf("vev: preserve previous broker crash log: %w", err) | |||
| 336 | + | } | |||
| 337 | + | crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) | |||
| 338 | + | if err != nil { | |||
| 339 | + | return fmt.Errorf("vev: open broker crash log: %w", err) | |||
| 340 | + | } | |||
| 341 | + | defer func() { _ = crashLog.Close() }() | |||
| 342 | + | cmd.Stderr = crashLog | |||
| 343 | + | } | |||
| 328 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | 344 | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | ||
| 329 | if err := cmd.Start(); err != nil { | 345 | if err := cmd.Start(); err != nil { | ||
| 330 | return fmt.Errorf("vev: starting detached broker: %w", err) | 346 | return fmt.Errorf("vev: starting detached broker: %w", err) | ||
M internal/app/broker_offline_test.go +2 -0
| @@ -101,6 +101,8 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) { | |||
| 101 | 101 | t.Helper() | |
| 102 | 102 | prodRuntime = t.TempDir() | |
| 103 | 103 | prodState = t.TempDir() | |
| 104 | + | t.Setenv("VEV_ENV", "") | |
| 105 | + | t.Setenv("VEV_ENV_ROOT", "") | |
| 104 | 106 | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | |
| 105 | 107 | t.Setenv("XDG_STATE_HOME", prodState) | |
| 106 | 108 | root = filepath.Join(shortTempDir(t, "vevs"), "sandbox") | |
| @@ -101,6 +101,8 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) { | |||||
| 101 | t.Helper() | 101 | t.Helper() | ||
| 102 | prodRuntime = t.TempDir() | 102 | prodRuntime = t.TempDir() | ||
| 103 | prodState = t.TempDir() | 103 | prodState = t.TempDir() | ||
| 104 | + | t.Setenv("VEV_ENV", "") | |||
| 105 | + | t.Setenv("VEV_ENV_ROOT", "") | |||
| 104 | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | 106 | t.Setenv("XDG_RUNTIME_DIR", prodRuntime) | ||
| 105 | t.Setenv("XDG_STATE_HOME", prodState) | 107 | t.Setenv("XDG_STATE_HOME", prodState) | ||
| 106 | root = filepath.Join(shortTempDir(t, "vevs"), "sandbox") | 108 | root = filepath.Join(shortTempDir(t, "vevs"), "sandbox") | ||
M internal/app/remote_hosts.go +21 -2
| @@ -102,9 +102,16 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error { | |||
| 102 | 102 | return err | |
| 103 | 103 | } | |
| 104 | 104 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 105 | - | _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE") | |
| 105 | + | _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS") | |
| 106 | 106 | for _, endpoint := range endpoints { | |
| 107 | - | _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint) | |
| 107 | + | status := "configured" | |
| 108 | + | for _, daemon := range snapshot.Daemons { | |
| 109 | + | if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon { | |
| 110 | + | status = "no daemon" | |
| 111 | + | break | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | _, _ = fmt.Fprintf(tw, "%s\tbroker\t%s\n", endpoint, status) | |
| 108 | 115 | } | |
| 109 | 116 | return tw.Flush() | |
| 110 | 117 | } | |
| @@ -114,12 +121,16 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io | |||
| 114 | 121 | return fmt.Errorf("vev: reading broker catalogue: %w", err) | |
| 115 | 122 | } | |
| 116 | 123 | var sessions []protocol.SessionInfo | |
| 124 | + | var statuses []string | |
| 117 | 125 | found := cmd.listHost == "" | |
| 118 | 126 | for _, daemon := range snapshot.Daemons { | |
| 119 | 127 | if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) { | |
| 120 | 128 | continue | |
| 121 | 129 | } | |
| 122 | 130 | found = true | |
| 131 | + | if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon { | |
| 132 | + | statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint)) | |
| 133 | + | } | |
| 123 | 134 | for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) { | |
| 124 | 135 | if daemon.Local { | |
| 125 | 136 | session.Name = daemon.Sessions[i].Name | |
| @@ -130,6 +141,14 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io | |||
| 130 | 141 | if !found { | |
| 131 | 142 | return fmt.Errorf("vev: unknown host %q", cmd.listHost) | |
| 132 | 143 | } | |
| 144 | + | for _, status := range statuses { | |
| 145 | + | if _, err := fmt.Fprintln(stdout, status); err != nil { | |
| 146 | + | return err | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | if len(sessions) == 0 && len(statuses) > 0 { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 133 | 152 | printSessions(stdout, sessions) | |
| 134 | 153 | return nil | |
| 135 | 154 | } | |
| @@ -102,9 +102,16 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error { | |||||
| 102 | return err | 102 | return err | ||
| 103 | } | 103 | } | ||
| 104 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | 104 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | ||
| 105 | - | _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE") | 105 | + | _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS") |
| 106 | for _, endpoint := range endpoints { | 106 | for _, endpoint := range endpoints { | ||
| 107 | - | _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint) | 107 | + | status := "configured" |
| 108 | + | for _, daemon := range snapshot.Daemons { | |||
| 109 | + | if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon { | |||
| 110 | + | status = "no daemon" | |||
| 111 | + | break | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | _, _ = fmt.Fprintf(tw, "%s\tbroker\t%s\n", endpoint, status) | |||
| 108 | } | 115 | } | ||
| 109 | return tw.Flush() | 116 | return tw.Flush() | ||
| 110 | } | 117 | } | ||
| @@ -114,12 +121,16 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io | |||||
| 114 | return fmt.Errorf("vev: reading broker catalogue: %w", err) | 121 | return fmt.Errorf("vev: reading broker catalogue: %w", err) | ||
| 115 | } | 122 | } | ||
| 116 | var sessions []protocol.SessionInfo | 123 | var sessions []protocol.SessionInfo | ||
| 124 | + | var statuses []string | |||
| 117 | found := cmd.listHost == "" | 125 | found := cmd.listHost == "" | ||
| 118 | for _, daemon := range snapshot.Daemons { | 126 | for _, daemon := range snapshot.Daemons { | ||
| 119 | if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) { | 127 | if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) { | ||
| 120 | continue | 128 | continue | ||
| 121 | } | 129 | } | ||
| 122 | found = true | 130 | found = true | ||
| 131 | + | if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon { | |||
| 132 | + | statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint)) | |||
| 133 | + | } | |||
| 123 | for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) { | 134 | for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) { | ||
| 124 | if daemon.Local { | 135 | if daemon.Local { | ||
| 125 | session.Name = daemon.Sessions[i].Name | 136 | session.Name = daemon.Sessions[i].Name | ||
| @@ -130,6 +141,14 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io | |||||
| 130 | if !found { | 141 | if !found { | ||
| 131 | return fmt.Errorf("vev: unknown host %q", cmd.listHost) | 142 | return fmt.Errorf("vev: unknown host %q", cmd.listHost) | ||
| 132 | } | 143 | } | ||
| 144 | + | for _, status := range statuses { | |||
| 145 | + | if _, err := fmt.Fprintln(stdout, status); err != nil { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | if len(sessions) == 0 && len(statuses) > 0 { | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 133 | printSessions(stdout, sessions) | 152 | printSessions(stdout, sessions) | ||
| 134 | return nil | 153 | return nil | ||
| 135 | } | 154 | } | ||
M internal/app/run_broker_test.go +3 -3
| @@ -218,9 +218,6 @@ func seamKillResult(requestID uint64, outcome protocol.KillOutcome, code uint16, | |||
| 218 | 218 | return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text} | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | - | // TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the | |
| 222 | - | // live session list through BrokerOperations over the per-user broker and | |
| 223 | - | // renders it, so a local list never reads durable state or dials a daemon. | |
| 224 | 221 | func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) { | |
| 225 | 222 | remote := ports.BrokerDaemonObservation{ | |
| 226 | 223 | Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test", | |
| @@ -258,6 +255,9 @@ func TestRunBrokerSnapshotListAllShowsNoDaemonAlongsideSessions(t *testing.T) { | |||
| 258 | 255 | require.Contains(t, output.String(), "work") | |
| 259 | 256 | } | |
| 260 | 257 | ||
| 258 | + | // TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the | |
| 259 | + | // live session list through BrokerOperations over the per-user broker and | |
| 260 | + | // renders it, so a local list never reads durable state or dials a daemon. | |
| 261 | 261 | func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) { | |
| 262 | 262 | stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{ | |
| 263 | 263 | {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true}, | |
| @@ -218,9 +218,6 @@ func seamKillResult(requestID uint64, outcome protocol.KillOutcome, code uint16, | |||||
| 218 | return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text} | 218 | return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text} | ||
| 219 | } | 219 | } | ||
| 220 | 220 | ||||
| 221 | - | // TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the | |||
| 222 | - | // live session list through BrokerOperations over the per-user broker and | |||
| 223 | - | // renders it, so a local list never reads durable state or dials a daemon. | |||
| 224 | func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) { | 221 | func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) { | ||
| 225 | remote := ports.BrokerDaemonObservation{ | 222 | remote := ports.BrokerDaemonObservation{ | ||
| 226 | Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test", | 223 | Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test", | ||
| @@ -258,6 +255,9 @@ func TestRunBrokerSnapshotListAllShowsNoDaemonAlongsideSessions(t *testing.T) { | |||||
| 258 | require.Contains(t, output.String(), "work") | 255 | require.Contains(t, output.String(), "work") | ||
| 259 | } | 256 | } | ||
| 260 | 257 | ||||
| 258 | + | // TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the | |||
| 259 | + | // live session list through BrokerOperations over the per-user broker and | |||
| 260 | + | // renders it, so a local list never reads durable state or dials a daemon. | |||
| 261 | func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) { | 261 | func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) { | ||
| 262 | stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{ | 262 | stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{ | ||
| 263 | {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true}, | 263 | {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true}, | ||
M internal/usecase/client/picker_catalogue.go +11 -1
| @@ -596,7 +596,13 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||
| 596 | 596 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | |
| 597 | 597 | return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}) | |
| 598 | 598 | } | |
| 599 | - | if refuseFailing && !ref.local && pickerObservationFailing(observation) { | |
| 599 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact { | |
| 600 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"}) | |
| 601 | + | } | |
| 602 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral { | |
| 603 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"}) | |
| 604 | + | } | |
| 605 | + | if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon { | |
| 600 | 606 | reason := pickerObservationReason(observation, false) | |
| 601 | 607 | return fail(pickerCatalogueError{ | |
| 602 | 608 | Code: pickerCatalogueUnavailable, | |
| @@ -843,6 +849,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||
| 843 | 849 | return protocol.PickerLineStatusError | |
| 844 | 850 | case domain.RemoteAvailabilityIncompatible: | |
| 845 | 851 | return protocol.PickerLineStatusVersion | |
| 852 | + | case domain.RemoteAvailabilityNoDaemon: | |
| 853 | + | return protocol.PickerLineStatusNoDaemon | |
| 846 | 854 | case domain.RemoteAvailabilityReachable: | |
| 847 | 855 | if pickerObservationVersionMismatch(observation) { | |
| 848 | 856 | return protocol.PickerLineStatusVersion | |
| @@ -873,6 +881,8 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo | |||
| 873 | 881 | return domain.RemoteReasonMalformed | |
| 874 | 882 | case domain.RemoteAvailabilityIncompatible: | |
| 875 | 883 | return domain.RemoteReasonVersionMismatch | |
| 884 | + | case domain.RemoteAvailabilityNoDaemon: | |
| 885 | + | return domain.RemoteReasonNoDaemon | |
| 876 | 886 | case domain.RemoteAvailabilityReachable: | |
| 877 | 887 | if pickerObservationVersionMismatch(observation) { | |
| 878 | 888 | return domain.RemoteReasonVersionMismatch | |
| @@ -596,7 +596,13 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori | |||||
| 596 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | 596 | if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) { | ||
| 597 | return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}) | 597 | return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}) | ||
| 598 | } | 598 | } | ||
| 599 | - | if refuseFailing && !ref.local && pickerObservationFailing(observation) { | 599 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact { |
| 600 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"}) | |||
| 601 | + | } | |||
| 602 | + | if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral { | |||
| 603 | + | return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"}) | |||
| 604 | + | } | |||
| 605 | + | if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon { | |||
| 600 | reason := pickerObservationReason(observation, false) | 606 | reason := pickerObservationReason(observation, false) | ||
| 601 | return fail(pickerCatalogueError{ | 607 | return fail(pickerCatalogueError{ | ||
| 602 | Code: pickerCatalogueUnavailable, | 608 | Code: pickerCatalogueUnavailable, | ||
| @@ -843,6 +849,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo | |||||
| 843 | return protocol.PickerLineStatusError | 849 | return protocol.PickerLineStatusError | ||
| 844 | case domain.RemoteAvailabilityIncompatible: | 850 | case domain.RemoteAvailabilityIncompatible: | ||
| 845 | return protocol.PickerLineStatusVersion | 851 | return protocol.PickerLineStatusVersion | ||
| 852 | + | case domain.RemoteAvailabilityNoDaemon: | |||
| 853 | + | return protocol.PickerLineStatusNoDaemon | |||
| 846 | case domain.RemoteAvailabilityReachable: | 854 | case domain.RemoteAvailabilityReachable: | ||
| 847 | if pickerObservationVersionMismatch(observation) { | 855 | if pickerObservationVersionMismatch(observation) { | ||
| 848 | return protocol.PickerLineStatusVersion | 856 | return protocol.PickerLineStatusVersion | ||
| @@ -873,6 +881,8 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo | |||||
| 873 | return domain.RemoteReasonMalformed | 881 | return domain.RemoteReasonMalformed | ||
| 874 | case domain.RemoteAvailabilityIncompatible: | 882 | case domain.RemoteAvailabilityIncompatible: | ||
| 875 | return domain.RemoteReasonVersionMismatch | 883 | return domain.RemoteReasonVersionMismatch | ||
| 884 | + | case domain.RemoteAvailabilityNoDaemon: | |||
| 885 | + | return domain.RemoteReasonNoDaemon | |||
| 876 | case domain.RemoteAvailabilityReachable: | 886 | case domain.RemoteAvailabilityReachable: | ||
| 877 | if pickerObservationVersionMismatch(observation) { | 887 | if pickerObservationVersionMismatch(observation) { | ||
| 878 | return domain.RemoteReasonVersionMismatch | 888 | return domain.RemoteReasonVersionMismatch | ||
M internal/usecase/client/picker_catalogue_test.go +83 -3
| @@ -189,6 +189,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 189 | 189 | wantStatus map[string]protocol.PickerLineStatus | |
| 190 | 190 | wantDim map[string]bool | |
| 191 | 191 | wantHostRow bool | |
| 192 | + | wantNoDaemon bool | |
| 192 | 193 | }{ | |
| 193 | 194 | { | |
| 194 | 195 | name: "local only", | |
| @@ -212,6 +213,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 212 | 213 | wantSessions: []string{"remote-a"}, | |
| 213 | 214 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | |
| 214 | 215 | }, | |
| 216 | + | { | |
| 217 | + | name: "reachable host with no daemon offers a create row", wantNoDaemon: true, | |
| 218 | + | daemons: []ports.BrokerDaemonObservation{ | |
| 219 | + | func() ports.BrokerDaemonObservation { | |
| 220 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 221 | + | observation.Identity = "" | |
| 222 | + | observation.Incarnation = ports.BrokerDaemonIncarnation{} | |
| 223 | + | observation.ProtocolVersion = 0 | |
| 224 | + | observation.Availability = domain.RemoteAvailabilityNoDaemon | |
| 225 | + | observation.LastSuccess = time.Time{} | |
| 226 | + | observation.InventoryKnown = false | |
| 227 | + | return observation | |
| 228 | + | }(), | |
| 229 | + | }, | |
| 230 | + | wantSections: []string{"user@arch"}, | |
| 231 | + | wantSessions: []string{}, | |
| 232 | + | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNoDaemon}, | |
| 233 | + | wantHostRow: true, | |
| 234 | + | }, | |
| 215 | 235 | { | |
| 216 | 236 | name: "stale observation stays explicitly selectable", | |
| 217 | 237 | daemons: []ports.BrokerDaemonObservation{ | |
| @@ -289,8 +309,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 289 | 309 | lines := catalogue.Lines() | |
| 290 | 310 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | |
| 291 | 311 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | |
| 292 | - | _, hasHost := pickerHostLine(lines) | |
| 312 | + | hostLine, hasHost := pickerHostLine(lines) | |
| 293 | 313 | require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information") | |
| 314 | + | if tt.wantNoDaemon { | |
| 315 | + | require.Equal(t, protocol.PickerLineStatusNoDaemon, hostLine.Status) | |
| 316 | + | require.Equal(t, protocol.PickerCanNavigate, hostLine.Actions, "Enter creates an ephemeral session on this host") | |
| 317 | + | require.True(t, hostLine.Focusable) | |
| 318 | + | require.Contains(t, hostLine.StatusDetail, "no_daemon") | |
| 319 | + | } | |
| 294 | 320 | for label, actions := range tt.wantActions { | |
| 295 | 321 | line, ok := pickerLineByLabel(lines, label) | |
| 296 | 322 | require.True(t, ok, "session %q must be projected", label) | |
| @@ -787,6 +813,7 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) | |||
| 787 | 813 | {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |
| 788 | 814 | {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |
| 789 | 815 | {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure}, | |
| 816 | + | {name: "reachable without daemon is distinct and createable", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon }), wantStatus: protocol.PickerLineStatusNoDaemon, wantReason: domain.RemoteReasonNoDaemon}, | |
| 790 | 817 | {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed}, | |
| 791 | 818 | {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) { | |
| 792 | 819 | o.Availability = domain.RemoteAvailabilityUnreachable | |
| @@ -819,6 +846,9 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 819 | 846 | name string | |
| 820 | 847 | daemon ports.BrokerDaemonObservation | |
| 821 | 848 | wantErr pickerCatalogueErrorCode | |
| 849 | + | hostRow bool | |
| 850 | + | create bool | |
| 851 | + | exact bool | |
| 822 | 852 | }{ | |
| 823 | 853 | { | |
| 824 | 854 | name: "unobserved unknown stays attemptable", | |
| @@ -858,6 +888,33 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 858 | 888 | return o | |
| 859 | 889 | }(), | |
| 860 | 890 | }, | |
| 891 | + | { | |
| 892 | + | name: "no daemon allows create admission", hostRow: true, create: true, | |
| 893 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 894 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 895 | + | o.Identity = "" | |
| 896 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |
| 897 | + | o.ProtocolVersion = 0 | |
| 898 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |
| 899 | + | o.LastSuccess = time.Time{} | |
| 900 | + | o.InventoryKnown = false | |
| 901 | + | return o | |
| 902 | + | }(), | |
| 903 | + | }, | |
| 904 | + | { | |
| 905 | + | name: "no daemon refuses exact absent session attach", hostRow: true, exact: true, | |
| 906 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 907 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |
| 908 | + | o.Identity = "" | |
| 909 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |
| 910 | + | o.ProtocolVersion = 0 | |
| 911 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |
| 912 | + | o.LastSuccess = time.Time{} | |
| 913 | + | o.InventoryKnown = false | |
| 914 | + | return o | |
| 915 | + | }(), | |
| 916 | + | wantErr: pickerCatalogueUnavailable, | |
| 917 | + | }, | |
| 861 | 918 | { | |
| 862 | 919 | name: "reachable mismatch refuses incompatible", | |
| 863 | 920 | daemon: func() ports.BrokerDaemonObservation { | |
| @@ -881,14 +938,37 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||
| 881 | 938 | t.Run(tt.name, func(t *testing.T) { | |
| 882 | 939 | catalogue, _ := pickerTestCatalogue(t) | |
| 883 | 940 | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |
| 884 | - | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 941 | + | lines := catalogue.Lines() | |
| 942 | + | line, ok := pickerLineByLabel(lines, "alpha") | |
| 943 | + | if tt.hostRow { | |
| 944 | + | line, ok = pickerHostLine(lines) | |
| 945 | + | } | |
| 946 | + | if tt.create { | |
| 947 | + | require.True(t, ok, "the no-daemon host row is visible") | |
| 948 | + | ref, exists := catalogue.Ref(line.Key) | |
| 949 | + | require.True(t, exists) | |
| 950 | + | request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |
| 951 | + | require.NoError(t, err) | |
| 952 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |
| 953 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |
| 954 | + | return | |
| 955 | + | } | |
| 956 | + | if tt.exact { | |
| 957 | + | ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | |
| 958 | + | registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"} | |
| 959 | + | _, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |
| 960 | + | require.Error(t, err) | |
| 961 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 962 | + | return | |
| 963 | + | } | |
| 885 | 964 | require.True(t, ok) | |
| 886 | - | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |
| 887 | 965 | if tt.wantErr != 0 { | |
| 966 | + | _, err := catalogue.Resolve(line.Key, pickerTestBase()) | |
| 888 | 967 | require.Error(t, err) | |
| 889 | 968 | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 890 | 969 | return | |
| 891 | 970 | } | |
| 971 | + | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |
| 892 | 972 | require.NoError(t, err) | |
| 893 | 973 | require.NoError(t, request.Validate()) | |
| 894 | 974 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization") | |
| @@ -189,6 +189,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 189 | wantStatus map[string]protocol.PickerLineStatus | 189 | wantStatus map[string]protocol.PickerLineStatus | ||
| 190 | wantDim map[string]bool | 190 | wantDim map[string]bool | ||
| 191 | wantHostRow bool | 191 | wantHostRow bool | ||
| 192 | + | wantNoDaemon bool | |||
| 192 | }{ | 193 | }{ | ||
| 193 | { | 194 | { | ||
| 194 | name: "local only", | 195 | name: "local only", | ||
| @@ -212,6 +213,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 212 | wantSessions: []string{"remote-a"}, | 213 | wantSessions: []string{"remote-a"}, | ||
| 213 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | 214 | wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate}, | ||
| 214 | }, | 215 | }, | ||
| 216 | + | { | |||
| 217 | + | name: "reachable host with no daemon offers a create row", wantNoDaemon: true, | |||
| 218 | + | daemons: []ports.BrokerDaemonObservation{ | |||
| 219 | + | func() ports.BrokerDaemonObservation { | |||
| 220 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 221 | + | observation.Identity = "" | |||
| 222 | + | observation.Incarnation = ports.BrokerDaemonIncarnation{} | |||
| 223 | + | observation.ProtocolVersion = 0 | |||
| 224 | + | observation.Availability = domain.RemoteAvailabilityNoDaemon | |||
| 225 | + | observation.LastSuccess = time.Time{} | |||
| 226 | + | observation.InventoryKnown = false | |||
| 227 | + | return observation | |||
| 228 | + | }(), | |||
| 229 | + | }, | |||
| 230 | + | wantSections: []string{"user@arch"}, | |||
| 231 | + | wantSessions: []string{}, | |||
| 232 | + | wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNoDaemon}, | |||
| 233 | + | wantHostRow: true, | |||
| 234 | + | }, | |||
| 215 | { | 235 | { | ||
| 216 | name: "stale observation stays explicitly selectable", | 236 | name: "stale observation stays explicitly selectable", | ||
| 217 | daemons: []ports.BrokerDaemonObservation{ | 237 | daemons: []ports.BrokerDaemonObservation{ | ||
| @@ -289,8 +309,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) { | |||||
| 289 | lines := catalogue.Lines() | 309 | lines := catalogue.Lines() | ||
| 290 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | 310 | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | ||
| 291 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | 311 | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | ||
| 292 | - | _, hasHost := pickerHostLine(lines) | 312 | + | hostLine, hasHost := pickerHostLine(lines) |
| 293 | require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information") | 313 | require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information") | ||
| 314 | + | if tt.wantNoDaemon { | |||
| 315 | + | require.Equal(t, protocol.PickerLineStatusNoDaemon, hostLine.Status) | |||
| 316 | + | require.Equal(t, protocol.PickerCanNavigate, hostLine.Actions, "Enter creates an ephemeral session on this host") | |||
| 317 | + | require.True(t, hostLine.Focusable) | |||
| 318 | + | require.Contains(t, hostLine.StatusDetail, "no_daemon") | |||
| 319 | + | } | |||
| 294 | for label, actions := range tt.wantActions { | 320 | for label, actions := range tt.wantActions { | ||
| 295 | line, ok := pickerLineByLabel(lines, label) | 321 | line, ok := pickerLineByLabel(lines, label) | ||
| 296 | require.True(t, ok, "session %q must be projected", label) | 322 | require.True(t, ok, "session %q must be projected", label) | ||
| @@ -787,6 +813,7 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) | |||||
| 787 | {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | 813 | {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | ||
| 788 | {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | 814 | {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | ||
| 789 | {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure}, | 815 | {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure}, | ||
| 816 | + | {name: "reachable without daemon is distinct and createable", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon }), wantStatus: protocol.PickerLineStatusNoDaemon, wantReason: domain.RemoteReasonNoDaemon}, | |||
| 790 | {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed}, | 817 | {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed}, | ||
| 791 | {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) { | 818 | {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) { | ||
| 792 | o.Availability = domain.RemoteAvailabilityUnreachable | 819 | o.Availability = domain.RemoteAvailabilityUnreachable | ||
| @@ -819,6 +846,9 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 819 | name string | 846 | name string | ||
| 820 | daemon ports.BrokerDaemonObservation | 847 | daemon ports.BrokerDaemonObservation | ||
| 821 | wantErr pickerCatalogueErrorCode | 848 | wantErr pickerCatalogueErrorCode | ||
| 849 | + | hostRow bool | |||
| 850 | + | create bool | |||
| 851 | + | exact bool | |||
| 822 | }{ | 852 | }{ | ||
| 823 | { | 853 | { | ||
| 824 | name: "unobserved unknown stays attemptable", | 854 | name: "unobserved unknown stays attemptable", | ||
| @@ -858,6 +888,33 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 858 | return o | 888 | return o | ||
| 859 | }(), | 889 | }(), | ||
| 860 | }, | 890 | }, | ||
| 891 | + | { | |||
| 892 | + | name: "no daemon allows create admission", hostRow: true, create: true, | |||
| 893 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 894 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 895 | + | o.Identity = "" | |||
| 896 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |||
| 897 | + | o.ProtocolVersion = 0 | |||
| 898 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |||
| 899 | + | o.LastSuccess = time.Time{} | |||
| 900 | + | o.InventoryKnown = false | |||
| 901 | + | return o | |||
| 902 | + | }(), | |||
| 903 | + | }, | |||
| 904 | + | { | |||
| 905 | + | name: "no daemon refuses exact absent session attach", hostRow: true, exact: true, | |||
| 906 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 907 | + | o := pickerTestRemoteObservation("user@arch", 1, 1, now) | |||
| 908 | + | o.Identity = "" | |||
| 909 | + | o.Incarnation = ports.BrokerDaemonIncarnation{} | |||
| 910 | + | o.ProtocolVersion = 0 | |||
| 911 | + | o.Availability = domain.RemoteAvailabilityNoDaemon | |||
| 912 | + | o.LastSuccess = time.Time{} | |||
| 913 | + | o.InventoryKnown = false | |||
| 914 | + | return o | |||
| 915 | + | }(), | |||
| 916 | + | wantErr: pickerCatalogueUnavailable, | |||
| 917 | + | }, | |||
| 861 | { | 918 | { | ||
| 862 | name: "reachable mismatch refuses incompatible", | 919 | name: "reachable mismatch refuses incompatible", | ||
| 863 | daemon: func() ports.BrokerDaemonObservation { | 920 | daemon: func() ports.BrokerDaemonObservation { | ||
| @@ -881,14 +938,37 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) { | |||||
| 881 | t.Run(tt.name, func(t *testing.T) { | 938 | t.Run(tt.name, func(t *testing.T) { | ||
| 882 | catalogue, _ := pickerTestCatalogue(t) | 939 | catalogue, _ := pickerTestCatalogue(t) | ||
| 883 | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | 940 | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | ||
| 884 | - | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | 941 | + | lines := catalogue.Lines() |
| 942 | + | line, ok := pickerLineByLabel(lines, "alpha") | |||
| 943 | + | if tt.hostRow { | |||
| 944 | + | line, ok = pickerHostLine(lines) | |||
| 945 | + | } | |||
| 946 | + | if tt.create { | |||
| 947 | + | require.True(t, ok, "the no-daemon host row is visible") | |||
| 948 | + | ref, exists := catalogue.Ref(line.Key) | |||
| 949 | + | require.True(t, exists) | |||
| 950 | + | request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |||
| 951 | + | require.NoError(t, err) | |||
| 952 | + | require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission) | |||
| 953 | + | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode) | |||
| 954 | + | return | |||
| 955 | + | } | |||
| 956 | + | if tt.exact { | |||
| 957 | + | ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint, | |||
| 958 | + | registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"} | |||
| 959 | + | _, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase()) | |||
| 960 | + | require.Error(t, err) | |||
| 961 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 962 | + | return | |||
| 963 | + | } | |||
| 885 | require.True(t, ok) | 964 | require.True(t, ok) | ||
| 886 | - | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |||
| 887 | if tt.wantErr != 0 { | 965 | if tt.wantErr != 0 { | ||
| 966 | + | _, err := catalogue.Resolve(line.Key, pickerTestBase()) | |||
| 888 | require.Error(t, err) | 967 | require.Error(t, err) | ||
| 889 | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | 968 | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | ||
| 890 | return | 969 | return | ||
| 891 | } | 970 | } | ||
| 971 | + | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |||
| 892 | require.NoError(t, err) | 972 | require.NoError(t, err) | ||
| 893 | require.NoError(t, request.Validate()) | 973 | require.NoError(t, request.Validate()) | ||
| 894 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization") | 974 | require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization") | ||
M internal/usecase/client/supervisor_attach.go +7 -0
| @@ -596,8 +596,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 596 | 596 | } | |
| 597 | 597 | request.Env = append([]string(nil), sessionEnv.Env...) | |
| 598 | 598 | ||
| 599 | + | s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation) | |
| 599 | 600 | stream, err := service.OpenStream(deadline.Context(), request) | |
| 600 | 601 | if err != nil { | |
| 602 | + | s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err)) | |
| 601 | 603 | s.reportAttachmentFailure(deadline.cause(err)) | |
| 602 | 604 | return false, nil | |
| 603 | 605 | } | |
| @@ -627,6 +629,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||
| 627 | 629 | } | |
| 628 | 630 | ||
| 629 | 631 | token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)} | |
| 632 | + | s.logger.Debug("client_stream_opened", "local", request.Local, "endpoint", request.Endpoint, "generation", token.Generation, "attempt", token.Attempt) | |
| 630 | 633 | run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream) | |
| 631 | 634 | if !ok { | |
| 632 | 635 | // Another foreground owns the terminal, or the shared reader is | |
| @@ -734,16 +737,19 @@ settlement: | |||
| 734 | 737 | return false, nil | |
| 735 | 738 | } | |
| 736 | 739 | if errors.Is(result.event.Err, errDetachAndExit) { | |
| 740 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "explicit_detach") | |
| 737 | 741 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 738 | 742 | s.notifyLifecycle(LifecycleNoticeDetachAndExit) | |
| 739 | 743 | return true, nil | |
| 740 | 744 | } | |
| 741 | 745 | if errors.Is(result.event.Err, errDetachToPicker) { | |
| 746 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "detach_to_picker") | |
| 742 | 747 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 743 | 748 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | |
| 744 | 749 | return false, nil | |
| 745 | 750 | } | |
| 746 | 751 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | |
| 752 | + | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err)) | |
| 747 | 753 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | |
| 748 | 754 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | |
| 749 | 755 | s.notifyAttachment(result.event.Err) | |
| @@ -755,6 +761,7 @@ settlement: | |||
| 755 | 761 | // attached presentation showing. | |
| 756 | 762 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | |
| 757 | 763 | if !brokerLost { | |
| 764 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String()) | |
| 758 | 765 | s.notifyLifecycle(LifecycleNoticeSessionEnded) | |
| 759 | 766 | } | |
| 760 | 767 | return false, nil | |
| @@ -596,8 +596,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||||
| 596 | } | 596 | } | ||
| 597 | request.Env = append([]string(nil), sessionEnv.Env...) | 597 | request.Env = append([]string(nil), sessionEnv.Env...) | ||
| 598 | 598 | ||||
| 599 | + | s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation) | |||
| 599 | stream, err := service.OpenStream(deadline.Context(), request) | 600 | stream, err := service.OpenStream(deadline.Context(), request) | ||
| 600 | if err != nil { | 601 | if err != nil { | ||
| 602 | + | s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err)) | |||
| 601 | s.reportAttachmentFailure(deadline.cause(err)) | 603 | s.reportAttachmentFailure(deadline.cause(err)) | ||
| 602 | return false, nil | 604 | return false, nil | ||
| 603 | } | 605 | } | ||
| @@ -627,6 +629,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif | |||||
| 627 | } | 629 | } | ||
| 628 | 630 | ||||
| 629 | token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)} | 631 | token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)} | ||
| 632 | + | s.logger.Debug("client_stream_opened", "local", request.Local, "endpoint", request.Endpoint, "generation", token.Generation, "attempt", token.Attempt) | |||
| 630 | run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream) | 633 | run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream) | ||
| 631 | if !ok { | 634 | if !ok { | ||
| 632 | // Another foreground owns the terminal, or the shared reader is | 635 | // Another foreground owns the terminal, or the shared reader is | ||
| @@ -734,16 +737,19 @@ settlement: | |||||
| 734 | return false, nil | 737 | return false, nil | ||
| 735 | } | 738 | } | ||
| 736 | if errors.Is(result.event.Err, errDetachAndExit) { | 739 | if errors.Is(result.event.Err, errDetachAndExit) { | ||
| 740 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "explicit_detach") | |||
| 737 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 741 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 738 | s.notifyLifecycle(LifecycleNoticeDetachAndExit) | 742 | s.notifyLifecycle(LifecycleNoticeDetachAndExit) | ||
| 739 | return true, nil | 743 | return true, nil | ||
| 740 | } | 744 | } | ||
| 741 | if errors.Is(result.event.Err, errDetachToPicker) { | 745 | if errors.Is(result.event.Err, errDetachToPicker) { | ||
| 746 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "detach_to_picker") | |||
| 742 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 747 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 743 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | 748 | s.notifyLifecycle(LifecycleNoticeDetachToPicker) | ||
| 744 | return false, nil | 749 | return false, nil | ||
| 745 | } | 750 | } | ||
| 746 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | 751 | if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) { | ||
| 752 | + | s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err)) | |||
| 747 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | 753 | s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err}) | ||
| 748 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | 754 | s.notifyLifecycle(LifecycleNoticeDestinationFailed) | ||
| 749 | s.notifyAttachment(result.event.Err) | 755 | s.notifyAttachment(result.event.Err) | ||
| @@ -755,6 +761,7 @@ settlement: | |||||
| 755 | // attached presentation showing. | 761 | // attached presentation showing. | ||
| 756 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | 762 | s.transition(supervisorEvent{kind: supervisorAttachEnded}) | ||
| 757 | if !brokerLost { | 763 | if !brokerLost { | ||
| 764 | + | s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String()) | |||
| 758 | s.notifyLifecycle(LifecycleNoticeSessionEnded) | 765 | s.notifyLifecycle(LifecycleNoticeSessionEnded) | ||
| 759 | } | 766 | } | ||
| 760 | return false, nil | 767 | return false, nil | ||
M internal/usecase/picker/model_test.go +13 -0
| @@ -303,6 +303,19 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||
| 303 | 303 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | |
| 304 | 304 | } | |
| 305 | 305 | ||
| 306 | + | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |
| 307 | + | m := New([]protocol.PickerLine{{ | |
| 308 | + | Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions", | |
| 309 | + | Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session", | |
| 310 | + | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 311 | + | }}, Config{Intent: protocol.PickerIntentNavigation}) | |
| 312 | + | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | |
| 313 | + | require.Contains(t, rowText(frame.Row(0)), "[no daemon]") | |
| 314 | + | require.Contains(t, rowText(frame.Row(7)), "no daemon") | |
| 315 | + | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | |
| 316 | + | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | |
| 317 | + | } | |
| 318 | + | ||
| 306 | 319 | func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) { | |
| 307 | 320 | m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation}) | |
| 308 | 321 | preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1} | |
| @@ -303,6 +303,19 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||||
| 303 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | 303 | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | ||
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | + | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |||
| 307 | + | m := New([]protocol.PickerLine{{ | |||
| 308 | + | Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions", | |||
| 309 | + | Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session", | |||
| 310 | + | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | |||
| 311 | + | }}, Config{Intent: protocol.PickerIntentNavigation}) | |||
| 312 | + | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | |||
| 313 | + | require.Contains(t, rowText(frame.Row(0)), "[no daemon]") | |||
| 314 | + | require.Contains(t, rowText(frame.Row(7)), "no daemon") | |||
| 315 | + | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | |||
| 316 | + | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | |||
| 317 | + | } | |||
| 318 | + | ||||
| 306 | func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) { | 319 | func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) { | ||
| 307 | m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation}) | 320 | m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation}) | ||
| 308 | preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1} | 321 | preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1} | ||