Files
zip/cli_test.go
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

621 lines
22 KiB
Go

package zip_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// ---------------------------------------------------------------------------
// The types one service registers. Nothing here is CLI-specific: these are the
// ordinary In/Out types of ordinary typed handlers, and everything the command
// line shows is read back off them.
// ---------------------------------------------------------------------------
type listAppsIn struct {
Env string `json:"env"`
Health string `json:"health"`
Drift bool `json:"drift"`
Limit int `json:"limit"`
}
type listAppsOut struct {
Apps []string `json:"apps"`
}
type deployIn struct {
App string `json:"app"`
Env string `json:"env" validate:"required"`
Wait bool `json:"wait"`
}
type deployOut struct {
App string `json:"app"`
Env string `json:"env"`
Restarted bool `json:"restarted"`
}
// TestCLI_NameComesFromTheRoute pins the derivation of `<service> <operation>`
// from an op's identity. The name is a function of that one op, so adding an
// unrelated route can never rename an existing command.
func TestCLI_NameComesFromTheRoute(t *testing.T) {
cases := []struct {
method, path string
service, op string
}{
{"GET", "/v1/paas/apps", "paas", "apps-list"},
{"GET", "/v1/paas/apps/:app", "paas", "apps-get"},
{"POST", "/v1/paas/apps/:app/deploy", "paas", "apps-deploy"},
{"GET", "/v1/paas/apps/:app/deploy", "paas", "apps-deploy-get"},
{"POST", "/v1/billing/charge", "billing", "charge-create"},
{"GET", "/v1/billing/invoices", "billing", "invoices-list"},
{"DELETE", "/v1/keys/:id", "keys", "delete"},
{"POST", "/v1/keys", "keys", "create"},
{"PATCH", "/v2/crm/contacts/:id", "crm", "contacts-update"},
{"GET", "/v1/gpu/nodes/:node/jobs", "gpu", "nodes-jobs-list"},
}
for _, c := range cases {
app := zip.New(zip.Config{DisableStartupMessage: true})
register(app, c.method, c.path)
cmds := app.Commands()
if len(cmds) != 1 {
t.Fatalf("%s %s: got %d commands, want 1", c.method, c.path, len(cmds))
}
if cmds[0].Service != c.service || cmds[0].Name != c.op {
t.Errorf("%s %s → %q %q, want %q %q",
c.method, c.path, cmds[0].Service, cmds[0].Name, c.service, c.op)
}
}
}
// register adds one typed route by method, so the name table above can be a
// table rather than ten near-identical apps.
func register(app *zip.App, method, path string) {
fn := func(_ context.Context, _ *listAppsIn) (*listAppsOut, error) { return &listAppsOut{}, nil }
switch method {
case "GET":
app.Get(path, fn)
case "POST":
app.Post(path, fn)
case "PUT":
app.Put(path, fn)
case "PATCH":
app.Patch(path, fn)
case "DELETE":
app.Delete(path, fn)
}
}
// TestCLI_FlagsAndHelpComeFromTheSource proves the whole command — arguments,
// flags, types, required-ness, prose and example — is read off the handler's
// types and its doc comment. None of it is written for the CLI.
func TestCLI_FlagsAndHelpComeFromTheSource(t *testing.T) {
app := deployApp(t)
var cmd zip.Command
for _, c := range app.Commands() {
if c.Service == "paas" && c.Name == "apps-deploy" {
cmd = c
}
}
if cmd.Name == "" {
t.Fatal("apps-deploy was not derived")
}
// The path parameter addresses the resource, so it is positional — and it is
// NOT also a flag, because that would be two ways to set one value.
if len(cmd.Args) != 1 || cmd.Args[0].Name != "app" {
t.Fatalf("args = %+v, want one <app>", cmd.Args)
}
if cmd.Args[0].Help != "App to redeploy." {
t.Errorf("arg help = %q, want the field's doc comment", cmd.Args[0].Help)
}
want := map[string]zip.Flag{
"env": {Name: "env", Field: "env", Type: "string", Help: "Lifecycle env: main|test|dev.", Required: true},
"wait": {Name: "wait", Field: "wait", Type: "boolean", Help: "Block until the rollout settles."},
}
if len(cmd.Flags) != len(want) {
t.Fatalf("flags = %+v, want %d", cmd.Flags, len(want))
}
for _, got := range cmd.Flags {
w, ok := want[got.Name]
if !ok {
t.Fatalf("unexpected flag --%s", got.Name)
}
if got != w {
t.Errorf("--%s = %+v, want %+v", got.Name, got, w)
}
}
// The summary is the doc comment's first sentence — Go's own convention
// already makes that a summary, so nobody writes a second one.
if !strings.HasPrefix(cmd.Summary, "Deploy restarts an app") || strings.Contains(cmd.Summary, "zero downtime") {
t.Errorf("summary = %q, want just the first sentence", cmd.Summary)
}
// The help renders the doc comment's own Example body as the command line
// that sends it: one example, two projections.
var out bytes.Buffer
cli := &zip.CLI{Name: "hanzo", Commands: app.Commands(), Out: &out}
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "--help"}); err != nil {
t.Fatalf("help: %v", err)
}
help := out.String()
for _, want := range []string{
"hanzo paas apps-deploy <app>",
"Deploy restarts an app",
"zero downtime", // the full description, not just the summary
"--env string", // flag name and type
"Lifecycle env: main|test|dev.", // the field's doc comment
"(required)",
"--wait boolean",
"POST /v1/paas/apps/:app/deploy",
"hanzo paas apps-deploy acme --env main --wait", // the Example, as a command
} {
if !strings.Contains(help, want) {
t.Errorf("help is missing %q\n---\n%s", want, help)
}
}
}
// deployApp is one service: two typed routes and the extraction cmd/zipdoc
// emits for them.
func deployApp(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{AppName: "hanzo", DisableStartupMessage: true})
zip.Describe("POST /v1/paas/apps/:app/deploy", zip.Doc{
Description: "Deploy restarts an app. The rollout is a rolling restart, so it happens with zero downtime.",
Fields: map[string]string{
"deployIn.app": "App to redeploy.",
"deployIn.env": "Lifecycle env: main|test|dev.",
"deployIn.wait": "Block until the rollout settles.",
},
Example: json.RawMessage(`{"app":"acme","env":"main","wait":true}`),
Response: json.RawMessage(`{"app":"acme","env":"main","restarted":true}`),
})
app.Post("/v1/paas/apps/:app/deploy", func(_ context.Context, in *deployIn) (*deployOut, error) {
return &deployOut{App: in.App, Env: in.Env, Restarted: in.Wait}, nil
})
zip.Describe("GET /v1/paas/apps", zip.Doc{
Description: "ListApps returns the fleet board.",
Fields: map[string]string{"listAppsIn.env": "Filter by env."},
})
app.Get("/v1/paas/apps", func(_ context.Context, in *listAppsIn) (*listAppsOut, error) {
return &listAppsOut{Apps: []string{"acme:" + in.Env}}, nil
})
return app
}
// TestCLI_RunsTheRegisteredHandler proves a command IS the operation: the
// positional argument reaches the In field the path names, the flags reach
// theirs, and the handler's own output is what the CLI prints.
func TestCLI_RunsTheRegisteredHandler(t *testing.T) {
app := deployApp(t)
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main", "--wait"}); err != nil {
t.Fatalf("run: %v", err)
}
var got deployOut
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode %q: %v", out.String(), err)
}
if got.App != "acme" || got.Env != "main" || !got.Restarted {
t.Fatalf("handler saw %+v, want the argument and both flags", got)
}
}
// TestCLI_RefusesWhatTheTypeRefuses proves the CLI enforces exactly what the
// operation declares — no more, and nothing of its own.
func TestCLI_RefusesWhatTheTypeRefuses(t *testing.T) {
app := deployApp(t)
cases := []struct {
args []string
want string
}{
{[]string{"paas", "apps-deploy", "acme"}, "--env is required"},
{[]string{"paas", "apps-deploy", "acme", "--env", "main", "--nope", "x"}, "unknown flag --nope"},
{[]string{"paas", "apps-deploy", "--env", "main"}, "takes 1 argument"},
{[]string{"paas", "apps-list", "--limit", "many"}, "--limit wants an integer"},
{[]string{"paas", "nope"}, `unknown operation "nope"`},
{[]string{"nope"}, `unknown service "nope"`},
}
for _, c := range cases {
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
err := cli.Run(context.Background(), c.args)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("run %v = %v, want an error containing %q", c.args, err, c.want)
}
}
}
// TestCLI_ANewRouteIsANewCommand is the test that decides whether the CLI is
// really derived: register one more typed route and the command exists, with
// its flags and its help, having edited no CLI source at all.
func TestCLI_ANewRouteIsANewCommand(t *testing.T) {
app := deployApp(t)
before := len(app.Commands())
for _, c := range app.Commands() {
if c.Service == "billing" {
t.Fatalf("billing already exists")
}
}
// The one edit: a service adds a typed route.
type refundIn struct {
Invoice string `json:"invoice"`
Cents int `json:"cents" validate:"required"`
Reason string `json:"reason"`
}
type refundOut struct {
Refunded int `json:"refunded"`
}
zip.Describe("POST /v1/billing/invoices/:invoice/refund", zip.Doc{
Description: "Refund returns money for an invoice.",
Fields: map[string]string{"refundIn.cents": "Amount to refund, in cents."},
Example: json.RawMessage(`{"invoice":"inv_1","cents":1200}`),
})
app.Post("/v1/billing/invoices/:invoice/refund", func(_ context.Context, in *refundIn) (*refundOut, error) {
return &refundOut{Refunded: in.Cents}, nil
})
cmds := app.Commands()
if len(cmds) != before+1 {
t.Fatalf("commands went %d → %d, want one more", before, len(cmds))
}
var got zip.Command
for _, c := range cmds {
if c.Service == "billing" && c.Name == "invoices-refund" {
got = c
}
}
if got.Name == "" {
t.Fatalf("the new route produced no `billing invoices-refund`: %v", names(cmds))
}
if len(got.Args) != 1 || got.Args[0].Name != "invoice" {
t.Errorf("args = %+v, want <invoice>", got.Args)
}
if len(got.Flags) != 2 {
t.Fatalf("flags = %+v, want --cents and --reason", got.Flags)
}
for _, f := range got.Flags {
if f.Name == "cents" && (!f.Required || f.Type != "integer" || f.Help != "Amount to refund, in cents.") {
t.Errorf("--cents = %+v, want a required integer with its doc comment", f)
}
}
// And it runs.
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"billing", "invoices-refund", "inv_1", "--cents", "1200"}); err != nil {
t.Fatalf("run the new command: %v", err)
}
if !strings.Contains(out.String(), `"refunded": 1200`) {
t.Fatalf("output = %q, want the handler's result", out.String())
}
}
func names(cmds []zip.Command) []string {
var out []string
for _, c := range cmds {
out = append(out, c.Service+" "+c.Name)
}
return out
}
// TestCLI_OperationIDIsTheOneIdentity relates the command to the op it is a
// projection of. Every other projection addresses an op by its operation id —
// the document's operationId, the MCP tool's name, the token zip.Call takes —
// and the command line carries that same token, so a command is a view of an op
// rather than a second thing with a second name.
//
// WithOperationID is the case that used to break it: renaming an op renamed it
// in the document, the tool list and the call plane, and the CLI carried on
// spelling it from the route. One op, two names, and a script written against
// the CLI addressed something the document had never heard of.
func TestCLI_OperationIDIsTheOneIdentity(t *testing.T) {
app := zip.New(zip.Config{AppName: "billing", DisableStartupMessage: true})
fn := func(_ context.Context, in *deployIn) (*deployOut, error) {
return &deployOut{App: in.App, Env: in.Env}, nil
}
app.Post("/v1/billing/charge", fn, zip.WithOperationID("billing_refund"))
app.Get("/v1/billing/invoices", fn) // no explicit id: the default token
// Every projection, one token per op.
byOp := map[string]zip.Command{}
for _, c := range app.Commands() {
byOp[c.OperationID] = c
}
tools := map[string]bool{}
for _, tool := range app.MCPTools() {
tools[tool["name"].(string)] = true
}
spec := app.OpenAPISpec()
paths, _ := spec["paths"].(map[string]map[string]any)
for path, item := range paths {
for method, raw := range item {
op, _ := raw.(map[string]any)
id, _ := op["operationId"].(string)
if !tools[id] {
t.Errorf("%s %s: operationId %q names no tool; tools = %v", method, path, id, tools)
}
if _, ok := byOp[id]; !ok {
t.Errorf("%s %s: operationId %q names no command; commands = %v", method, path, id, byOp)
}
}
}
// The renamed op is renamed on the command line too — `billing refund`,
// not the `billing charge-create` the route alone would have spelled.
renamed, ok := byOp["billing_refund"]
if !ok {
t.Fatalf("no command carries the explicit id; got %v", byOp)
}
if renamed.Service != "billing" || renamed.Name != "refund" {
t.Errorf("command = %q %q, want %q %q", renamed.Service, renamed.Name, "billing", "refund")
}
// And it is the name that RUNS: the old one is gone, not merely aliased.
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), []string{"billing", "refund", "--env", "prod"}); err != nil {
t.Fatalf("run the renamed command: %v", err)
}
if !strings.Contains(out.String(), `"env": "prod"`) {
t.Fatalf("output = %q, want the handler's result", out.String())
}
if err := cli.Run(context.Background(), []string{"billing", "charge-create"}); err == nil {
t.Error("`billing charge-create` still resolves — the op has one name, not two")
}
// The client tree reads the id off the document and lands on the same command.
raw, err := json.Marshal(spec)
if err != nil {
t.Fatal(err)
}
fromSpec, err := zip.CommandsFromSpec(raw)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
for _, s := range fromSpec {
r, ok := byOp[s.OperationID]
if !ok {
t.Fatalf("spec command %q %q carries id %q, which the registry does not", s.Service, s.Name, s.OperationID)
}
if r.Service != s.Service || r.Name != s.Name {
t.Errorf("id %q: spec says %q %q, registry says %q %q",
s.OperationID, s.Service, s.Name, r.Service, r.Name)
}
}
}
// everyKind has one field of every kind a flag can be spelled from, including
// the compound ones a flag cannot spell flatly.
type everyKind struct {
Str string `json:"str"`
Flag bool `json:"flag"`
Count int `json:"count"`
Big uint64 `json:"big"`
Ratio float64 `json:"ratio"`
Tags []string `json:"tags"`
Meta map[string]int `json:"meta"`
Nested deployIn `json:"nested"`
Ptr *int `json:"ptr"`
}
// TestCLI_FlagKindIsTheSchemaKind relates a flag spelled from a Go type to the
// same flag spelled from that type's published schema. They are one derivation:
// the flag kind is read off schemaOf and mapped by the SAME schema-type →
// flag-kind rule the spec-derived tree uses, rather than from a second list of
// reflect kinds that has to be kept equal to the first by hand.
func TestCLI_FlagKindIsTheSchemaKind(t *testing.T) {
app := zip.New(zip.Config{AppName: "kinds", DisableStartupMessage: true})
app.Post("/v1/kinds/things", func(_ context.Context, in *everyKind) (*everyKind, error) {
return in, nil
})
spec, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatal(err)
}
fromSpec, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
if len(fromSpec) != 1 {
t.Fatalf("spec commands = %v, want 1", names(fromSpec))
}
want := map[string]string{
"str": "string", "flag": "boolean", "count": "integer", "big": "integer",
"ratio": "number", "tags": "json", "meta": "json", "nested": "json", "ptr": "integer",
}
for _, side := range []struct {
from string
flags []zip.Flag
}{
{"the registry", app.Commands()[0].Flags},
{"the document", fromSpec[0].Flags},
} {
if len(side.flags) != len(want) {
t.Fatalf("%s: flags = %+v, want %d", side.from, side.flags, len(want))
}
for _, f := range side.flags {
if f.Type != want[f.Field] {
t.Errorf("%s: --%s is %q, want %q", side.from, f.Name, f.Type, want[f.Field])
}
}
}
}
// purgeIn is a bodyless op's input: a path segment addresses the app, a scalar
// filter can ride the URL beside it, and a compound field can ride neither.
type purgeIn struct {
App string `json:"app"`
Before string `json:"before" validate:"required"`
Tags []string `json:"tags"`
}
// TestCLI_SpecAndRegistryAgree proves the two derivations are one derivation:
// the command tree read from the live registry and the command tree read from
// the document that registry generates are the same commands — every field, for
// every method, with nothing skipped.
//
// It used to `continue` past GET/HEAD/DELETE while the fixture registered no
// DELETE at all, so three methods' worth of divergence was hidden twice over.
// The divergence was real: a bodyless op's flags, their required-ness and its
// example each stopped at the document's edge. All three are closed — hasBody is
// one predicate, a URL-borne field's `validate:"required"` reaches its
// parameter, and an example rides the parameters when there is no body for it to
// live in — so the comparison is now plain equality.
func TestCLI_SpecAndRegistryAgree(t *testing.T) {
app := deployApp(t)
// The DELETE the skip used to name. Its input has one field of each kind
// that matters here, so the bodyless case is actually exercised.
zip.Describe("DELETE /v1/paas/apps/:app", zip.Doc{
Description: "Purge deletes an app and everything it owns.",
// Deliberately not in sorted order: the document rebuilds a bodyless
// op's example out of its parameters, so what round-trips is the value.
Example: json.RawMessage(`{"before":"2026-01-01","app":"acme"}`),
})
app.Delete("/v1/paas/apps/:app", func(_ context.Context, in *purgeIn) (*deployOut, error) {
return &deployOut{App: in.App}, nil
})
spec, err := json.Marshal(app.OpenAPISpec())
if err != nil {
t.Fatalf("marshal spec: %v", err)
}
fromSpec, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
fromRegistry := app.Commands()
if len(fromSpec) != len(fromRegistry) {
t.Fatalf("spec has %v, registry has %v", names(fromSpec), names(fromRegistry))
}
for i := range fromRegistry {
r, s := fromRegistry[i], fromSpec[i]
if r.Service != s.Service || r.Name != s.Name || r.Method != s.Method || r.Path != s.Path {
t.Fatalf("op %d: spec says %q %q (%s %s), registry says %q %q (%s %s)",
i, s.Service, s.Name, s.Method, s.Path, r.Service, r.Name, r.Method, r.Path)
}
if fmt.Sprint(r.Args) != fmt.Sprint(s.Args) {
t.Errorf("%s %s args: spec %v, registry %v", r.Service, r.Name, s.Args, r.Args)
}
if r.Summary != s.Summary {
t.Errorf("%s %s summary: spec %q, registry %q", r.Service, r.Name, s.Summary, r.Summary)
}
if r.OperationID != s.OperationID {
t.Errorf("%s %s id: spec %q, registry %q", r.Service, r.Name, s.OperationID, r.OperationID)
}
if fmt.Sprint(r.Flags) != fmt.Sprint(s.Flags) {
t.Errorf("%s %s flags: spec %v, registry %v", r.Service, r.Name, s.Flags, r.Flags)
}
// The example is a JSON value, not a byte string: the document splits it
// across parameters when there is no body, so it comes back rebuilt
// rather than echoed. What has to survive is what it MEANS.
if !sameJSON(r.Example, s.Example) {
t.Errorf("%s %s example: spec %s, registry %s", r.Service, r.Name, s.Example, r.Example)
}
}
}
func sameJSON(a, b json.RawMessage) bool {
if len(a) == 0 || len(b) == 0 {
return len(a) == len(b)
}
var av, bv any
if json.Unmarshal(a, &av) != nil || json.Unmarshal(b, &bv) != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// TestCLI_RemoteRunsAgainstARunningService is the client case end to end: a
// binary that links none of the service's code asks it what it can do, builds
// the command line from the answer, and runs a command against it.
func TestCLI_RemoteRunsAgainstARunningService(t *testing.T) {
app := deployApp(t)
addr := freeAddr(t)
go func() { _ = app.Listen("http://" + addr) }()
defer func() { _ = app.Shutdown() }()
waitHTTP(t, "http://"+addr+"/.well-known/openapi.json")
// Everything from here knows only an address.
remote := zip.Remote{Base: "http://" + addr, Header: map[string]string{"X-Test": "1"}}
spec, err := remote.Spec(context.Background())
if err != nil {
t.Fatalf("fetch spec: %v", err)
}
cmds, err := zip.CommandsFromSpec(spec)
if err != nil {
t.Fatalf("CommandsFromSpec: %v", err)
}
var out bytes.Buffer
cli := &zip.CLI{Name: "hanzo", Commands: cmds, Invoke: remote.Invoke, Out: &out}
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main", "--wait"}); err != nil {
t.Fatalf("remote run: %v", err)
}
var got deployOut
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("decode %q: %v", out.String(), err)
}
if got.App != "acme" || got.Env != "main" || !got.Restarted {
t.Fatalf("service saw %+v, want the argument and both flags", got)
}
// A required flag is enforced before anything leaves the machine — the
// document carries validate:"required" through as the schema's required.
out.Reset()
if err := cli.Run(context.Background(), []string{"paas", "apps-deploy", "acme"}); err == nil ||
!strings.Contains(err.Error(), "--env is required") {
t.Fatalf("missing --env = %v, want the client to refuse it", err)
}
// A spec-derived command has no handler in this process, and says so rather
// than pretending.
out.Reset()
local := &zip.CLI{Name: "hanzo", Commands: cmds, Invoke: zip.LocalInvoke, Out: &out}
if err := local.Run(context.Background(), []string{"paas", "apps-deploy", "acme", "--env", "main"}); err == nil ||
!strings.Contains(err.Error(), "not registered in this process") {
t.Fatalf("local invoke of a remote command = %v, want a clear refusal", err)
}
}
// TestCLI_UsageListsServices proves the top level is derived too: no list of
// services is written anywhere.
func TestCLI_UsageListsServices(t *testing.T) {
app := deployApp(t)
var out bytes.Buffer
cli := app.CLI()
cli.Out = &out
if err := cli.Run(context.Background(), nil); err != nil {
t.Fatalf("usage: %v", err)
}
if !strings.Contains(out.String(), "paas") || !strings.Contains(out.String(), "2 operations") {
t.Fatalf("usage = %q, want the derived service list", out.String())
}
out.Reset()
if err := cli.Run(context.Background(), []string{"paas"}); err != nil {
t.Fatalf("service usage: %v", err)
}
for _, want := range []string{"apps-deploy <app>", "apps-list", "Deploy restarts an app"} {
if !strings.Contains(out.String(), want) {
t.Errorf("service usage is missing %q\n---\n%s", want, out.String())
}
}
}