Skip to content
9 changes: 9 additions & 0 deletions cmd/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ const (
// Subcommand extensions can read it to know which k6 version launched them.
ProvisionHostVersion = "K6_PROVISION_HOST_VERSION"

// ProvisionCatalogTTL overrides how long the on-disk k6 extension
// registry catalog is considered fresh before `k6 x` refetches it.
ProvisionCatalogTTL = "K6_PROVISION_CATALOG_TTL"

// ProvisionCatalogURL overrides the k6 extension registry catalog endpoint
// fetched by `k6 x`. Tests point this at httptest or an unreachable address
// to keep the command tree construction off the real network.
ProvisionCatalogURL = "K6_PROVISION_CATALOG_URL"

// defaultBuildServiceURL defines the URL to the default (grafana hosted) build service
defaultBuildServiceURL = "https://ingest.k6.io/builder/api/v1"

Expand Down
84 changes: 80 additions & 4 deletions internal/cmd/subcommand.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cmd

import (
"cmp"
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"
Expand All @@ -10,6 +12,11 @@ import (
"go.k6.io/k6/v2/subcommand"
)

// xStubAnnotation marks a cobra subcommand under `x` as a registry-sourced
// stub: it exists only to advertise an extension subcommand in `k6 x` help.
// Running it falls into the same provisioning path as an unregistered name.
const xStubAnnotation = "k6-x-stub"

// getX creates the "x" command that serves as a namespace for extension-provided subcommands.
//
// Provisioning Workflow:
Expand Down Expand Up @@ -47,6 +54,8 @@ func getX(gs *state.GlobalState) *cobra.Command {

This command serves as a parent for subcommands registered by k6 extensions,
allowing them to extend k6's functionality with custom commands.

Run "k6 x explore" to see the full list of official and community-provided subcommands.
`,
// Disable flag parsing to pass all arguments unchanged to the provisioned binary.
// This ensures flags like --help reach the extension subcommand after provisioning.
Expand All @@ -67,11 +76,73 @@ allowing them to extend k6's functionality with custom commands.
},
}

cmd.AddCommand(extensionSubcommands(gs)...)
baked := extensionSubcommands(gs)
cmd.AddCommand(baked...)
cmd.AddCommand(registryStubs(gs, baked)...)

return cmd
}

// registryStubs returns cobra stubs for registry-advertised subcommands not
// already provided by baked-in extensions. The stubs only exist so `k6 x`
// help can advertise the wider catalog; invoking one drops into the regular
// provisioning path. The function is a no-op outside `k6 x` invocations so
// other commands don't pay for the registry I/O.
func registryStubs(gs *state.GlobalState, baked []*cobra.Command) []*cobra.Command {
if !gs.Flags.AutoExtensionResolution {
return nil
}

args := gs.CmdArgs[1:]
first := slices.IndexFunc(args, func(a string) bool {
return a != cobra.ShellCompRequestCmd && a != cobra.ShellCompNoDescRequestCmd
})
if first < 0 || args[first] != "x" {
return nil
}

cachePath := catalogCachePath(gs)
subs, err := readCachedCatalog(gs, cachePath)
if err != nil {
gs.Logger.WithError(err).Debug("k6 extension catalog")
}
if subs == nil && first == 0 { // first == 0: not TAB completion, network allowed
url := cmp.Or(gs.Env[state.ProvisionCatalogURL], defaultCatalogURL())
_, _ = fmt.Fprint(gs.Stderr, "Loading the subcommand list...")
tail := "\n\n"
if gs.Stderr.IsTTY {
// Best-effort VT100 erase so help renders without a leftover trail.
tail = "\r\x1b[2K"
}
fetched, raw, err := fetchCatalog(gs.Ctx, url)
_, _ = fmt.Fprint(gs.Stderr, tail)
if err == nil {
subs = fetched
err = writeCachedCatalog(gs, cachePath, raw)
}
if err != nil {
gs.Logger.WithError(err).Debug("k6 extension catalog")
}
}

var stubs []*cobra.Command
for _, r := range subs {
if slices.ContainsFunc(baked, func(b *cobra.Command) bool { return b.Name() == r.Name }) {
continue
}
stubs = append(stubs, &cobra.Command{
Use: r.Name,
Short: r.Short,
DisableFlagParsing: true,
Annotations: map[string]string{xStubAnnotation: "true"},
RunE: func(c *cobra.Command, _ []string) error {
return buildExtensionDeps(gs, c.Name())
},
})
}
return stubs
}

// buildExtensionDeps returns a [binaryIsNotSatisfyingDependenciesError] for
// the given extension name if the required dependencies are not satisfied.
// It's used by both [getX] and extension completions check in root.go to
Expand Down Expand Up @@ -128,11 +199,16 @@ func detectExtensionCompletion(root *cobra.Command, gs *state.GlobalState) (stri
}

cmd, remaining, err := root.Find(args[1:])
if err != nil || cmd.Name() != "x" || len(remaining) < 2 {
if err != nil {
return "", false
}

return remaining[0], true
switch {
case cmd.Annotations[xStubAnnotation] == "true" && len(remaining) >= 1:
return cmd.Name(), true
case cmd.Name() == "x" && len(remaining) >= 2:
return remaining[0], true
}
return "", false
}

// dependenciesFromSubcommand constructs a dependencies object for the given subcommand,
Expand Down
143 changes: 143 additions & 0 deletions internal/cmd/subcommand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,41 @@ package cmd

import (
"maps"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"sync"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"go.k6.io/k6/v2/cmd/state"
"go.k6.io/k6/v2/internal/cmd/tests"
"go.k6.io/k6/v2/lib/fsext"
"go.k6.io/k6/v2/subcommand"
)

const testCatalogJSON = `{
"example.com/x-alpha": {"subcommands":["alpha"],"description":"alpha sub","tier":"official"},
"example.com/x-bravo": {"subcommands":["bravo"],"description":"bravo sub","tier":"official"},
"example.com/x-charlie": {"subcommands":["charlie"],"description":"charlie sub","tier":"community"}
}`

func newCatalogServer(t *testing.T, body ...string) *httptest.Server {
t.Helper()
catalog := testCatalogJSON
if len(body) > 0 {
catalog = body[0]
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(catalog))
}))
t.Cleanup(s.Close)
return s
}

func TestExtensionSubcommands(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -94,6 +119,124 @@ func TestXCommandHelpDisplayCommands(t *testing.T) {
}
}

func TestXCommandRegistryStubs(t *testing.T) {
t.Parallel()

registerTestSubcommandExtensions(t)
server := newCatalogServer(t)

tt := []struct {
name string
catalog string
autoOff bool
wantStubs bool
}{
{name: "reachable catalog shows stubs", catalog: server.URL, wantStubs: true},
{name: "unreachable catalog hides stubs", catalog: "http://127.0.0.1:1/unreachable"},
{name: "auto-extension-resolution off hides stubs", catalog: server.URL, autoOff: true},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ts := tests.NewGlobalTestState(t)
ts.Env[state.ProvisionCatalogURL] = tc.catalog
if tc.autoOff {
ts.Env[state.AutoExtensionResolution] = "false"
}
ts.CmdArgs = []string{"k6", "x"}
ts.ReparseFlags()
newRootCommand(ts.GlobalState).execute()

out := ts.Stdout.String()
require.Contains(t, out, "Available Commands:")
require.Regexp(t, `(?m)^ test-cmd-1\s+Test command 1$`, out)

for _, name := range []string{"alpha", "bravo", "charlie"} {
pattern := `(?m)^ ` + regexp.QuoteMeta(name) + `\s`
if tc.wantStubs {
require.Regexp(t, pattern+`+\S`, out, "missing stub row %q", name)
} else {
require.NotRegexp(t, pattern, out, "unexpected stub row %q", name)
}
}
})
}
}

func TestXCompletionUsesCacheOnly(t *testing.T) {
t.Parallel()

registerTestSubcommandExtensions(t)

tt := []struct {
name string
withCache bool
want []string
notWant []string
}{
{name: "no stubs without cache", notWant: []string{"alpha", "bravo", "charlie"}},
{name: "stubs from cache", withCache: true, want: []string{"alpha", "bravo", "charlie"}},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ts := tests.NewGlobalTestState(t)
// Any catalog URL is fine: completion must stay offline regardless.
ts.Env[state.ProvisionCatalogURL] = "http://127.0.0.1:1/unreachable"

if tc.withCache {
path := catalogCachePath(ts.GlobalState)
require.NoError(t, ts.FS.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, fsext.WriteFile(ts.FS, path, []byte(testCatalogJSON), 0o600))
}

ts.CmdArgs = []string{"k6", "__complete", "x", ""}
newRootCommand(ts.GlobalState).execute()

out := ts.Stdout.String()
for _, w := range tc.want {
require.Contains(t, out, w)
}
for _, nw := range tc.notWant {
require.NotContains(t, out, nw)
}
})
}
}

func TestXCompletionRoutesStubsToProvisioning(t *testing.T) {
t.Parallel()

registerTestSubcommandExtensions(t)

tt := []struct {
name string
args []string
}{
{"committed stub name", []string{"k6", "__complete", "x", "alpha", ""}},
{"deeper args under stub", []string{"k6", "__complete", "x", "alpha", "deep", ""}},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

ts := tests.NewGlobalTestState(t)
// Pre-write cache so alpha registers as a stub under `k6 x`.
path := catalogCachePath(ts.GlobalState)
require.NoError(t, ts.FS.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, fsext.WriteFile(ts.FS, path, []byte(testCatalogJSON), 0o600))

ts.CmdArgs = tc.args
root := newRootCommand(ts.GlobalState)
ext, ok := detectExtensionCompletion(root.cmd, ts.GlobalState)
require.True(t, ok)
require.Equal(t, "alpha", ext)
})
}
}

func Test_dependenciesFromSubcommand(t *testing.T) {
t.Parallel()

Expand Down
17 changes: 11 additions & 6 deletions internal/cmd/tests/test_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,17 @@ func NewGlobalTestState(tb testing.TB) *GlobalTestState {
defaultFlags := state.GetDefaultFlags(".config", ".cache")

ts.GlobalState = &state.GlobalState{
Ctx: ctx,
FS: fs,
Getwd: func() (string, error) { return ts.Cwd, nil },
BinaryName: "k6",
CmdArgs: []string{},
Env: map[string]string{"K6_NO_USAGE_REPORT": "true"},
Ctx: ctx,
FS: fs,
Getwd: func() (string, error) { return ts.Cwd, nil },
BinaryName: "k6",
CmdArgs: []string{},
Env: map[string]string{
"K6_NO_USAGE_REPORT": "true",
// Keep `k6 x` command-tree construction off the real registry.k6.io
// when tests don't explicitly point it at an httptest server.
state.ProvisionCatalogURL: "http://127.0.0.1:1/unreachable",
},
Events: event.NewEventSystem(100, logger),
DefaultFlags: defaultFlags,
Flags: defaultFlags,
Expand Down
Loading
Loading