Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const (
// AutoExtensionResolution defines the environment variable that enables using extensions natively
AutoExtensionResolution = "K6_AUTO_EXTENSION_RESOLUTION"

// DependenciesManifest defines the default values for dependency resolution
DependenciesManifest = "K6_DEPENDENCIES_MANIFEST"

// communityExtensionsCatalog defines the catalog for community extensions
communityExtensionsCatalog = "oss"

Expand Down Expand Up @@ -185,6 +188,7 @@ type GlobalFlags struct {
BuildServiceURL string
BinaryCache string
EnableCommunityExtensions bool
DependenciesManifest string
}

// GetDefaultFlags returns the default global flags.
Expand Down Expand Up @@ -250,6 +254,9 @@ func getFlags(defaultFlags GlobalFlags, env map[string]string, args []string) Gl
result.EnableCommunityExtensions = vb
}
}
if val, ok := env["K6_DEPENDENCIES_MANIFEST"]; ok {
result.DependenciesManifest = val
}

// adjust BuildServiceURL if community extensions are enable
// community extensions flag only takes effect if the default build service is used
Expand Down
77 changes: 77 additions & 0 deletions internal/cmd/deps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

import (
"bytes"
"encoding/json"
"errors"
"slices"

"github.com/spf13/cobra"

"go.k6.io/k6/cmd/state"
"go.k6.io/k6/ext"
"go.k6.io/k6/internal/build"
)

