fix: simplify dashboard and disable unauthorized actions
hermetic-ci / test (push) Canceled after 0s
hermetic-ci / test (push) Canceled after 0s
This commit is contained in:
@@ -97,8 +97,8 @@ func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
|||||||
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
|
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
|
||||||
t.Fatalf("run proxyctl: %v", err)
|
t.Fatalf("run proxyctl: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output.String(), "Active Profile: office") {
|
if !strings.Contains(output.String(), "Profile: office") {
|
||||||
t.Fatalf("default TUI output missing Active Profile: %q", output.String())
|
t.Fatalf("default TUI output missing Profile: %q", output.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,19 +247,17 @@ func TestCommandsRunAsRealProcessesInsideIsolatedUserState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defaultView := string(defaultOutput)
|
defaultView := string(defaultOutput)
|
||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"proxyctl Operations Console",
|
"proxyctl Profile: No Active Profile",
|
||||||
"Active Profile: No Active Profile",
|
|
||||||
"Targets: ● Enabled 0 ○ Disabled 1 ◇ External 0 ! Drifted 1",
|
|
||||||
"▶ Targets [focused]",
|
"▶ Targets [focused]",
|
||||||
"> ○ Disabled Shell",
|
"> ○ Disabled Shell",
|
||||||
"! Drifted Docker daemon",
|
"! Drifted Docker daemon",
|
||||||
"Target details",
|
"Target details",
|
||||||
"State: ○ Disabled",
|
"○ Disabled",
|
||||||
"Evidence: no recognized Shell proxy configuration detected",
|
"no recognized Shell proxy configuration detected",
|
||||||
"Tab: focus details",
|
"Tab: Details",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(defaultView, expected) {
|
if !strings.Contains(defaultView, expected) {
|
||||||
t.Errorf("default Operations Console missing %q; output=%q", expected, defaultOutput)
|
t.Errorf("default dashboard missing %q; output=%q", expected, defaultOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
versionOutput, err := invoke("", "version", "--short")
|
versionOutput, err := invoke("", "version", "--short")
|
||||||
|
|||||||
+47
-16
@@ -164,6 +164,7 @@ type Application struct {
|
|||||||
targetDescriptions []target.Description
|
targetDescriptions []target.Description
|
||||||
availabilities map[string]target.Availability
|
availabilities map[string]target.Availability
|
||||||
inspections map[string]target.Inspection
|
inspections map[string]target.Inspection
|
||||||
|
mutationBlockReasons map[string]string
|
||||||
detectionPhase map[string]TargetDetectionPhase
|
detectionPhase map[string]TargetDetectionPhase
|
||||||
detectionErr map[string]error
|
detectionErr map[string]error
|
||||||
detectionGeneration uint64
|
detectionGeneration uint64
|
||||||
@@ -197,15 +198,16 @@ func NewWithTargetsAndRuntime(store profile.Store, targets []target.Target, muta
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
a := &Application{
|
a := &Application{
|
||||||
store: store,
|
store: store,
|
||||||
state: state,
|
state: state,
|
||||||
targets: append([]target.Target(nil), targets...),
|
targets: append([]target.Target(nil), targets...),
|
||||||
availabilities: make(map[string]target.Availability),
|
availabilities: make(map[string]target.Availability),
|
||||||
inspections: make(map[string]target.Inspection),
|
inspections: make(map[string]target.Inspection),
|
||||||
detectionPhase: make(map[string]TargetDetectionPhase),
|
mutationBlockReasons: make(map[string]string),
|
||||||
detectionErr: make(map[string]error),
|
detectionPhase: make(map[string]TargetDetectionPhase),
|
||||||
mutationLock: mutationLock,
|
detectionErr: make(map[string]error),
|
||||||
logger: logger,
|
mutationLock: mutationLock,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
for _, adapter := range a.targets {
|
for _, adapter := range a.targets {
|
||||||
id := adapter.ID()
|
id := adapter.ID()
|
||||||
@@ -229,7 +231,7 @@ func (a *Application) snapshotLocked() Snapshot {
|
|||||||
availability := a.availabilities[id]
|
availability := a.availabilities[id]
|
||||||
inspection := a.inspections[id]
|
inspection := a.inspections[id]
|
||||||
phase := a.detectionPhase[id]
|
phase := a.detectionPhase[id]
|
||||||
primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "")
|
primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "", a.mutationBlockReasons[id])
|
||||||
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
|
snapshot.Targets = append(snapshot.Targets, TargetSnapshot{
|
||||||
ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
|
ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id],
|
||||||
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
|
Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence,
|
||||||
@@ -239,7 +241,7 @@ func (a *Application) snapshotLocked() Snapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) {
|
func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool, mutationBlockReason string) (TargetAction, []TargetAction) {
|
||||||
primaryKind := primaryTargetActionKind(state)
|
primaryKind := primaryTargetActionKind(state)
|
||||||
primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0}
|
primary := TargetAction{Kind: primaryKind, Available: primaryKind != 0}
|
||||||
if primaryKind == 0 {
|
if primaryKind == 0 {
|
||||||
@@ -253,6 +255,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
|
|||||||
primary.Available = false
|
primary.Available = false
|
||||||
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
|
primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first"
|
||||||
}
|
}
|
||||||
|
if primary.Available && mutationBlockReason != "" {
|
||||||
|
primary.Available = false
|
||||||
|
primary.DisabledReason = mutationBlockReason
|
||||||
|
}
|
||||||
if state != target.Drifted {
|
if state != target.Drifted {
|
||||||
return primary, nil
|
return primary, nil
|
||||||
}
|
}
|
||||||
@@ -271,6 +277,10 @@ func contextualTargetActions(phase TargetDetectionPhase, available bool, state t
|
|||||||
action.Available = false
|
action.Available = false
|
||||||
action.DisabledReason = reason
|
action.DisabledReason = reason
|
||||||
}
|
}
|
||||||
|
if action.Available && mutationBlockReason != "" {
|
||||||
|
action.Available = false
|
||||||
|
action.DisabledReason = mutationBlockReason
|
||||||
|
}
|
||||||
resolution = append(resolution, action)
|
resolution = append(resolution, action)
|
||||||
}
|
}
|
||||||
return primary, resolution
|
return primary, resolution
|
||||||
@@ -300,8 +310,9 @@ func targetActionDisabledReason(phase TargetDetectionPhase, available bool) stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
type targetDetectionOutcome struct {
|
type targetDetectionOutcome struct {
|
||||||
targetID string
|
targetID string
|
||||||
detection target.Detection
|
detection target.Detection
|
||||||
|
mutationBlockReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type targetDetectionUpdate struct {
|
type targetDetectionUpdate struct {
|
||||||
@@ -327,7 +338,10 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha
|
|||||||
for i, adapter := range a.targets {
|
for i, adapter := range a.targets {
|
||||||
adapter, id := adapter, a.targetIDs[i]
|
adapter, id := adapter, a.targetIDs[i]
|
||||||
go func() {
|
go func() {
|
||||||
outcomes <- targetDetectionOutcome{targetID: id, detection: detectTarget(ctx, adapter, active)}
|
outcomes <- targetDetectionOutcome{
|
||||||
|
targetID: id, detection: detectTarget(ctx, adapter, active),
|
||||||
|
mutationBlockReason: mutationBlockReason(ctx, adapter),
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -345,6 +359,7 @@ func (a *Application) beginTargetDetection(ctx context.Context) (Snapshot, <-cha
|
|||||||
}
|
}
|
||||||
a.availabilities[outcome.targetID] = outcome.detection.Availability
|
a.availabilities[outcome.targetID] = outcome.detection.Availability
|
||||||
a.inspections[outcome.targetID] = outcome.detection.Inspection
|
a.inspections[outcome.targetID] = outcome.detection.Inspection
|
||||||
|
a.mutationBlockReasons[outcome.targetID] = outcome.mutationBlockReason
|
||||||
a.detectionPhase[outcome.targetID] = phase
|
a.detectionPhase[outcome.targetID] = phase
|
||||||
a.detectionErr[outcome.targetID] = outcome.detection.Err
|
a.detectionErr[outcome.targetID] = outcome.detection.Err
|
||||||
update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID}
|
update := targetDetectionUpdate{snapshot: a.snapshotLocked(), targetID: outcome.targetID}
|
||||||
@@ -372,6 +387,21 @@ func detectTarget(ctx context.Context, adapter target.Target, active *profile.Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mutationBlockReason(ctx context.Context, adapter target.Target) string {
|
||||||
|
authorizer, ok := adapter.(target.MutationAuthorizer)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
check := authorizer.MutationAuthority(ctx)
|
||||||
|
if check.OK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if check.Evidence == "" {
|
||||||
|
return "The current process does not have authority to change this Target"
|
||||||
|
}
|
||||||
|
return check.Evidence
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Application) Dispatch(intent Intent) Result {
|
func (a *Application) Dispatch(intent Intent) Result {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), operationTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -642,12 +672,13 @@ func (a *Application) replaceInspection(id string, inspection target.Inspection)
|
|||||||
// publishTargetDetection atomically replaces all availability, inspection, and
|
// publishTargetDetection atomically replaces all availability, inspection, and
|
||||||
// failure fields from one targeted check. Detect runs before this method, so no
|
// failure fields from one targeted check. Detect runs before this method, so no
|
||||||
// Application state mutex is held while adapter code executes.
|
// Application state mutex is held while adapter code executes.
|
||||||
func (a *Application) publishTargetDetection(id string, detection target.Detection) Snapshot {
|
func (a *Application) publishTargetDetection(id string, detection target.Detection, mutationBlockReason string) Snapshot {
|
||||||
a.stateMu.Lock()
|
a.stateMu.Lock()
|
||||||
defer a.stateMu.Unlock()
|
defer a.stateMu.Unlock()
|
||||||
a.detectionGeneration++
|
a.detectionGeneration++
|
||||||
a.availabilities[id] = detection.Availability
|
a.availabilities[id] = detection.Availability
|
||||||
a.inspections[id] = detection.Inspection
|
a.inspections[id] = detection.Inspection
|
||||||
|
a.mutationBlockReasons[id] = mutationBlockReason
|
||||||
a.detectionErr[id] = detection.Err
|
a.detectionErr[id] = detection.Err
|
||||||
a.detectionPhase[id] = TargetReady
|
a.detectionPhase[id] = TargetReady
|
||||||
if detection.Err != nil {
|
if detection.Err != nil {
|
||||||
@@ -806,7 +837,7 @@ func (a *Application) setTargetDesired(ctx context.Context, intent Intent, publi
|
|||||||
// before action validation. Adapter calls happen without stateMu; the full
|
// before action validation. Adapter calls happen without stateMu; the full
|
||||||
// outcome is then published under one lock before policy is derived.
|
// outcome is then published under one lock before policy is derived.
|
||||||
detection := detectTarget(ctx, selected, active)
|
detection := detectTarget(ctx, selected, active)
|
||||||
snapshot := a.publishTargetDetection(intent.TargetID, detection)
|
snapshot := a.publishTargetDetection(intent.TargetID, detection, mutationBlockReason(ctx, selected))
|
||||||
selectedSnapshot := snapshot.Targets[selectedIndex]
|
selectedSnapshot := snapshot.Targets[selectedIndex]
|
||||||
policy, err := validateTargetAction(intent, selectedSnapshot)
|
policy, err := validateTargetAction(intent, selectedSnapshot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -103,6 +103,35 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authorityTarget struct {
|
||||||
|
*fakeTarget
|
||||||
|
allowed bool
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *authorityTarget) MutationAuthority(context.Context) target.Check {
|
||||||
|
return target.Check{Name: "mutation authority", OK: t.allowed, Evidence: t.reason}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplicationDisablesMutationWhenCurrentProcessLacksAuthority(t *testing.T) {
|
||||||
|
adapter := &authorityTarget{fakeTarget: &fakeTarget{available: true, state: target.Disabled}, reason: "requires elevated authority"}
|
||||||
|
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
createAndActivate(t, application)
|
||||||
|
refreshApplicationTargets(t, application)
|
||||||
|
|
||||||
|
snapshot := application.Snapshot()
|
||||||
|
if snapshot.Targets[0].PrimaryAction.Available || snapshot.Targets[0].PrimaryAction.DisabledReason != adapter.reason {
|
||||||
|
t.Fatalf("authority policy = %#v", snapshot.Targets[0].PrimaryAction)
|
||||||
|
}
|
||||||
|
result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()})
|
||||||
|
if result.Err == nil || !strings.Contains(result.Err.Error(), adapter.reason) || len(adapter.calls) != 0 {
|
||||||
|
t.Fatalf("blocked mutation result = %#v calls=%v", result, adapter.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type targetWithID struct {
|
type targetWithID struct {
|
||||||
*fakeTarget
|
*fakeTarget
|
||||||
id string
|
id string
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ func (a *Adapter) Confirmation(bool) string { return "" }
|
|||||||
|
|
||||||
// DiagnosticChecks observes the APT command, configuration schema, and current
|
// DiagnosticChecks observes the APT command, configuration schema, and current
|
||||||
// filesystem authority without changing the overlay or effective settings.
|
// filesystem authority without changing the overlay or effective settings.
|
||||||
|
func (a *Adapter) MutationAuthority(context.Context) target.Check {
|
||||||
|
check := target.ReadOnlyWriteAuthority(a.configuration.OverlayFile)
|
||||||
|
if !check.OK {
|
||||||
|
check.Evidence += "; run proxyctl with sufficient authority (normally sudo proxyctl)"
|
||||||
|
}
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
||||||
commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"}
|
commandCheck := target.Check{Name: "required command", OK: true, Evidence: "apt-config is available"}
|
||||||
command, err := a.runner.LookPath("apt-config")
|
command, err := a.runner.LookPath("apt-config")
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ func (a *Adapter) Confirmation(bool) string {
|
|||||||
|
|
||||||
// DiagnosticChecks performs only systemctl show and filesystem reads/metadata
|
// DiagnosticChecks performs only systemctl show and filesystem reads/metadata
|
||||||
// probes; service reload/restart and all configuration mutations are excluded.
|
// probes; service reload/restart and all configuration mutations are excluded.
|
||||||
|
func (a *Adapter) MutationAuthority(context.Context) target.Check {
|
||||||
|
if a.authority.EffectiveUID() != 0 {
|
||||||
|
return target.Check{Name: "mutation authority", Evidence: "Docker daemon changes require root authority; restart with sudo proxyctl"}
|
||||||
|
}
|
||||||
|
check := target.ReadOnlyWriteAuthority(a.configuration.DaemonConfigPath, a.configuration.RecoveryPath)
|
||||||
|
check.Name = "mutation authority"
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
func (a *Adapter) DiagnosticChecks(ctx context.Context, _ *profile.Profile) []target.Check {
|
||||||
commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"}
|
commandCheck := target.Check{Name: "required command and service", OK: true, Evidence: "systemctl can inspect the loaded docker.service"}
|
||||||
if availability, err := a.detect(ctx); err != nil || !availability.Available {
|
if availability, err := a.detect(ctx); err != nil || !availability.Available {
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ type Diagnosable interface {
|
|||||||
DiagnosticChecks(context.Context, *profile.Profile) []Check
|
DiagnosticChecks(context.Context, *profile.Profile) []Check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MutationAuthorizer optionally explains why a detected Target cannot be
|
||||||
|
// changed by the current process. It is read-only and presentation-independent.
|
||||||
|
type MutationAuthorizer interface {
|
||||||
|
MutationAuthority(context.Context) Check
|
||||||
|
}
|
||||||
|
|
||||||
// Target hides platform paths and commands from the application and TUI.
|
// Target hides platform paths and commands from the application and TUI.
|
||||||
type Target interface {
|
type Target interface {
|
||||||
ID() string
|
ID() string
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func TestCompleteModelPickerConfirmedActivationContinuesThroughUnifiedOperation(
|
|||||||
completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil)
|
completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil)
|
||||||
close(completion)
|
close(completion)
|
||||||
model, _ = applyOperationCommand(t, model, command)
|
model, _ = applyOperationCommand(t, model, command)
|
||||||
if view := model.View(); !strings.Contains(view, "Active Profile: new") || !strings.Contains(view, `Activated Profile "new"`) {
|
if view := model.View(); !strings.Contains(view, "Profile: new") || !strings.Contains(view, `Activated Profile "new"`) {
|
||||||
t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view)
|
t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai
|
|||||||
close(updates)
|
close(updates)
|
||||||
model, _ = applyOperationCommand(t, model, command)
|
model, _ = applyOperationCommand(t, model, command)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") {
|
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Profile: new") {
|
||||||
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
|
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,9 @@ func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T)
|
|||||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}}
|
||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "Action: "+test.label) || !strings.Contains(view, "Space: "+test.label) {
|
if !strings.Contains(view, "Space: "+test.label) {
|
||||||
t.Fatalf("contextual action not rendered:\n%s", view)
|
t.Fatalf("contextual action not rendered:\n%s", view)
|
||||||
}
|
}
|
||||||
if test.state == target.External && (!strings.Contains(view, "proxyctl will begin managing this configuration") || !strings.Contains(view, "while preserving restorable state")) {
|
|
||||||
t.Fatalf("Apply Profile ownership explanation missing:\n%s", view)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
@@ -91,7 +88,7 @@ func TestCompleteModelRendersUnknownTargetStateAsStructurallyUnavailable(t *test
|
|||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
|
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "Action: Unavailable") || !strings.Contains(view, "Action unavailable:") || !strings.Contains(view, "state is unknown") || !strings.Contains(view, "Space: Unavailable disabled") {
|
if !strings.Contains(view, "Unavailable:") || !strings.Contains(view, "Target actions are unavailable") || !strings.Contains(view, "unknown") || !strings.Contains(view, "Space: Unavailable unavailable") {
|
||||||
t.Fatalf("unknown Target action was not structurally unavailable:\n%s", view)
|
t.Fatalf("unknown Target action was not structurally unavailable:\n%s", view)
|
||||||
}
|
}
|
||||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
@@ -108,7 +105,7 @@ func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *t
|
|||||||
module := &targetModule{snapshot: initial}
|
module := &targetModule{snapshot: initial}
|
||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable disabled") {
|
if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable unavailable") {
|
||||||
t.Fatalf("structured disabled reason missing:\n%s", view)
|
t.Fatalf("structured disabled reason missing:\n%s", view)
|
||||||
}
|
}
|
||||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import "proxyctl/internal/app"
|
||||||
"proxyctl/internal/app"
|
|
||||||
"proxyctl/internal/target"
|
|
||||||
)
|
|
||||||
|
|
||||||
func targetActionLabel(kind app.TargetActionKind) string {
|
func targetActionLabel(kind app.TargetActionKind) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
@@ -101,40 +98,3 @@ func (c targetCatalog) retain(id string, formerIndex int) string {
|
|||||||
}
|
}
|
||||||
return c.items[max(0, min(len(c.items)-1, formerIndex))].ID
|
return c.items[max(0, min(len(c.items)-1, formerIndex))].ID
|
||||||
}
|
}
|
||||||
|
|
||||||
type targetStateCounts struct {
|
|
||||||
enabled int
|
|
||||||
disabled int
|
|
||||||
external int
|
|
||||||
drifted int
|
|
||||||
checking int
|
|
||||||
failed int
|
|
||||||
}
|
|
||||||
|
|
||||||
func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts {
|
|
||||||
var counts targetStateCounts
|
|
||||||
for _, item := range snapshot.Targets {
|
|
||||||
switch item.DetectionPhase {
|
|
||||||
case app.TargetChecking:
|
|
||||||
counts.checking++
|
|
||||||
continue
|
|
||||||
case app.TargetDetectionFailed:
|
|
||||||
counts.failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !item.Available {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch item.State {
|
|
||||||
case target.Enabled:
|
|
||||||
counts.enabled++
|
|
||||||
case target.Disabled:
|
|
||||||
counts.disabled++
|
|
||||||
case target.External:
|
|
||||||
counts.external++
|
|
||||||
case target.Drifted:
|
|
||||||
counts.drifted++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t
|
|||||||
|
|
||||||
view := resizedModel(t, module, themeModeNoColor, 120, 36).View()
|
view := resizedModel(t, module, themeModeNoColor, 120, 36).View()
|
||||||
assertUsableFrame(t, view, 120, 36,
|
assertUsableFrame(t, view, 120, 36,
|
||||||
"proxyctl", "Active Profile: office",
|
"proxyctl", "Profile: office",
|
||||||
"● Enabled 1", "○ Disabled 1", "◇ External 1", "! Drifted 1",
|
"> ● Enabled", "Shell", "matches Active Profile",
|
||||||
"> ● Enabled", "Shell", "State: ● Enabled", "Evidence: matches Active Profile",
|
|
||||||
)
|
)
|
||||||
if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") {
|
if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") {
|
||||||
t.Fatalf("default dashboard included an unavailable Target:\n%s", view)
|
t.Fatalf("default dashboard included an unavailable Target:\n%s", view)
|
||||||
@@ -71,12 +70,8 @@ func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t
|
|||||||
|
|
||||||
view := model.View()
|
view := model.View()
|
||||||
assertUsableFrame(t, view, 120, 36,
|
assertUsableFrame(t, view, 120, 36,
|
||||||
"> ○ Unavailable APT", "State: ○ Unavailable", "Evidence: apt-config was not detected",
|
"> ○ Unavailable APT", "○ Unavailable", "apt-config was not detected",
|
||||||
"Affects: package operations across privilege boundaries",
|
"Persistent package proxy.", "u: Hide unavailable",
|
||||||
"Permissions: pre-existing root write authority",
|
|
||||||
"Ownership & restoration: isolated overlay; disabling reveals the", "previous configuration",
|
|
||||||
"Restart risk: none", "Persistent package proxy.", "Longer APT explanation.",
|
|
||||||
"u: hide unavailable",
|
|
||||||
)
|
)
|
||||||
if !(strings.Index(view, "Shell") < strings.Index(view, "APT") && strings.Index(view, "APT") < strings.Index(view, "Docker daemon")) {
|
if !(strings.Index(view, "Shell") < strings.Index(view, "APT") && strings.Index(view, "APT") < strings.Index(view, "Docker daemon")) {
|
||||||
t.Fatalf("revealing unavailable Targets changed product order:\n%s", view)
|
t.Fatalf("revealing unavailable Targets changed product order:\n%s", view)
|
||||||
@@ -95,7 +90,7 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
targets[22].Description.Summary = "Selected identity summary"
|
targets[22].Description.Summary = "Selected identity summary"
|
||||||
targets[22].Description.Details = strings.Repeat("Scrollable detail line. ", 45) + "END OF TARGET DETAILS"
|
targets[22].Evidence = strings.Repeat("Scrollable evidence line. ", 45) + "END OF TARGET EVIDENCE"
|
||||||
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}}
|
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}}
|
||||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||||
for range 22 {
|
for range 22 {
|
||||||
@@ -103,7 +98,7 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde
|
|||||||
}
|
}
|
||||||
|
|
||||||
view := model.View()
|
view := model.View()
|
||||||
assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "Selected identity summary", "▶ Targets [focused]")
|
assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "▶ Targets [focused]")
|
||||||
|
|
||||||
model = updateKey(model, "tab")
|
model = updateKey(model, "tab")
|
||||||
for range 70 {
|
for range 70 {
|
||||||
@@ -111,16 +106,16 @@ func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIde
|
|||||||
}
|
}
|
||||||
view = model.View()
|
view = model.View()
|
||||||
assertUsableFrame(t, view, 80, 24,
|
assertUsableFrame(t, view, 80, 24,
|
||||||
"> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET DETAILS",
|
"> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET EVIDENCE",
|
||||||
"↑/k ↓/j PgUp/PgDn: scroll", "Tab: focus Targets",
|
"↑/↓: Scroll", "Tab: Targets",
|
||||||
)
|
)
|
||||||
|
|
||||||
model = resize(model, 120, 36)
|
model = resize(model, 120, 36)
|
||||||
view = model.View()
|
view = model.View()
|
||||||
assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET DETAILS", "▶ Target details [focused]")
|
assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET", "EVIDENCE", "▶ Target details [focused]")
|
||||||
if !sameLine(view, "Targets", "Target details") {
|
if !sameLine(view, "Targets", "Target details") {
|
||||||
t.Fatalf("resized dashboard did not become side by side:\n%s", view)
|
t.Fatalf("resized dashboard did not become side by side:\n%s", view)
|
||||||
}
|
}
|
||||||
model = resize(model, 80, 24)
|
model = resize(model, 80, 24)
|
||||||
assertUsableFrame(t, model.View(), 80, 24, "> ○ Disabled Product Target 22", "END OF", "TARGET DETAILS", "▶ Target details [focused]")
|
assertUsableFrame(t, model.View(), 80, 24, "> ○ Disabled Product Target 22", "END OF", "TARGET EVIDENCE", "▶ Target details [focused]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/target"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type dashboardRenderState struct {
|
type dashboardRenderState struct {
|
||||||
@@ -149,55 +148,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress ap
|
|||||||
|
|
||||||
var details strings.Builder
|
var details strings.Builder
|
||||||
details.WriteString(p.theme.section.Render(item.Description.Name))
|
details.WriteString(p.theme.section.Render(item.Description.Name))
|
||||||
fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence)
|
fmt.Fprintf(&details, "\n%s\n%s", state, evidence)
|
||||||
fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind))
|
action := targetActionLabel(item.PrimaryAction.Kind)
|
||||||
if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" {
|
if item.PrimaryAction.Available {
|
||||||
fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason)
|
fmt.Fprintf(&details, "\n\nSpace: %s", action)
|
||||||
}
|
} else if item.PrimaryAction.DisabledReason != "" {
|
||||||
switch item.PrimaryAction.Kind {
|
fmt.Fprintf(&details, "\n\nUnavailable: %s", item.PrimaryAction.DisabledReason)
|
||||||
case app.ApplyProfileTargetAction:
|
|
||||||
details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.")
|
|
||||||
case app.ResolveDriftTargetAction:
|
|
||||||
details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.")
|
|
||||||
}
|
}
|
||||||
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
|
if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil {
|
||||||
fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr)
|
fmt.Fprintf(&details, "\nCheck failed: %s", item.DetectionErr)
|
||||||
}
|
|
||||||
facts := []struct {
|
|
||||||
label string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
{"Affects", item.Description.Scope},
|
|
||||||
{"Permissions", item.Description.Permissions},
|
|
||||||
{"Ownership & restoration", item.Description.Ownership},
|
|
||||||
{"Restart risk", item.Description.RestartRisk},
|
|
||||||
}
|
|
||||||
for _, fact := range facts {
|
|
||||||
if fact.value != "" {
|
|
||||||
fmt.Fprintf(&details, "\n%s: %s", fact.label, fact.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if item.Description.Summary != "" {
|
if item.Description.Summary != "" {
|
||||||
fmt.Fprintf(&details, "\n\n%s", item.Description.Summary)
|
fmt.Fprintf(&details, "\n\n%s", item.Description.Summary)
|
||||||
}
|
}
|
||||||
if item.Description.Details != "" {
|
|
||||||
fmt.Fprintf(&details, "\n\n%s", item.Description.Details)
|
|
||||||
}
|
|
||||||
return protectRenderedText(details.String())
|
return protectRenderedText(details.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
|
|
||||||
counts := countAvailableTargetStates(snapshot)
|
|
||||||
summary := fmt.Sprintf("%s %d %s %d %s %d %s %d",
|
|
||||||
p.theme.renderTargetState(target.Enabled, false), counts.enabled,
|
|
||||||
p.theme.renderTargetState(target.Disabled, false), counts.disabled,
|
|
||||||
p.theme.renderTargetState(target.External, false), counts.external,
|
|
||||||
p.theme.renderTargetState(target.Drifted, false), counts.drifted)
|
|
||||||
if counts.checking > 0 {
|
|
||||||
summary += fmt.Sprintf(" %s %d", p.theme.checking.Render("… Checking"), counts.checking)
|
|
||||||
}
|
|
||||||
if counts.failed > 0 {
|
|
||||||
summary += fmt.Sprintf(" %s %d", p.theme.checkFailed.Render("! Check failed"), counts.failed)
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
|
|||||||
if !sameLine(view, "Targets", "Target details") {
|
if !sameLine(view, "Targets", "Target details") {
|
||||||
t.Fatalf("wide layout did not place list and details side by side:\n%s", view)
|
t.Fatalf("wide layout did not place list and details side by side:\n%s", view)
|
||||||
}
|
}
|
||||||
assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Active Profile: office")
|
assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Profile: office")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("narrow 80x24", func(t *testing.T) {
|
t.Run("narrow 80x24", func(t *testing.T) {
|
||||||
@@ -33,7 +33,7 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
|
|||||||
if sameLine(view, "Targets", "Target details") || strings.Index(view, "Target details") < strings.Index(view, "Shell") {
|
if sameLine(view, "Targets", "Target details") || strings.Index(view, "Target details") < strings.Index(view, "Shell") {
|
||||||
t.Fatalf("narrow layout did not stack list above details:\n%s", view)
|
t.Fatalf("narrow layout did not stack list above details:\n%s", view)
|
||||||
}
|
}
|
||||||
assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Active Profile: office")
|
assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Profile: office")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("below minimum 59x15", func(t *testing.T) {
|
t.Run("below minimum 59x15", func(t *testing.T) {
|
||||||
@@ -82,11 +82,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
|
|||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
model = updateKey(model, "down")
|
model = updateKey(model, "down")
|
||||||
model = resize(model, 80, 24)
|
model = resize(model, 80, 24)
|
||||||
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
|
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Space: Apply Profile") {
|
||||||
t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
|
t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
|
||||||
}
|
}
|
||||||
model = resize(model, 120, 36)
|
model = resize(model, 120, 36)
|
||||||
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
|
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Space: Apply Profile") {
|
||||||
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
|
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func TestCompleteModelSemanticThemeSupportsDarkLightAndNoColor(t *testing.T) {
|
|||||||
plain := resizedModel(t, module, themeModeNoColor, 80, 24).View()
|
plain := resizedModel(t, module, themeModeNoColor, 80, 24).View()
|
||||||
|
|
||||||
for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} {
|
for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} {
|
||||||
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
|
for _, semantic := range []string{"Profile: office", "Disabled", "External", ">"} {
|
||||||
if !strings.Contains(view, semantic) {
|
if !strings.Contains(view, semantic) {
|
||||||
t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view)
|
t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view)
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func TestNewModelUsesInjectedBubbleTeaOutputForTerminalCapabilities(t *testing.T
|
|||||||
if ansiPattern.MatchString(view) {
|
if ansiPattern.MatchString(view) {
|
||||||
t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", view)
|
t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", view)
|
||||||
}
|
}
|
||||||
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
|
for _, semantic := range []string{"Profile: office", "Disabled", "External", ">"} {
|
||||||
if !strings.Contains(view, semantic) {
|
if !strings.Contains(view, semantic) {
|
||||||
t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view)
|
t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view)
|
||||||
}
|
}
|
||||||
@@ -231,19 +231,19 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
|
|||||||
|
|
||||||
assertUsableFrame(t, model.View(), 80, 24,
|
assertUsableFrame(t, model.View(), 80, 24,
|
||||||
"> ! Drifted Docker daemon",
|
"> ! Drifted Docker daemon",
|
||||||
"State: ! Drifted",
|
"! Drifted",
|
||||||
"Docker daemon selected evidence.",
|
"Docker daemon selected evidence.",
|
||||||
"Status: Refresh completed; selected Target remains actionable.",
|
"Status: Refresh completed; selected Target remains actionable.",
|
||||||
"Space: Resolve",
|
"Space: Resolve",
|
||||||
"p: switch Active Profile",
|
"p: Profile",
|
||||||
"m: manage Proxy Profiles",
|
"m: Profiles",
|
||||||
)
|
)
|
||||||
|
|
||||||
model = resize(model, 120, 36)
|
model = resize(model, 120, 36)
|
||||||
model = resize(model, 80, 24)
|
model = resize(model, 80, 24)
|
||||||
assertUsableFrame(t, model.View(), 80, 24,
|
assertUsableFrame(t, model.View(), 80, 24,
|
||||||
"> ! Drifted Docker daemon",
|
"> ! Drifted Docker daemon",
|
||||||
"State: ! Drifted",
|
"! Drifted",
|
||||||
"Docker daemon selected evidence.",
|
"Docker daemon selected evidence.",
|
||||||
"Space: Resolve",
|
"Space: Resolve",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestCompleteModelContextualHelpOpensOverEveryPageAndRestoresPageState(t *te
|
|||||||
model = updateKey(model, "down")
|
model = updateKey(model, "down")
|
||||||
model = updateKey(model, "?")
|
model = updateKey(model, "?")
|
||||||
view := model.View()
|
view := model.View()
|
||||||
assertUsableFrame(t, view, 120, 36, "Operations Console", "Contextual Help", "Dashboard", "Target details focus", "APT", "Disable")
|
assertUsableFrame(t, view, 120, 36, "Profile: office", "Contextual Help", "Dashboard", "Target details focus", "APT", "Disable")
|
||||||
|
|
||||||
model = updateKey(model, " ")
|
model = updateKey(model, " ")
|
||||||
if len(module.intents) != 0 {
|
if len(module.intents) != 0 {
|
||||||
@@ -259,9 +259,9 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("dashboard and busy details", func(t *testing.T) {
|
t.Run("dashboard and busy details", func(t *testing.T) {
|
||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: show unavailable", "r: refresh", "q/Ctrl+C: quit")
|
assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: Show hidden", "r: Refresh", "q: Quit")
|
||||||
model = updateKey(model, "tab")
|
model = updateKey(model, "tab")
|
||||||
assertUsableFrame(t, model.View(), 120, 36, "PgUp/PgDn: scroll", "u: show unavailable", "q/Ctrl+C: quit")
|
assertUsableFrame(t, model.View(), 120, 36, "↑/↓: Scroll", "u: Show hidden", "q: Quit")
|
||||||
|
|
||||||
updates := make(chan app.OperationUpdate)
|
updates := make(chan app.OperationUpdate)
|
||||||
busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
||||||
@@ -269,9 +269,12 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
|
|||||||
model = updateKey(model, "tab")
|
model = updateKey(model, "tab")
|
||||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
assertUsableFrame(t, model.View(), 120, 36, "u: show unavailable", "q/Ctrl+C: defer safe exit", "mutations disabled")
|
assertUsableFrame(t, model.View(), 120, 36, "q: Stop")
|
||||||
model = updateKey(model, "u")
|
model = updateKey(model, "u")
|
||||||
assertUsableFrame(t, model.View(), 120, 36, "u: hide unavailable")
|
if !model.showUnavailable {
|
||||||
|
t.Fatal("u did not toggle unavailable Targets while busy")
|
||||||
|
}
|
||||||
|
assertUsableFrame(t, model.View(), 120, 36, "q: Stop")
|
||||||
close(updates)
|
close(updates)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
model := NewModel(module)
|
model := NewModel(module)
|
||||||
if view := model.View(); !strings.Contains(view, "Active Profile: office") {
|
if view := model.View(); !strings.Contains(view, "Profile: office") {
|
||||||
t.Fatalf("main view missing Active Profile:\n%s", view)
|
t.Fatalf("main view missing Active Profile:\n%s", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
|
|||||||
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
|
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
|
||||||
model := NewModel(&recordingModule{})
|
model := NewModel(&recordingModule{})
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: manage Proxy Profiles") {
|
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: Profiles") {
|
||||||
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
|
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-52
@@ -14,12 +14,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
|||||||
if activeProfile == "" {
|
if activeProfile == "" {
|
||||||
activeProfile = "No Active Profile"
|
activeProfile = "No Active Profile"
|
||||||
}
|
}
|
||||||
header := fmt.Sprintf("%s Operations Console\nActive Profile: %s Targets: %s",
|
header := fmt.Sprintf("%s Profile: %s", p.theme.title.Render("proxyctl"), activeProfile)
|
||||||
p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot))
|
|
||||||
if width < wideWidth {
|
|
||||||
header = fmt.Sprintf("%s Operations Console\nActive Profile: %s\nTargets: %s",
|
|
||||||
p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot))
|
|
||||||
}
|
|
||||||
|
|
||||||
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
|
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
|
||||||
footer := p.dashboardFooter(model, catalog)
|
footer := p.dashboardFooter(model, catalog)
|
||||||
@@ -97,62 +92,30 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
|
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
|
||||||
dashboard, operation, width := model.dashboard, model.operation, model.terminal.width
|
dashboard, operation := model.dashboard, model.operation
|
||||||
visibility := "u: show unavailable"
|
visibility := "u: Show hidden"
|
||||||
if dashboard.showUnavailable {
|
if dashboard.showUnavailable {
|
||||||
visibility = "u: hide unavailable"
|
visibility = "u: Hide unavailable"
|
||||||
|
}
|
||||||
|
if operation.exitDeferred {
|
||||||
|
return "Operation stopping; changes disabled ?: Help"
|
||||||
}
|
}
|
||||||
if dashboard.focus == targetDetailsPane {
|
if dashboard.focus == targetDetailsPane {
|
||||||
if operation.exitDeferred {
|
|
||||||
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion"
|
|
||||||
}
|
|
||||||
if width < wideWidth {
|
|
||||||
if operation.active {
|
|
||||||
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled"
|
|
||||||
}
|
|
||||||
if width < 70 {
|
|
||||||
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Profile m: manage ?: help q: quit"
|
|
||||||
}
|
|
||||||
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
|
|
||||||
}
|
|
||||||
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit"
|
|
||||||
if operation.active {
|
if operation.active {
|
||||||
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled"
|
return "↑/↓: Scroll Tab: Targets ?: Help q: Stop"
|
||||||
}
|
}
|
||||||
return footer
|
return "↑/↓: Scroll Tab: Targets " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit"
|
||||||
}
|
|
||||||
actions := "↑/k ↓/j: select Tab: focus details " + visibility
|
|
||||||
if operation.exitDeferred {
|
|
||||||
return actions + " ?: help\nExit pending; new work disabled until safe completion"
|
|
||||||
}
|
}
|
||||||
selected, ok := catalog.selected(dashboard.selectedTargetID)
|
selected, ok := catalog.selected(dashboard.selectedTargetID)
|
||||||
availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred)
|
availability := targetMutationAvailability(selected, ok, operation.active)
|
||||||
label := targetActionLabel(selected.PrimaryAction.Kind)
|
action := "Space: " + targetActionLabel(selected.PrimaryAction.Kind)
|
||||||
if availability.available {
|
if !availability.available {
|
||||||
actions = "Space: " + label + " " + actions
|
action += " unavailable"
|
||||||
} else {
|
|
||||||
actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
|
|
||||||
}
|
}
|
||||||
if width < wideWidth {
|
|
||||||
if availability.available {
|
|
||||||
actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details"
|
|
||||||
} else {
|
|
||||||
actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details"
|
|
||||||
}
|
|
||||||
|
|
||||||
if operation.active {
|
|
||||||
return actions + "\n?: help q/Ctrl+C: defer exit; mutations disabled"
|
|
||||||
}
|
|
||||||
if width < 70 {
|
|
||||||
return actions + "\np: switch Profile m: manage ?: help q: quit"
|
|
||||||
}
|
|
||||||
return actions + "\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
|
|
||||||
}
|
|
||||||
footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q/Ctrl+C: quit"
|
|
||||||
if operation.active {
|
if operation.active {
|
||||||
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
|
return action + " ↑/↓: Select Tab: Details\n?: Help q: Stop"
|
||||||
}
|
}
|
||||||
return footer
|
return action + " ↑/↓: Select Tab: Details " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p terminalPresentation) profilesView(model presentationState) terminalPage {
|
func (p terminalPresentation) profilesView(model presentationState) terminalPage {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *
|
|||||||
model = updateKey(model, "m")
|
model = updateKey(model, "m")
|
||||||
updated, command := model.Update(back)
|
updated, command := model.Update(back)
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
if command != nil || !strings.Contains(model.View(), "Operations Console") {
|
if command != nil || !strings.Contains(model.View(), "Profile:") {
|
||||||
t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View())
|
t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClos
|
|||||||
if next != nil || model.operation.active {
|
if next != nil || model.operation.active {
|
||||||
t.Fatalf("stale closure left detection active: next=%v active=%v", next != nil, model.operation.active)
|
t.Fatalf("stale closure left detection active: next=%v active=%v", next != nil, model.operation.active)
|
||||||
}
|
}
|
||||||
assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "Evidence: shell enabled")
|
assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "shell enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate {
|
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
|
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
|
||||||
},
|
},
|
||||||
want: []string{"Operations Console", "Checking", "Target availability"},
|
want: []string{"Profile:", "Checking", "Target availability"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "refresh",
|
name: "refresh",
|
||||||
@@ -46,7 +46,7 @@ func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return model
|
return model
|
||||||
},
|
},
|
||||||
want: []string{"Operations Console", "Checking Target availability and state"},
|
want: []string{"Profile:", "Checking Target availability and state"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Target action",
|
name: "Target action",
|
||||||
@@ -155,7 +155,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T)
|
|||||||
model, _ = applyOperationCommand(t, model, command)
|
model, _ = applyOperationCommand(t, model, command)
|
||||||
model = updateKey(model, "e")
|
model = updateKey(model, "e")
|
||||||
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
|
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
|
||||||
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "mutations disabled", "Current error:", "REDACTED", "Action: Enable"} {
|
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "q: Stop", "Current error:", "REDACTED", "Space: Enable"} {
|
||||||
if !strings.Contains(views[mode], semantic) {
|
if !strings.Contains(views[mode], semantic) {
|
||||||
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
|
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
plain := views[themeModeNoColor]
|
plain := views[themeModeNoColor]
|
||||||
for _, symbol := range []string{"● Enabled", "○ Disabled", "! Drifted", "▶ Target details [focused]"} {
|
for _, symbol := range []string{"○ Disabled", "! Drifted", "▶ Target details [focused]"} {
|
||||||
if !strings.Contains(plain, symbol) {
|
if !strings.Contains(plain, symbol) {
|
||||||
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain)
|
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
|
|||||||
snapshot.Profiles[0].Name = longProfile
|
snapshot.Profiles[0].Name = longProfile
|
||||||
snapshot.Targets[0].Description.Name = longName
|
snapshot.Targets[0].Description.Name = longName
|
||||||
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END"
|
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END"
|
||||||
snapshot.Targets[0].Description.Details = strings.Repeat("詳細 line ", 100) + " DETAILS-END"
|
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 120) + " EVIDENCE-END"
|
||||||
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
|
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
|
||||||
|
|
||||||
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
|
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
|
||||||
@@ -196,7 +196,7 @@ func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
|
|||||||
for range 120 {
|
for range 120 {
|
||||||
model = updateKey(model, "pgdown")
|
model = updateKey(model, "pgdown")
|
||||||
}
|
}
|
||||||
assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END")
|
assertCellBoundedFrame(t, model.View(), 80, 24, "EVIDENCE-END")
|
||||||
|
|
||||||
model = updateKey(model, "m")
|
model = updateKey(model, "m")
|
||||||
for range 60 {
|
for range 60 {
|
||||||
@@ -318,7 +318,7 @@ func TestCompleteModelLongFieldsAndDialogsRemainReachableAcrossResizeAtNontermin
|
|||||||
|
|
||||||
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
|
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
|
||||||
snapshot := hardeningSnapshot()
|
snapshot := hardeningSnapshot()
|
||||||
snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END"
|
snapshot.Targets[1].Evidence = strings.Repeat("APT scrollable evidence. ", 90) + " APT-VIEWPORT-END"
|
||||||
updates := make(chan app.OperationUpdate, 2)
|
updates := make(chan app.OperationUpdate, 2)
|
||||||
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
|
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
|
||||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
@@ -354,8 +354,8 @@ func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightA
|
|||||||
|
|
||||||
model = updateKey(model, "q")
|
model = updateKey(model, "q")
|
||||||
assertCellBoundedFrame(t, model.View(), 80, 24,
|
assertCellBoundedFrame(t, model.View(), 80, 24,
|
||||||
"> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END",
|
"> ! Drifted APT", "▶ Target details [focused]", "VIEWPORT-END",
|
||||||
"Applying", "Exit requested", "new work disabled")
|
"Applying", "Exit requested", "changes disabled")
|
||||||
|
|
||||||
updates <- app.SucceededOperationUpdate(snapshot,
|
updates <- app.SucceededOperationUpdate(snapshot,
|
||||||
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
|
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
|
|||||||
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches", PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}}}}, Notice: "Shell Target is now Enabled"}
|
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches", PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}}}}, Notice: "Shell Target is now Enabled"}
|
||||||
model := NewModel(module)
|
model := NewModel(module)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Action: Apply Profile") {
|
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Space: Apply Profile") {
|
||||||
t.Fatalf("Target panel:\n%s", view)
|
t.Fatalf("Target panel:\n%s", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
|
|||||||
}}}
|
}}}
|
||||||
model := NewModel(module)
|
model := NewModel(module)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if strings.Contains(view, "APT") || !strings.Contains(view, "Action: Enable") || !strings.Contains(view, "integration installed") {
|
if strings.Contains(view, "APT") || !strings.Contains(view, "Unavailable:") || !strings.Contains(view, "integration installed") {
|
||||||
t.Fatalf("main Target panel:\n%s", view)
|
t.Fatalf("main Target panel:\n%s", view)
|
||||||
}
|
}
|
||||||
model = updateKey(model, "?")
|
model = updateKey(model, "?")
|
||||||
@@ -123,7 +123,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
|
|||||||
t.Fatalf("scrollable Target reference:\n%s", view)
|
t.Fatalf("scrollable Target reference:\n%s", view)
|
||||||
}
|
}
|
||||||
model = updateKey(model, "esc")
|
model = updateKey(model, "esc")
|
||||||
if !strings.Contains(model.View(), "Operations Console") {
|
if !strings.Contains(model.View(), "Profile:") {
|
||||||
t.Fatalf("Escape did not return from help:\n%s", model.View())
|
t.Fatalf("Escape did not return from help:\n%s", model.View())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,12 +279,12 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) {
|
|||||||
for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
|
for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
|
||||||
model := updateKey(NewModel(module), "?")
|
model := updateKey(NewModel(module), "?")
|
||||||
updated, command := model.Update(key)
|
updated, command := model.Update(key)
|
||||||
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
if command != nil || !strings.Contains(updated.(Model).View(), "Profile:") {
|
||||||
t.Fatalf("help key %q did not return to Operations Console", key.String())
|
t.Fatalf("help key %q did not return to dashboard", key.String())
|
||||||
}
|
}
|
||||||
model = updateKey(NewModel(module), "m")
|
model = updateKey(NewModel(module), "m")
|
||||||
updated, command = model.Update(key)
|
updated, command = model.Update(key)
|
||||||
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
if command != nil || !strings.Contains(updated.(Model).View(), "Profile:") {
|
||||||
t.Fatalf("Profile key %q did not return to Target panel", key.String())
|
t.Fatalf("Profile key %q did not return to Target panel", key.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
|
|||||||
t.Fatal("Profile transition did not start progressive operation")
|
t.Fatal("Profile transition did not start progressive operation")
|
||||||
}
|
}
|
||||||
model, command = applyOperationCommand(t, model, command)
|
model, command = applyOperationCommand(t, model, command)
|
||||||
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") {
|
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Profile: new") {
|
||||||
t.Fatalf("applying progress:\n%s", model.View())
|
t.Fatalf("applying progress:\n%s", model.View())
|
||||||
}
|
}
|
||||||
model, command = applyOperationCommand(t, model, command)
|
model, command = applyOperationCommand(t, model, command)
|
||||||
|
|||||||
Reference in New Issue
Block a user