mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
536 lines
17 KiB
Go
536 lines
17 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
)
|
|
|
|
// fakeMutator is a minimal MutatorInterface for tests that don't touch the
|
|
// filesystem. It records the order of AddForward calls.
|
|
type fakeMutator struct {
|
|
addError error
|
|
added []config.Forward
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (f *fakeMutator) AddForward(ctxName, ns string, fwd config.Forward) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.addError != nil {
|
|
return f.addError
|
|
}
|
|
fwd.SetContext(ctxName, ns)
|
|
f.added = append(f.added, fwd)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error {
|
|
return nil
|
|
}
|
|
func (f *fakeMutator) RemoveForwardByID(id string) error { return nil }
|
|
func (f *fakeMutator) RemoveForwardByIDInContext(contextName, id string) error {
|
|
return nil
|
|
}
|
|
func (f *fakeMutator) UpdateForwardInContext(oldCtx, oldID, newCtx, newNS string, newFwd config.Forward) error {
|
|
return nil
|
|
}
|
|
func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error {
|
|
return nil
|
|
}
|
|
|
|
// fakeDiscovery is a minimal DiscoveryInterface for tests.
|
|
type fakeDiscovery struct {
|
|
servicesByNS map[string][]k8s.ServiceInfo
|
|
listNamespacesEr error
|
|
listServicesEr error
|
|
namespaces []string
|
|
}
|
|
|
|
func (f *fakeDiscovery) ListContexts() ([]string, error) { return []string{"test"}, nil }
|
|
func (f *fakeDiscovery) GetCurrentContext() (string, error) { return "test", nil }
|
|
func (f *fakeDiscovery) ListNamespaces(_ context.Context, _ string) ([]string, error) {
|
|
return f.namespaces, f.listNamespacesEr
|
|
}
|
|
func (f *fakeDiscovery) ListPods(_ context.Context, _, _ string) ([]k8s.PodInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeDiscovery) ListPodsWithSelector(_ context.Context, _, _, _ string) ([]k8s.PodInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.ServiceInfo, error) {
|
|
if f.listServicesEr != nil {
|
|
return nil, f.listServicesEr
|
|
}
|
|
return f.servicesByNS[ns], nil
|
|
}
|
|
|
|
// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes.
|
|
func keyOf(s string) tea.KeyMsg {
|
|
switch s {
|
|
case "enter":
|
|
return tea.KeyMsg{Type: tea.KeyEnter}
|
|
case "esc":
|
|
return tea.KeyMsg{Type: tea.KeyEsc}
|
|
case "space":
|
|
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
|
|
case "backspace":
|
|
return tea.KeyMsg{Type: tea.KeyBackspace}
|
|
}
|
|
if len(s) == 1 {
|
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
|
}
|
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
|
}
|
|
|
|
// drainModel applies a sequence of messages and returns the final model.
|
|
func drainModel(t *testing.T, m tea.Model, msgs ...tea.Msg) tea.Model {
|
|
t.Helper()
|
|
cur := m
|
|
for _, msg := range msgs {
|
|
next, _ := cur.Update(msg)
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func TestGenerateModel_NamespaceMultiSelect(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"alpha", "beta", "gamma"},
|
|
servicesByNS: map[string][]k8s.ServiceInfo{
|
|
"alpha": {{Name: "svc-a", Namespace: "alpha", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}}}},
|
|
},
|
|
}
|
|
mut := &fakeMutator{}
|
|
m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", true, nil)
|
|
|
|
// Init (load namespaces)
|
|
cmd := m.Init()
|
|
if cmd == nil {
|
|
t.Fatal("expected Init to return command")
|
|
}
|
|
// Simulate the namespaces-loaded message.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
if gm.loading {
|
|
t.Fatal("expected loading=false after namespaces loaded")
|
|
}
|
|
if len(gm.nsFilteredView) != 3 {
|
|
t.Fatalf("want 3 namespaces, got %d", len(gm.nsFilteredView))
|
|
}
|
|
|
|
// Toggle first item with space — cursor starts at 0.
|
|
gm2 := drainModel(t, gm, keyOf("space")).(*GenerateModel)
|
|
if !gm2.nsSelected["alpha"] {
|
|
t.Fatal("expected alpha to be selected")
|
|
}
|
|
|
|
// 'a' toggles all. Because alpha is selected and the others are not,
|
|
// allSelected=false so the press selects everything visible.
|
|
gm3 := drainModel(t, gm2, keyOf("a")).(*GenerateModel)
|
|
for _, ns := range []string{"alpha", "beta", "gamma"} {
|
|
if !gm3.nsSelected[ns] {
|
|
t.Fatalf("expected %s to be selected after first toggle-all", ns)
|
|
}
|
|
}
|
|
// Press again — now all are selected, so it should deselect all.
|
|
gm4 := drainModel(t, gm3, keyOf("a")).(*GenerateModel)
|
|
for _, ns := range []string{"alpha", "beta", "gamma"} {
|
|
if gm4.nsSelected[ns] {
|
|
t.Fatalf("expected %s to be unselected after second toggle-all", ns)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_NamespaceFilter(t *testing.T) {
|
|
disc := &fakeDiscovery{namespaces: []string{"alpha", "beta", "gamma"}}
|
|
m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
// Enter filter mode
|
|
gm = drainModel(t, gm, keyOf("/")).(*GenerateModel)
|
|
if !gm.nsFiltering {
|
|
t.Fatal("expected to enter filter mode")
|
|
}
|
|
gm = drainModel(t, gm, keyOf("b")).(*GenerateModel)
|
|
if gm.nsFilter != "b" {
|
|
t.Fatalf("expected filter=b, got %q", gm.nsFilter)
|
|
}
|
|
if len(gm.nsFilteredView) != 1 || gm.nsFilteredView[0] != "beta" {
|
|
t.Fatalf("expected [beta], got %v", gm.nsFilteredView)
|
|
}
|
|
// Exit filter
|
|
gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel)
|
|
if gm.nsFiltering {
|
|
t.Fatal("expected filtering to be off after enter")
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_ServiceMultiSelectAndLock(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"ns1"},
|
|
servicesByNS: map[string][]k8s.ServiceInfo{
|
|
"ns1": {
|
|
{Name: "svc-a", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}, {Port: 443, Protocol: "TCP"}}},
|
|
{Name: "svc-udp", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 53, Protocol: "UDP"}}},
|
|
},
|
|
},
|
|
}
|
|
// One forward already configured: svc-a:80 in ns1
|
|
existing := []config.Forward{makeFwd("ctx", "ns1", "service/svc-a", 80, 9000, "tcp")}
|
|
|
|
m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, existing)
|
|
// Drive past the namespace step.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
gm.nsSelected["ns1"] = true
|
|
// Press enter to advance to services step.
|
|
model2, _ := gm.Update(keyOf("enter"))
|
|
gm2 := model2.(*GenerateModel)
|
|
// Provide the loaded services.
|
|
model3, _ := gm2.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{
|
|
"ns1": {
|
|
{Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "svc-a", Port: 443, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "svc-udp", Port: 53, Protocol: "UDP"},
|
|
},
|
|
}})
|
|
gm3 := model3.(*GenerateModel)
|
|
|
|
if len(gm3.svcOrder) != 3 {
|
|
t.Fatalf("want 3 candidates, got %d", len(gm3.svcOrder))
|
|
}
|
|
if !gm3.svcLocked[(ServiceCandidate{Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"}).Key()] {
|
|
t.Fatal("svc-a:80 should be locked (already in config)")
|
|
}
|
|
|
|
// Move to svc-a:443 (cursor index 1) and toggle.
|
|
gm4 := drainModel(t, gm3, keyOf("down"), keyOf("space")).(*GenerateModel)
|
|
sel := gm4.selectedCandidates()
|
|
if len(sel) != 1 || sel[0].Service != "svc-a" || sel[0].Port != 443 {
|
|
t.Fatalf("expected [svc-a:443], got %v", sel)
|
|
}
|
|
|
|
// Try to toggle the locked row (cursor 0) — should remain unselected.
|
|
gm5 := drainModel(t, gm4, keyOf("up"), keyOf("space")).(*GenerateModel)
|
|
for _, c := range gm5.selectedCandidates() {
|
|
if c.Port == 80 {
|
|
t.Fatal("locked row was selectable")
|
|
}
|
|
}
|
|
|
|
// Toggle-all should select all selectable (i.e., svc-a:443 only — the others are locked or non-TCP).
|
|
gm6 := drainModel(t, gm5, keyOf("a")).(*GenerateModel)
|
|
// First press: all eligible already selected (svc-a:443) → deselect.
|
|
if len(gm6.selectedCandidates()) != 0 {
|
|
t.Fatalf("expected toggle-all to deselect, got %d", len(gm6.selectedCandidates()))
|
|
}
|
|
gm7 := drainModel(t, gm6, keyOf("a")).(*GenerateModel)
|
|
if len(gm7.selectedCandidates()) != 1 {
|
|
t.Fatalf("expected 1 selected after second toggle-all, got %d", len(gm7.selectedCandidates()))
|
|
}
|
|
}
|
|
|
|
// readyModel returns a model with loading already cleared so step-level
|
|
// behaviour can be tested without injecting load messages first.
|
|
func readyModel(disc DiscoveryInterface, mut MutatorInterface, ctx, cfg string, dryRun bool, existing []config.Forward) *GenerateModel {
|
|
m := NewGenerateModel(disc, mut, ctx, cfg, dryRun, existing)
|
|
m.loading = false
|
|
return m
|
|
}
|
|
|
|
func TestGenerateModel_PortAssignmentWithCollisions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, ".kportal.yaml")
|
|
// Seed an existing config that has localPort 10000 and 10002 already used.
|
|
seed := []byte(`contexts:
|
|
- name: ctx
|
|
namespaces:
|
|
- name: existing
|
|
forwards:
|
|
- resource: service/legacy
|
|
port: 8080
|
|
localPort: 10000
|
|
protocol: tcp
|
|
- resource: service/legacy2
|
|
port: 8080
|
|
localPort: 10002
|
|
protocol: tcp
|
|
`)
|
|
if err := os.WriteFile(configPath, seed, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Re-load to grab the existing forwards.
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
mut := config.NewMutator(configPath)
|
|
|
|
disc := &fakeDiscovery{}
|
|
m := readyModel(disc, mut, "ctx", configPath, false, cfg.GetAllForwards())
|
|
// Pre-populate svcOrder with three candidates that need ports.
|
|
m.svcOrder = []ServiceCandidate{
|
|
{Namespace: "ns1", Service: "alpha", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "beta", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "gamma", Port: 80, Protocol: "TCP"},
|
|
}
|
|
for _, c := range m.svcOrder {
|
|
m.svcSelected[c.Key()] = true
|
|
}
|
|
|
|
planned := m.assignPorts(10000)
|
|
if len(planned) != 3 {
|
|
t.Fatalf("expected 3 planned forwards, got %d", len(planned))
|
|
}
|
|
got := []int{planned[0].LocalPort, planned[1].LocalPort, planned[2].LocalPort}
|
|
want := []int{10001, 10003, 10004} // 10000 and 10002 taken
|
|
for i, p := range want {
|
|
if got[i] != p {
|
|
t.Fatalf("planned[%d] localPort: want %d, got %d (full=%v)", i, p, got[i], got)
|
|
}
|
|
}
|
|
|
|
// Now invoke saveCmd through the model and verify mutator side-effects.
|
|
m.startingPortStr = "10000"
|
|
for _, c := range m.svcOrder {
|
|
m.svcSelected[c.Key()] = true
|
|
}
|
|
m.step = GenerateStepPortAssign
|
|
model2, cmd := m.Update(keyOf("enter"))
|
|
if cmd == nil {
|
|
t.Fatal("expected save command")
|
|
}
|
|
msg := cmd()
|
|
saved, ok := msg.(generateSavedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected generateSavedMsg, got %T", msg)
|
|
}
|
|
if saved.added != 3 {
|
|
t.Fatalf("expected 3 added, got %d (errors=%v)", saved.added, saved.errors)
|
|
}
|
|
|
|
// Verify config file now has 5 forwards total.
|
|
cfg2, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
if len(cfg2.GetAllForwards()) != 5 {
|
|
t.Fatalf("expected 5 forwards after save, got %d", len(cfg2.GetAllForwards()))
|
|
}
|
|
_ = model2
|
|
}
|
|
|
|
func TestGenerateModel_PortBelow1024Rejected(t *testing.T) {
|
|
m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
m.startingPortStr = "80"
|
|
|
|
model, cmd := m.Update(keyOf("enter"))
|
|
gm := model.(*GenerateModel)
|
|
if cmd != nil {
|
|
t.Fatal("expected no command (rejected)")
|
|
}
|
|
if gm.portError == "" {
|
|
t.Fatal("expected port error to be set")
|
|
}
|
|
if gm.step != GenerateStepPortAssign {
|
|
t.Fatal("expected to remain on port step after invalid input")
|
|
}
|
|
|
|
// Backspace + retype a valid value should clear the error and allow continuing.
|
|
gm.startingPortStr = "1024"
|
|
model2, cmd2 := gm.Update(keyOf("enter"))
|
|
gm2 := model2.(*GenerateModel)
|
|
if cmd2 == nil {
|
|
t.Fatal("expected save command after valid port")
|
|
}
|
|
if gm2.portError != "" {
|
|
t.Fatalf("expected port error cleared, got %q", gm2.portError)
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_DryRunDoesNotInvokeMutator(t *testing.T) {
|
|
mut := &fakeMutator{}
|
|
m := readyModel(&fakeDiscovery{}, mut, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
m.startingPortStr = "10000"
|
|
|
|
model, cmd := m.Update(keyOf("enter"))
|
|
gm := model.(*GenerateModel)
|
|
if !gm.result.UsedDryRun {
|
|
t.Fatal("expected dry-run flag set in result")
|
|
}
|
|
if len(mut.added) != 0 {
|
|
t.Fatalf("expected mutator untouched in dry-run, got %d adds", len(mut.added))
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected quit command from dry-run path")
|
|
}
|
|
if msg := cmd(); msg == nil {
|
|
// Quit returns a tea.QuitMsg — just ensure it's non-nil.
|
|
t.Fatal("expected non-nil quit message")
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_EndToEnd(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"ns1"},
|
|
}
|
|
mut := &fakeMutator{}
|
|
m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", false, nil)
|
|
|
|
// Init returns a Cmd; we don't run it directly. Instead we manually
|
|
// inject the messages it would produce.
|
|
_ = m.Init()
|
|
|
|
// 1. Namespaces load.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
// 2. Toggle ns1 + enter.
|
|
gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel)
|
|
if gm.step != GenerateStepServices {
|
|
t.Fatalf("expected services step, got %v", gm.step)
|
|
}
|
|
|
|
// 3. Provide loaded services.
|
|
model2, _ := gm.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{
|
|
"ns1": {{Namespace: "ns1", Service: "svc", Port: 8080, Protocol: "TCP"}},
|
|
}})
|
|
gm = model2.(*GenerateModel)
|
|
|
|
// 4. Toggle the (only) service + enter.
|
|
gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel)
|
|
if gm.step != GenerateStepPortAssign {
|
|
t.Fatalf("expected port-assign step, got %v", gm.step)
|
|
}
|
|
|
|
// 5. Press enter on the default port (10000).
|
|
model3, cmd := gm.Update(keyOf("enter"))
|
|
gm = model3.(*GenerateModel)
|
|
if cmd == nil {
|
|
t.Fatal("expected save command")
|
|
}
|
|
msg := cmd()
|
|
saved := msg.(generateSavedMsg)
|
|
if saved.added != 1 {
|
|
t.Fatalf("expected 1 added, got %d (errs=%v)", saved.added, saved.errors)
|
|
}
|
|
|
|
// 6. Process the saved message → step should be Done.
|
|
model4, _ := gm.Update(saved)
|
|
final := model4.(*GenerateModel)
|
|
if final.step != GenerateStepDone {
|
|
t.Fatalf("expected Done step, got %v", final.step)
|
|
}
|
|
if final.result.Added != 1 {
|
|
t.Fatalf("expected result.Added=1, got %d", final.result.Added)
|
|
}
|
|
if len(mut.added) != 1 {
|
|
t.Fatalf("expected mutator to record 1 forward, got %d", len(mut.added))
|
|
}
|
|
if mut.added[0].Resource != "service/svc" || mut.added[0].LocalPort != 10000 {
|
|
t.Fatalf("unexpected forward recorded: %+v", mut.added[0])
|
|
}
|
|
}
|
|
|
|
// makeFwd is a small helper to build a Forward with context/namespace pre-set.
|
|
func makeFwd(ctxName, ns, resource string, port, localPort int, proto string) config.Forward {
|
|
f := config.Forward{
|
|
Resource: resource,
|
|
Port: port,
|
|
LocalPort: localPort,
|
|
Protocol: proto,
|
|
}
|
|
f.SetContext(ctxName, ns)
|
|
return f
|
|
}
|
|
|
|
func TestGenerateModel_ParseStartingPortBoundary(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
wantOK bool
|
|
wantVal int
|
|
}{
|
|
{"empty", "", false, 0},
|
|
{"non-numeric", "abc", false, 0},
|
|
{"below min", "1023", false, 0},
|
|
{"at min", "1024", true, 1024},
|
|
{"above max", "70000", false, 0},
|
|
{"valid", "10000", true, 10000},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := NewGenerateModel(&fakeDiscovery{}, &fakeMutator{}, "c", "/tmp/x.yaml", true, nil)
|
|
m.startingPortStr = tc.input
|
|
got, ok := m.parseStartingPort()
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("ok mismatch: want %v, got %v (err=%q)", tc.wantOK, ok, m.portError)
|
|
}
|
|
if ok && got != tc.wantVal {
|
|
t.Fatalf("val mismatch: want %d, got %d", tc.wantVal, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGenerateModel_PortStepView ensures the port-step view renders without panic.
|
|
func TestGenerateModel_PortStepView(t *testing.T) {
|
|
m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
view := m.View()
|
|
if !contains(view, "Step 3 / 3") {
|
|
t.Fatalf("expected step header in view, got: %s", view)
|
|
}
|
|
if !contains(view, "10000") {
|
|
t.Fatalf("expected default port in view, got: %s", view)
|
|
}
|
|
}
|
|
|
|
// contains is a tiny strings.Contains wrapper that also gives a clearer test failure message.
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (sub == "" || stringIndex(s, sub) >= 0)
|
|
}
|
|
|
|
func stringIndex(s, sub string) int {
|
|
if sub == "" {
|
|
return 0
|
|
}
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Sanity: ensure the model satisfies tea.Model interface — compile-time check.
|
|
var _ tea.Model = (*GenerateModel)(nil)
|
|
|
|
// Sanity: ensure existing key generation matches a manually-built one.
|
|
func TestServiceCandidate_KeyDeterministic(t *testing.T) {
|
|
c := ServiceCandidate{Namespace: "ns1", Service: "svc", Port: 80, Protocol: "TCP"}
|
|
want := fmt.Sprintf("%s|%s|%s|%d", "ns1", "service/svc", "tcp", 80)
|
|
if c.Key() != want {
|
|
t.Fatalf("Key() mismatch: want %q, got %q", want, c.Key())
|
|
}
|
|
}
|