func getCmdDeps(gs *state.GlobalState) *cobra.Command {
depsCmd := &cobra.Command{
Use: "deps",
Short: "Resolve dependencies of a test",
Long: `Resolve dependencies of a test including automatic extenstion resolution.` +
`And outputs all dependencies for the test and whether custom build is required.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
test, err := loadLocalTestWithoutRunner(gs, cmd, args)
if err != nil {
var unsatisfiedErr binaryIsNotSatisfyingDependenciesError
if !errors.As(err, &unsatisfiedErr) {
return err
}
}

deps := test.Dependencies()
depsMap := map[string]string{}
for name, constraint := range deps {
if constraint == nil {
depsMap[name] = "*"
continue
}
depsMap[name] = constraint.String()
}

depsMap, err = mergeManifest(depsMap, gs.Flags.DependenciesManifest)
if err != nil {
return err
}

imports := test.Imports()
slices.Sort(imports)

result := struct {
Dependencies map[string]string `json:"dependencies"`
Imports []string `json:"imports"`
CustomBuildRequired bool `json:"customBuildRequired"`
}{
Dependencies: depsMap,
Imports: imports,
CustomBuildRequired: isCustomBuildRequired(deps, build.Version, ext.GetAll()),
}

buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
return err
}

printToStdout(gs, buf.String())
return nil
},
}

depsCmd.Flags().SortFlags = false
depsCmd.Flags().AddFlagSet(runtimeOptionFlagSet(false))

return depsCmd
}
140 changes: 140 additions & 0 deletions internal/cmd/deps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package cmd

import (
"encoding/json"
"slices"
"strings"
"testing"

"github.com/stretchr/testify/require"

"go.k6.io/k6/internal/cmd/tests"
"go.k6.io/k6/internal/lib/testutils"
)

func TestGetCmdDeps(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
files map[string][]byte
expectedDeps map[string]string
expectCustomBuild bool
expectedImports []string
}{
{
name: "single external dependency",
files: map[string][]byte{
"/main.js": []byte(`import http from "k6/http";
import foo from "k6/x/foo";

export default function () {
http.get("https://example.com");
foo();
}
`),
},
expectedDeps: map[string]string{"k6/x/foo": "*"},
expectCustomBuild: true,
expectedImports: []string{"/main.js", "k6/http", "k6/x/foo"},
},
{
name: "no external dependency",
files: map[string][]byte{
"/main.js": []byte(`import http from "k6/http";

export default function () {
http.get("https://example.com");
}
`),
},
expectedDeps: map[string]string{},
expectCustomBuild: false,
expectedImports: []string{"/main.js", "k6/http"},
},
{
name: "nested local imports",
files: map[string][]byte{
"/main.js": []byte(`import helper from "./lib/helper.js";

export default function () {
helper();
}
`),
"/lib/helper.js": []byte(`import nested from "../shared/nested.js";
import ext from "k6/x/bar";

export default function () {
nested();
ext();
}
`),
"/shared/nested.js": []byte(`export default function () {
return "nested";
}
`),
},
expectedDeps: map[string]string{"k6/x/bar": "*"},
expectCustomBuild: true,
expectedImports: []string{"/lib/helper.js", "/main.js", "/shared/nested.js", "k6/x/bar"},
},
{
name: "use directive across files",
files: map[string][]byte{
"/main.js": []byte(`import directive from "./modules/with-directive.js";

export default function () {
directive();
}
`),
"/modules/with-directive.js": []byte(`"use k6 with k6/x/alpha >= 1.2.3";
import beta from "k6/x/beta";
import util from "./util.js";

export default function () {
util();
beta();
}
`),
"/modules/util.js": []byte(`export default function () {
return "util";
}
`),
},
expectedDeps: map[string]string{"k6/x/alpha": ">=1.2.3", "k6/x/beta": "*"},
expectCustomBuild: true,
expectedImports: []string{"/main.js", "/modules/util.js", "/modules/with-directive.js", "k6/x/beta"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ts := tests.NewGlobalTestState(t)
ts.FS = testutils.MakeMemMapFs(t, tc.files)

cmd := getCmdDeps(ts.GlobalState)
cmd.SetArgs([]string{"/main.js"})
require.NoError(t, cmd.Execute())

var output struct {
Dependencies map[string]string `json:"dependencies"`
Imports []string `json:"imports"`
CustomBuildRequired bool `json:"customBuildRequired"`
}
require.NoError(t, json.Unmarshal(ts.Stdout.Bytes(), &output))

require.Equal(t, tc.expectedDeps, output.Dependencies)

require.Equal(t, tc.expectCustomBuild, output.CustomBuildRequired)

expectedImports := slices.Clone(tc.expectedImports)
for i, expectedImport := range tc.expectedImports {
if !strings.HasPrefix(expectedImport, "k6") {
expectedImports[i] = "file://" + expectedImport
}
}

require.EqualValues(t, expectedImports, output.Imports)
})
}
}
30 changes: 30 additions & 0 deletions internal/cmd/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -299,3 +300,32 @@ func findDirectives(text []byte) []string {
}
return result
}

func mergeManifest(deps map[string]string, manifestString string) (map[string]string, error) {
if manifestString == "" {
return deps, nil
}

manifest := make(map[string]string)
if err := json.Unmarshal([]byte(manifestString), &manifest); err != nil {
return nil, fmt.Errorf("invalid dependencies manifest %w", err)
}

result := make(map[string]string)
for dep, constraint := range deps {
result[dep] = constraint

// if deps has a non default constrain, keep ip
if constraint != "" && constraint != "*" {
continue
}

// check if there's an override in the manifest
manifestConstrain := manifest[dep]
if manifestConstrain != "" && manifestConstrain != "*" {
result[dep] = manifestConstrain
}
}

return result, nil
}
74 changes: 74 additions & 0 deletions internal/cmd/launcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,77 @@ const l = 5
})
}
}

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

tests := []struct {
name string
deps map[string]string
manifest string
expected map[string]string
expectedError string
}{
{
name: "default constrain with no manifest",
deps: map[string]string{"k6/x/dep": "*"},
manifest: "",
expected: map[string]string{"k6/x/dep": "*"},
},
{
name: "empty constrain with no manifest",
deps: map[string]string{"k6/x/dep": ""},
manifest: "",
expected: map[string]string{"k6/x/dep": ""},
},
{
name: "default constrain with empty manifest",
deps: map[string]string{"k6/x/dep": "*"},
manifest: "{}",
expected: map[string]string{"k6/x/dep": "*"},
},
{
name: "default constrain with manifest overrides",
deps: map[string]string{"k6/x/dep": "*"},
manifest: `{"k6/x/dep": "=v0.0.0"}`,
expected: map[string]string{"k6/x/dep": "=v0.0.0"},
},
{
name: "dependency with version constraint",
deps: map[string]string{"k6/x/dep": "=v0.0.1"},
manifest: `{"k6/x/dep": "=v0.0.0"}`,
expected: map[string]string{"k6/x/dep": "=v0.0.1"},
},
{
name: "manifest with different dependency",
deps: map[string]string{"k6/x/dep": "*"},
manifest: `{"k6/x/another": "=v0.0.0"}`,
expected: map[string]string{"k6/x/dep": "*"},
},
{
name: "no dependencies",
deps: map[string]string{},
manifest: `{"k6/x/dep": "=v0.0.0"}`,
expected: map[string]string{},
},
{
name: "malformed manifest",
deps: map[string]string{"k6/x/dep": "*"},
manifest: `{"k6/x/dep": }`,
expectedError: "invalid dependencies manifest",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

merged, err := mergeManifest(test.deps, test.manifest)

if len(test.expectedError) > 0 {
require.ErrorContains(t, err, test.expectedError)
} else {
require.EqualValues(t, test.expected, merged)
}
})
}
}
17 changes: 15 additions & 2 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func newRootCommand(gs *state.GlobalState) *rootCommand {
rootCmd.SetIn(gs.Stdin)

subCommands := []func(*state.GlobalState) *cobra.Command{
getCmdArchive, getCmdCloud, getCmdNewScript, getCmdInspect,
getCmdArchive, getCmdCloud, getCmdNewScript, getCmdInspect, getCmdDeps,
getCmdLogin, getCmdPause, getCmdResume, getCmdScale, getCmdRun,
getCmdStats, getCmdStatus, getCmdVersion,
}
Expand Down Expand Up @@ -166,7 +166,20 @@ func handleUnsatisfiedDependencies(err error, c *rootCommand) (exitcodes.ExitCod
" it's required to provision a custom binary.")
provisioner := newK6BuildProvisioner(c.globalState)
var customBinary commandExecutor
customBinary, err = provisioner.provision(constraintsMapToProvisionDependency(deps))

// merge the script dependencies with the dependencies manifest, if specified
manifest := c.globalState.Flags.DependenciesManifest
depsMap, err := mergeManifest(constraintsMapToProvisionDependency(deps), manifest)
if err != nil {
c.globalState.Logger.
WithField("deps", deps).
WithField("manifest", manifest).
WithError(err).
Error("Failed to process the dependecies manifest for automatic dependency resolution")
return 0, err
}

customBinary, err = provisioner.provision(depsMap)
if err != nil {
err = errext.WithExitCodeIfNone(err, exitcodes.ScriptException)
c.globalState.Logger.
Expand Down
Loading
Loading