From 01395e8fad0ed0ac1834550a9c15109767c2f547 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 2 Oct 2025 18:37:59 +0300 Subject: [PATCH 01/16] Thread the running the binary up enough --- internal/cmd/cloud.go | 15 +++-- internal/cmd/launcher.go | 36 +++++++++++- internal/cmd/root.go | 15 ++++- internal/cmd/run.go | 2 +- internal/cmd/test_load.go | 120 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 12 deletions(-) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 235a60fb243..07b64102c5f 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -85,14 +85,6 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, _ []string) error { // //nolint:funlen,gocognit,cyclop func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { - printBanner(c.gs) - - progressBar := pb.New( - pb.WithConstLeft("Init"), - pb.WithConstProgress(0, "Loading test script..."), - ) - printBar(c.gs, progressBar) - test, err := loadAndConfigureLocalTest(c.gs, cmd, args, getPartialConfig) if err != nil { return err @@ -111,6 +103,13 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { // TODO: validate for usage of execution segment // TODO: validate for externally controlled executor (i.e. executors that aren't distributable) // TODO: move those validations to a separate function and reuse validateConfig()? + printBanner(c.gs) + + progressBar := pb.New( + pb.WithConstLeft("Init"), + pb.WithConstProgress(0, "Loading test script..."), + ) + printBar(c.gs, progressBar) modifyAndPrintBar(c.gs, progressBar, pb.WithConstProgress(0, "Building the archive...")) arc := testRunState.Runner.MakeArchive() diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 6c518923888..19b61429267 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -9,6 +9,7 @@ import ( "os/exec" "path" "path/filepath" + "regexp" "slices" "strings" "syscall" @@ -230,7 +231,6 @@ func (b *customBinary) run(gs *state.GlobalState) error { // isCustomBuildRequired checks if there is at least one dependency that are not satisfied by the binary // considering the version of k6 and any built-in extension func isCustomBuildRequired(deps map[string]*semver.Constraints, k6Version string, exts []*ext.Extension) bool { - // return early if there are no dependencies if len(deps) == 0 { return false } @@ -408,3 +408,37 @@ func extractToken(gs *state.GlobalState) (string, error) { return config.Token.String, nil } + +//nolint:gochecknoglobals +var ( + srcName = `(?Pk6|k6/[^/]{2}.*|k6/[^x]/.*|k6/x/[/0-9a-zA-Z_-]+|(@[a-zA-Z0-9-_]+/)?xk6-([a-zA-Z0-9-_]+)((/[a-zA-Z0-9-_]+)*))` //nolint:lll + srcConstraint = `=?v?0\.0\.0\+[0-9A-Za-z-]+|[vxX*|,&\^0-9.+-><=, ~]+` + + reUseK6 = regexp.MustCompile( + `"use +k6(( with ` + srcName + `( *(?P` + srcConstraint + `))?)|(( *(?P` + srcConstraint + `)?)))"`) //nolint:lll + + idxUseName = reUseK6.SubexpIndex("name") + idxUseConstraints = reUseK6.SubexpIndex("constraints") + idxUseK6Constraints = reUseK6.SubexpIndex("k6Constraints") + nameK6 = "k6" +) + +func processUseDirectives(text []byte) (map[string]string, error) { + deps := make(map[string]string) + for _, match := range reUseK6.FindAllSubmatch(text, -1) { + if constraints := string(match[idxUseK6Constraints]); len(constraints) != 0 { + deps[nameK6] = constraints + } + + if extension := string(match[idxUseName]); len(extension) != 0 { + constraints := string(match[idxUseConstraints]) + + if _, ok := deps[extension]; ok { + return deps, fmt.Errorf("already had a use directivce for %q", extension) + } + deps[extension] = constraints + } + } + + return deps, nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 320b14f4fcb..98e1f089eaf 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,7 +48,10 @@ type rootCommand struct { // newRootCommand creates a root command with a default launcher func newRootCommand(gs *state.GlobalState) *rootCommand { - return newRootWithLauncher(gs, newLauncher(gs)) + if gs.Env["OLD_RESOLUTION"] == "true" { + return newRootWithLauncher(gs, newLauncher(gs)) + } + return newRootWithLauncher(gs, nil) } // newRootWithLauncher creates a root command with a launcher. @@ -108,7 +111,7 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error c.globalState.Logger.Debugf("k6 version: v%s", fullVersion()) // If automatic extension resolution is not enabled, continue with the regular k6 execution path - if !c.globalState.Flags.AutoExtensionResolution { + if !c.globalState.Flags.AutoExtensionResolution || c.launcher == nil { c.globalState.Logger.Debug("Automatic extension resolution is disabled.") return nil } @@ -151,6 +154,14 @@ func (c *rootCommand) execute() { if errors.As(err, &ecerr) { exitCode = int(ecerr.ExitCode()) } + var differentBinaryError runDifferentBinaryError + + if errors.As(err, &differentBinaryError) { + err = differentBinaryError.customBinary.run(c.globalState) + if err == nil { + return + } + } errText, fields := errext.Format(err) c.globalState.Logger.WithFields(fields).Error(errText) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 764019d2bc8..acc27f78e6d 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -67,7 +67,6 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) (err error) { logger.WithError(err).Debug("Everything has finished, exiting k6 with an error!") } }() - printBanner(c.gs) globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() @@ -106,6 +105,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) (err error) { if err != nil { return err } + printBanner(c.gs) if test.keyLogger != nil { defer func() { if klErr := test.keyLogger.Close(); klErr != nil { diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 42828d8fc92..46f90968f3f 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -4,12 +4,16 @@ import ( "archive/tar" "bytes" "crypto/x509" + "errors" "fmt" "io" + "net/url" "path/filepath" + "strings" "sync" "syscall" + "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -17,6 +21,8 @@ import ( "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" + "go.k6.io/k6/ext" + "go.k6.io/k6/internal/build" "go.k6.io/k6/internal/js" "go.k6.io/k6/internal/loader" "go.k6.io/k6/js/modules" @@ -142,6 +148,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, lt.source.Data), exitcodes.ScriptException) + err = figureOutAutoExtensionResolution(err, moduleResolver.Imported(), logger, lt.fileSystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -173,6 +180,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, arc.Data), exitcodes.ScriptException) + err = figureOutAutoExtensionResolution(err, moduleResolver.Imported(), logger, arc.Filesystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -191,6 +199,118 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { } } +func figureOutAutoExtensionResolution( + originalError error, imports []string, logger logrus.FieldLogger, + fileSystems map[string]fsext.Fs, source *loader.SourceData, gs *state.GlobalState, +) error { + if !gs.Flags.AutoExtensionResolution { + fmt.Println("no auto ex") + return originalError + } + + deps, err := extractUnknownModules(originalError) + if err != nil { + return err + } + err = analyseUseContraints(imports, logger, fileSystems, deps) + if err != nil { + return err + } + if len(deps) == 0 { + return nil + } + if !isCustomBuildRequired(deps, build.Version, ext.GetAll()) { + logger. + Debug("The current k6 binary already satisfies all the required dependencies," + + " it isn't required to provision a new binary.") + return nil + } + + logger. + WithField("deps", deps). + Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + + " it's required to provision a custom binary.") + provisioner := newK6BuildProvisioner(gs) + customBinary, err := provisioner.provision(constraintsMapToProvisionDependency(deps)) + if err != nil { + logger. + WithError(err). + Error("Failed to provision a k6 binary with required dependencies." + + " Please, make sure to report this issue by opening a bug report.") + return err + } + + if source.URL.Path == "/-" { + gs.Stdin = bytes.NewBuffer(source.Data) + } + + return runDifferentBinaryError{ + customBinary: customBinary, + } +} + +func analyseUseContraints( + imports []string, logger logrus.FieldLogger, fileSystems map[string]fsext.Fs, deps map[string]*semver.Constraints, +) error { + for _, imported := range imports { + if strings.HasPrefix(imported, "k6") { + continue + } + u, err := url.Parse(imported) + if err != nil { + panic(err) + } + // TODO: do not load it like this :shrug: + d, err := loader.Load(logger, fileSystems, u, u.String()) + if err != nil { + panic(err) + } + newdeps, err := processUseDirectives(d.Data) + if err != nil { + panic(err) + } + logger.Debugf("dependencies from %q: %q", imported, newdeps) + for extension, constraintStr := range newdeps { + if _, ok := deps[extension]; ok { + return fmt.Errorf("already had a use directivce for %q", extension) + } + constraint, err := semver.NewConstraint(constraintStr) + if err != nil { + return fmt.Errorf("unparsable constraint %q for %q", constraintStr, extension) + } + deps[extension] = constraint + } + } + return nil +} + +func extractUnknownModules(err error) (map[string]*semver.Constraints, error) { + deps := make(map[string]*semver.Constraints) + if err == nil { + return deps, nil + } + + var u modules.UnknownModulesError + + if errors.As(err, &u) { + for _, name := range u.List() { + deps[name] = nil + } + return deps, nil + } + + return nil, err +} + +// TODO(@mstoykov) potentially figure out some less "exceptionl workflow" solution +type runDifferentBinaryError struct { + customBinary commandExecutor +} + +func (r runDifferentBinaryError) Error() string { + return "a different binary error - this should never be printed, please report it" +} + // readSource is a small wrapper around loader.ReadSource returning // result of the load and filesystems map func readSource(gs *state.GlobalState, filename string) (*loader.SourceData, map[string]fsext.Fs, string, error) { From 10552197c7e9e6999f33e1a2a88416f8bdb74056 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Tue, 21 Oct 2025 13:16:04 +0300 Subject: [PATCH 02/16] Add test from k6deps --- internal/cmd/test_load_test.go | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/cmd/test_load_test.go diff --git a/internal/cmd/test_load_test.go b/internal/cmd/test_load_test.go new file mode 100644 index 00000000000..251afe130bb --- /dev/null +++ b/internal/cmd/test_load_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + "go.k6.io/k6/internal/lib/testutils" + "go.k6.io/k6/lib/fsext" +) + +const ( + fakerJs = ` +import { Faker } from "k6/x/faker"; + +const faker = new Faker(11); + +export default function () { + console.log(faker.person.firstName()); +} +` + + scriptJS = ` +"use k6 with k6/x/faker > 0.4.0"; +import faker from "./faker.js"; + +export default () => { + faker(); +}; +` +) + +func TestAnalyseUseConstraints(t *testing.T) { + t.Parallel() + + fs := testutils.MakeMemMapFs(t, map[string][]byte{ + "/script.js": []byte(scriptJS), + "/faker.js": []byte(fakerJs), + }) + deps := make(map[string]*semver.Constraints) + + err := analyseUseContraints([]string{"file:///script.js", "file:///faker.js"}, testutils.NewLogger(t), map[string]fsext.Fs{"file": fs}, deps) + + require.NoError(t, err) + require.Len(t, deps, 1) + require.Equal(t, deps["k6/x/faker"].String(), ">0.4.0") +} From 3d491577dfc6c1645c494f7325a08401fa6a1190 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Tue, 21 Oct 2025 13:23:16 +0300 Subject: [PATCH 03/16] Add tests around paths especially on Windows --- internal/cmd/tests/cmd_run_test.go | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/internal/cmd/tests/cmd_run_test.go b/internal/cmd/tests/cmd_run_test.go index bebc95fd691..9c1a57ab9d1 100644 --- a/internal/cmd/tests/cmd_run_test.go +++ b/internal/cmd/tests/cmd_run_test.go @@ -1060,6 +1060,61 @@ func TestAbortedByUnknownModules(t *testing.T) { assert.Contains(t, stdout, `unknown modules [\"k6/x/anotherone\", \"k6/x/somethinghere\"] were tried to be loaded,`) } +func TestRunFromNotBaseDirectory(t *testing.T) { + t.Parallel() + depScript := ` + export const p = 5; + ` + mainScript := ` + import { p } from "../../../b/dep.js"; + export default function() { + console.log("p = " + p); + }; + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "a/b/c/test.js"), []byte(mainScript), 0o644)) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "b/dep.js"), []byte(depScript), 0o644)) + + ts.Cwd = filepath.Join(ts.Cwd, "./a/") + ts.CmdArgs = []string{"k6", "run", "-v", "--log-output=stdout", "b/c/test.js"} + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + require.Contains(t, stdout, `p = 5`) +} + +func TestRunFromSeparateDriveWindows(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skip("test only for windows") + } + depScript := ` + export const p = 5; + ` + mainScript := ` + import { p } from "../../../b/dep.js"; + export default function() { + console.log("p = " + p); + }; + ` + + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "a/b/c/test.js"), []byte(mainScript), 0o644)) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "b/dep.js"), []byte(depScript), 0o644)) + + ts.Cwd = "f:\\something somewhere\\and another\\" + ts.CmdArgs = []string{"k6", "run", "-v", "--log-output=stdout", "c:\\test\\a\\b\\c\\test.js"} + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + require.Contains(t, stdout, `p = 5`) +} + func runTestWithNoLinger(_ *testing.T, ts *GlobalTestState) { cmd.ExecuteWithGlobalState(ts.GlobalState) } From da53b3c5edfa387c65b7e60898d4dfda5f7265a3 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Wed, 22 Oct 2025 11:57:59 +0300 Subject: [PATCH 04/16] rename --- internal/cmd/test_load.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 46f90968f3f..23c53225c2e 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -148,7 +148,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, lt.source.Data), exitcodes.ScriptException) - err = figureOutAutoExtensionResolution(err, moduleResolver.Imported(), logger, lt.fileSystems, lt.source, gs) + err = tryResolveModulesExtensions(err, moduleResolver.Imported(), logger, lt.fileSystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -180,7 +180,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { err := errext.WithExitCodeIfNone( moduleResolver.LoadMainModule(pwd, specifier, arc.Data), exitcodes.ScriptException) - err = figureOutAutoExtensionResolution(err, moduleResolver.Imported(), logger, arc.Filesystems, lt.source, gs) + err = tryResolveModulesExtensions(err, moduleResolver.Imported(), logger, arc.Filesystems, lt.source, gs) if err != nil { return fmt.Errorf("could not load JS test '%s': %w", testPath, err) } @@ -199,7 +199,7 @@ func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { } } -func figureOutAutoExtensionResolution( +func tryResolveModulesExtensions( originalError error, imports []string, logger logrus.FieldLogger, fileSystems map[string]fsext.Fs, source *loader.SourceData, gs *state.GlobalState, ) error { From 4d5ca1e4af8078fa49a9cb0151709cfb18813851 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov <312246+mstoykov@users.noreply.github.com> Date: Wed, 22 Oct 2025 12:11:30 +0300 Subject: [PATCH 05/16] Update internal/cmd/root.go Co-authored-by: Ivan <2103732+codebien@users.noreply.github.com> --- internal/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 98e1f089eaf..bc8d91de32c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,7 +48,7 @@ type rootCommand struct { // newRootCommand creates a root command with a default launcher func newRootCommand(gs *state.GlobalState) *rootCommand { - if gs.Env["OLD_RESOLUTION"] == "true" { + if gs.Env["K6_OLD_RESOLUTION"] == "true" { return newRootWithLauncher(gs, newLauncher(gs)) } return newRootWithLauncher(gs, nil) From c239938375a5733d2e6551403bf3df4743643db3 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Wed, 22 Oct 2025 12:32:02 +0300 Subject: [PATCH 06/16] panic on Error() that should never be called --- internal/cmd/test_load.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 23c53225c2e..1a714617937 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -308,7 +308,7 @@ type runDifferentBinaryError struct { } func (r runDifferentBinaryError) Error() string { - return "a different binary error - this should never be printed, please report it" + panic("a different binary error - this should never be printed, please report it") } // readSource is a small wrapper around loader.ReadSource returning From 4fb9c142f34863b305d0cd3a7f08d44937ef7fbe Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 23 Oct 2025 16:40:54 +0300 Subject: [PATCH 07/16] move whole binary provisioning in the root command --- internal/cmd/root.go | 24 ++++++++++++++++++++---- internal/cmd/test_load.go | 27 +++++++-------------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bc8d91de32c..05461f05477 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -154,12 +154,28 @@ func (c *rootCommand) execute() { if errors.As(err, &ecerr) { exitCode = int(ecerr.ExitCode()) } - var differentBinaryError runDifferentBinaryError + + var differentBinaryError binaryIsNotSatisfyingDependenciesError if errors.As(err, &differentBinaryError) { - err = differentBinaryError.customBinary.run(c.globalState) - if err == nil { - return + deps := differentBinaryError.deps + c.globalState.Logger. + WithField("deps", deps). + Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + + " it's required to provision a custom binary.") + provisioner := newK6BuildProvisioner(c.globalState) + var customBinary commandExecutor + customBinary, err = provisioner.provision(constraintsMapToProvisionDependency(deps)) + if err != nil { + c.globalState.Logger. + WithError(err). + Error("Failed to provision a k6 binary with required dependencies." + + " Please, make sure to report this issue by opening a bug report.") + } else { + err = customBinary.run(c.globalState) + if err == nil { + return + } } } diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 1a714617937..3d6e794177a 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -226,26 +226,12 @@ func tryResolveModulesExtensions( return nil } - logger. - WithField("deps", deps). - Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + - " it's required to provision a custom binary.") - provisioner := newK6BuildProvisioner(gs) - customBinary, err := provisioner.provision(constraintsMapToProvisionDependency(deps)) - if err != nil { - logger. - WithError(err). - Error("Failed to provision a k6 binary with required dependencies." + - " Please, make sure to report this issue by opening a bug report.") - return err - } - if source.URL.Path == "/-" { gs.Stdin = bytes.NewBuffer(source.Data) } - return runDifferentBinaryError{ - customBinary: customBinary, + return binaryIsNotSatisfyingDependenciesError{ + deps: deps, } } @@ -303,12 +289,13 @@ func extractUnknownModules(err error) (map[string]*semver.Constraints, error) { } // TODO(@mstoykov) potentially figure out some less "exceptionl workflow" solution -type runDifferentBinaryError struct { - customBinary commandExecutor +type binaryIsNotSatisfyingDependenciesError struct { + deps map[string]*semver.Constraints } -func (r runDifferentBinaryError) Error() string { - panic("a different binary error - this should never be printed, please report it") +func (r binaryIsNotSatisfyingDependenciesError) Error() string { + // TODO(@mstoykov) fix this for #5327 before merge + return fmt.Sprintf("binary does not satisfy dependencies %q", r.deps) } // readSource is a small wrapper around loader.ReadSource returning From 98b7fe7ccf6d71a01a1191d45eaeefd403b3f07f Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 23 Oct 2025 18:30:45 +0300 Subject: [PATCH 08/16] Drop fmt.Println --- internal/cmd/test_load.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 3d6e794177a..9900c115e1d 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -204,7 +204,6 @@ func tryResolveModulesExtensions( fileSystems map[string]fsext.Fs, source *loader.SourceData, gs *state.GlobalState, ) error { if !gs.Flags.AutoExtensionResolution { - fmt.Println("no auto ex") return originalError } From 9cb832942c438ede0b06b9c4f5ca202d3efd7edb Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Thu, 23 Oct 2025 19:06:36 +0300 Subject: [PATCH 09/16] Parse use directives just at the start --- internal/cmd/launcher.go | 82 ++++++++++++++++++-------- internal/cmd/launcher_test.go | 106 ++++++++++++++++++++++++++++++++++ internal/cmd/test_load.go | 2 +- 3 files changed, 164 insertions(+), 26 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 19b61429267..38336b7b55f 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -9,7 +9,6 @@ import ( "os/exec" "path" "path/filepath" - "regexp" "slices" "strings" "syscall" @@ -17,6 +16,9 @@ import ( "github.com/Masterminds/semver/v3" "github.com/grafana/k6deps" "github.com/grafana/k6provider" + "github.com/grafana/sobek" + "github.com/grafana/sobek/ast" + "github.com/grafana/sobek/parser" "github.com/spf13/cobra" "go.k6.io/k6/cloudapi" @@ -409,36 +411,66 @@ func extractToken(gs *state.GlobalState) (string, error) { return config.Token.String, nil } -//nolint:gochecknoglobals -var ( - srcName = `(?Pk6|k6/[^/]{2}.*|k6/[^x]/.*|k6/x/[/0-9a-zA-Z_-]+|(@[a-zA-Z0-9-_]+/)?xk6-([a-zA-Z0-9-_]+)((/[a-zA-Z0-9-_]+)*))` //nolint:lll - srcConstraint = `=?v?0\.0\.0\+[0-9A-Za-z-]+|[vxX*|,&\^0-9.+-><=, ~]+` - - reUseK6 = regexp.MustCompile( - `"use +k6(( with ` + srcName + `( *(?P` + srcConstraint + `))?)|(( *(?P` + srcConstraint + `)?)))"`) //nolint:lll - - idxUseName = reUseK6.SubexpIndex("name") - idxUseConstraints = reUseK6.SubexpIndex("constraints") - idxUseK6Constraints = reUseK6.SubexpIndex("k6Constraints") - nameK6 = "k6" -) - -func processUseDirectives(text []byte) (map[string]string, error) { +func processUseDirectives(name string, text []byte) (map[string]string, error) { deps := make(map[string]string) - for _, match := range reUseK6.FindAllSubmatch(text, -1) { - if constraints := string(match[idxUseK6Constraints]); len(constraints) != 0 { - deps[nameK6] = constraints + // TODO In theory this will be super easy to parse even with comments and special cases such as #! at the beginning + // of the file, but using sobek.Parse is likely *a lot* more sure to work correctly. + m, err := sobek.Parse(name, string(text), parser.IsModule, parser.WithDisableSourceMaps) + if err != nil { + return nil, err + } + updateDep := func(dep, constraint string) error { + // TODO: We could actually do constraint comparison here and get the more specific one + oldConstraint, ok := deps[dep] + if !ok || oldConstraint == "" { // either nothing or it didn't have constraint + deps[dep] = constraint + return nil } + if constraint == oldConstraint || constraint == "" { + return nil + } + return fmt.Errorf("already have constraint for %q, when parsing %q in %q", dep, constraint, name) + } - if extension := string(match[idxUseName]); len(extension) != 0 { - constraints := string(match[idxUseConstraints]) - - if _, ok := deps[extension]; ok { - return deps, fmt.Errorf("already had a use directivce for %q", extension) + directives := findDirectives(m.Body) + for _, directive := range directives { + // normalize spaces + directive = strings.ReplaceAll(directive, " ", " ") + if !strings.HasPrefix(directive, "use k6") { + continue + } + directive = strings.TrimSpace(strings.TrimPrefix(directive, "use k6")) + if !strings.HasPrefix(directive, "with k6/x/") { + err := updateDep("k6", directive) + if err != nil { + return nil, err } - deps[extension] = constraints + continue + } + directive = strings.TrimSpace(strings.TrimPrefix(directive, "with ")) + dep, constraint, _ := strings.Cut(directive, " ") + err := updateDep(dep, constraint) + if err != nil { + return nil, err } } return deps, nil } + +// TODO(@mstoykov): in a future RP make this more generic +func findDirectives(list []ast.Statement) []string { + var result []string + for _, st := range list { + if st, ok := st.(*ast.ExpressionStatement); ok { + if e, ok := st.Expression.(*ast.StringLiteral); ok { + result = append(result, e.Value.String()) + } else { + break + } + } else { + break + } + } + return result +} diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index efa8ad27e9c..bfe4b04b26d 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -567,3 +567,109 @@ func TestGetProviderConfig(t *testing.T) { }) } } + +func TestProcessUseDirectives(t *testing.T) { + t.Parallel() + tests := map[string]struct { + input string + expectedOutput map[string]string + expectedError string + }{ + "nothing": { + input: "export default function() {}", + expectedOutput: map[string]string{}, + }, + "nothing really": { + input: `"use k6"`, + expectedOutput: map[string]string{ + "k6": "", + }, + }, + "k6 pinning": { + input: `"use k6 > 1.4.0"`, + expectedOutput: map[string]string{ + "k6": "> 1.4.0", + }, + }, + "a extension": { + input: `"use k6 with k6/x/sql"`, + expectedOutput: map[string]string{ + "k6/x/sql": "", + }, + }, + "an extension with constraint": { + input: `"use k6 with k6/x/sql > 1.4.0"`, + expectedOutput: map[string]string{ + "k6/x/sql": "> 1.4.0", + }, + }, + "complex": { + input: ` + // something here + "use k6 with k6/x/A" + function a (){ + "use k6 with k6/x/B" + let s = JSON.stringify( "use k6 with k6/x/C") + "use k6 with k6/x/D" + + return s + } + + export const b = "use k6 with k6/x/E" + "use k6 with k6/x/F" + + // Here for esbuild and k6 warnings + a() + export default function(){} + `, + expectedOutput: map[string]string{ + "k6/x/A": "", + }, + }, + + "repeat": { + input: ` + "use k6 with k6/x/A" + "use k6 with k6/x/A" + `, + expectedOutput: map[string]string{ + "k6/x/A": "", + }, + }, + "repeat with constraint first": { + input: ` + "use k6 with k6/x/A > 1.4.0" + "use k6 with k6/x/A" + `, + expectedOutput: map[string]string{ + "k6/x/A": "> 1.4.0", + }, + }, + "constraint difference": { + input: ` + "use k6 > 1.4.0" + "use k6 = 1.2.3" + `, + expectedError: `already have constraint for "k6", when parsing "= 1.2.3" in "name.js"`, + }, + "constraint difference for extensions": { + input: ` + "use k6 with k6/x/A > 1.4.0" + "use k6 with k6/x/A = 1.2.3" + `, + expectedError: `already have constraint for "k6/x/A", when parsing "= 1.2.3" in "name.js"`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m, err := processUseDirectives("name.js", []byte(test.input)) + assert.EqualValues(t, test.expectedOutput, m) + if len(test.expectedError) > 0 { + assert.ErrorContains(t, err, test.expectedError) + } + }) + } +} diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 9900c115e1d..66a818e76ee 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -250,7 +250,7 @@ func analyseUseContraints( if err != nil { panic(err) } - newdeps, err := processUseDirectives(d.Data) + newdeps, err := processUseDirectives(imported, d.Data) if err != nil { panic(err) } From 3fb6d10ff9b013c1da01d17ac8ee25e12b59e68f Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 10:56:15 +0300 Subject: [PATCH 10/16] Move away from sobek to find directives --- internal/cmd/launcher.go | 57 +++++++++++++++++++++++------------ internal/cmd/launcher_test.go | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 38336b7b55f..69005e4216f 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -12,13 +12,12 @@ import ( "slices" "strings" "syscall" + "unicode" + "unicode/utf8" "github.com/Masterminds/semver/v3" "github.com/grafana/k6deps" "github.com/grafana/k6provider" - "github.com/grafana/sobek" - "github.com/grafana/sobek/ast" - "github.com/grafana/sobek/parser" "github.com/spf13/cobra" "go.k6.io/k6/cloudapi" @@ -413,12 +412,7 @@ func extractToken(gs *state.GlobalState) (string, error) { func processUseDirectives(name string, text []byte) (map[string]string, error) { deps := make(map[string]string) - // TODO In theory this will be super easy to parse even with comments and special cases such as #! at the beginning - // of the file, but using sobek.Parse is likely *a lot* more sure to work correctly. - m, err := sobek.Parse(name, string(text), parser.IsModule, parser.WithDisableSourceMaps) - if err != nil { - return nil, err - } + updateDep := func(dep, constraint string) error { // TODO: We could actually do constraint comparison here and get the more specific one oldConstraint, ok := deps[dep] @@ -432,7 +426,8 @@ func processUseDirectives(name string, text []byte) (map[string]string, error) { return fmt.Errorf("already have constraint for %q, when parsing %q in %q", dep, constraint, name) } - directives := findDirectives(m.Body) + directives := findDirectives(text) + for _, directive := range directives { // normalize spaces directive = strings.ReplaceAll(directive, " ", " ") @@ -458,18 +453,40 @@ func processUseDirectives(name string, text []byte) (map[string]string, error) { return deps, nil } -// TODO(@mstoykov): in a future RP make this more generic -func findDirectives(list []ast.Statement) []string { +func findDirectives(text []byte) []string { + // parse #! at beginning of file + if bytes.HasPrefix(text, []byte("#!")) { + _, text, _ = bytes.Cut(text, []byte("\n")) + } + var result []string - for _, st := range list { - if st, ok := st.(*ast.ExpressionStatement); ok { - if e, ok := st.Expression.(*ast.StringLiteral); ok { - result = append(result, e.Value.String()) - } else { - break + + for i := 0; i < len(text); { + r, width := utf8.DecodeRune(text[i:]) + switch { + case unicode.IsSpace(r) || r == rune(';'): // skip all spaces and ; + i += width + case r == '"' || r == '\'': // string literals + idx := bytes.IndexRune(text[i+width:], r) + if idx < 0 { + return result + } + result = append(result, string(text[i+width:i+width+idx])) + i += width + idx + 1 + case bytes.HasPrefix(text[i:], []byte("//")): + idx := bytes.IndexRune(text[i+width:], '\n') + if idx < 0 { + return result + } + i += width + idx + 1 + case bytes.HasPrefix(text[i:], []byte("/*")): + idx := bytes.Index(text[i+width:], []byte("*/")) + if idx < 0 { + return result } - } else { - break + i += width + idx + 2 + default: + return result } } return result diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index bfe4b04b26d..8a23c77fc9f 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -673,3 +673,57 @@ func TestProcessUseDirectives(t *testing.T) { }) } } + +func TestFindDirectives(t *testing.T) { + t.Parallel() + tests := map[string]struct { + input string + expectedOutput []string + }{ + "nothing": { + input: "export default function() {}", + expectedOutput: nil, + }, + "nothing really": { + input: `"use k6"`, + expectedOutput: []string{"use k6"}, + }, + "multiline": { + input: ` + "use k6 with k6/x/sql" + "something" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + "multiline start at beginning": { + input: ` +"use k6 with k6/x/sql" +"something" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + "multiline comments": { + input: `#!/bin/sh + // here comment "hello" +"use k6 with k6/x/sql"; + /* + "something else here as well" + */ + ; +"something"; +const l = 5 +"more" + `, + expectedOutput: []string{"use k6 with k6/x/sql", "something"}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m := findDirectives([]byte(test.input)) + assert.EqualValues(t, test.expectedOutput, m) + }) + } +} From a01ddac38f5ad5c3a97013ca4a42ae8e3ae20580 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 11:32:13 +0300 Subject: [PATCH 11/16] Add dependancy type and fix errors and logs around loading files --- internal/cmd/launcher.go | 25 ++++--------- internal/cmd/launcher_test.go | 16 ++++++--- internal/cmd/test_load.go | 65 +++++++++++++++++++++++++--------- internal/cmd/test_load_test.go | 2 +- 4 files changed, 67 insertions(+), 41 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 69005e4216f..f1cf182e822 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -410,21 +410,8 @@ func extractToken(gs *state.GlobalState) (string, error) { return config.Token.String, nil } -func processUseDirectives(name string, text []byte) (map[string]string, error) { - deps := make(map[string]string) - - updateDep := func(dep, constraint string) error { - // TODO: We could actually do constraint comparison here and get the more specific one - oldConstraint, ok := deps[dep] - if !ok || oldConstraint == "" { // either nothing or it didn't have constraint - deps[dep] = constraint - return nil - } - if constraint == oldConstraint || constraint == "" { - return nil - } - return fmt.Errorf("already have constraint for %q, when parsing %q in %q", dep, constraint, name) - } +func processUseDirectives(name string, text []byte) (dependencies, error) { + deps := make(dependencies) directives := findDirectives(text) @@ -436,17 +423,17 @@ func processUseDirectives(name string, text []byte) (map[string]string, error) { } directive = strings.TrimSpace(strings.TrimPrefix(directive, "use k6")) if !strings.HasPrefix(directive, "with k6/x/") { - err := updateDep("k6", directive) + err := deps.update("k6", directive) if err != nil { - return nil, err + return nil, fmt.Errorf("error while parsing use directives in %q: %w", name, err) } continue } directive = strings.TrimSpace(strings.TrimPrefix(directive, "with ")) dep, constraint, _ := strings.Cut(directive, " ") - err := updateDep(dep, constraint) + err := deps.update(dep, constraint) if err != nil { - return nil, err + return nil, fmt.Errorf("error while parsing use directives in %q: %w", name, err) } } diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index 8a23c77fc9f..d8b3e909d2a 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -576,8 +576,7 @@ func TestProcessUseDirectives(t *testing.T) { expectedError string }{ "nothing": { - input: "export default function() {}", - expectedOutput: map[string]string{}, + input: "export default function() {}", }, "nothing really": { input: `"use k6"`, @@ -650,23 +649,30 @@ func TestProcessUseDirectives(t *testing.T) { "use k6 > 1.4.0" "use k6 = 1.2.3" `, - expectedError: `already have constraint for "k6", when parsing "= 1.2.3" in "name.js"`, + expectedError: `error while parsing use directives in "name.js": already have constraint for "k6", when parsing "=1.2.3"`, }, "constraint difference for extensions": { input: ` "use k6 with k6/x/A > 1.4.0" "use k6 with k6/x/A = 1.2.3" `, - expectedError: `already have constraint for "k6/x/A", when parsing "= 1.2.3" in "name.js"`, + expectedError: `error while parsing use directives in "name.js": already have constraint for "k6/x/A", when parsing "=1.2.3"`, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { t.Parallel() + deps := make(dependencies) + for k, v := range test.expectedOutput { + require.NoError(t, deps.update(k, v)) + } + if len(test.expectedError) > 0 { + deps = nil + } m, err := processUseDirectives("name.js", []byte(test.input)) - assert.EqualValues(t, test.expectedOutput, m) + assert.EqualValues(t, deps, m) if len(test.expectedError) > 0 { assert.ErrorContains(t, err, test.expectedError) } diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 66a818e76ee..282475a34a8 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "io" + "maps" "net/url" "path/filepath" + "slices" "strings" "sync" "syscall" @@ -211,7 +213,7 @@ func tryResolveModulesExtensions( if err != nil { return err } - err = analyseUseContraints(imports, logger, fileSystems, deps) + err = analyseUseContraints(imports, fileSystems, deps) if err != nil { return err } @@ -234,9 +236,7 @@ func tryResolveModulesExtensions( } } -func analyseUseContraints( - imports []string, logger logrus.FieldLogger, fileSystems map[string]fsext.Fs, deps map[string]*semver.Constraints, -) error { +func analyseUseContraints(imports []string, fileSystems map[string]fsext.Fs, deps dependencies) error { for _, imported := range imports { if strings.HasPrefix(imported, "k6") { continue @@ -245,30 +245,63 @@ func analyseUseContraints( if err != nil { panic(err) } - // TODO: do not load it like this :shrug: - d, err := loader.Load(logger, fileSystems, u, u.String()) + // We always have URLs here with scheme and everything + _, path, _ := strings.Cut(imported, "://") + data, err := fsext.ReadFile(fileSystems[u.Scheme], path) if err != nil { panic(err) } - newdeps, err := processUseDirectives(imported, d.Data) + newdeps, err := processUseDirectives(imported, data) if err != nil { panic(err) } - logger.Debugf("dependencies from %q: %q", imported, newdeps) - for extension, constraintStr := range newdeps { - if _, ok := deps[extension]; ok { - return fmt.Errorf("already had a use directivce for %q", extension) - } - constraint, err := semver.NewConstraint(constraintStr) + for dep, constraint := range newdeps { + err := deps.update(dep, constraint.String()) if err != nil { - return fmt.Errorf("unparsable constraint %q for %q", constraintStr, extension) + return fmt.Errorf("error while parsing use directives in %q: %w", imported, err) } - deps[extension] = constraint } } return nil } +type dependencies map[string]*semver.Constraints + +func (d dependencies) update(dep, constraintStr string) error { + var constraint *semver.Constraints + var err error + if len(constraintStr) > 0 { + constraint, err = semver.NewConstraint(constraintStr) + if err != nil { + return fmt.Errorf("unparsable constraint %q for %q", constraintStr, dep) + } + } + // TODO: We could actually do constraint comparison here and get the more specific one + oldConstraint, ok := d[dep] + if !ok || oldConstraint == nil { // either nothing or it didn't have constraint + d[dep] = constraint + return nil + } + if constraint == oldConstraint || constraint == nil { + return nil + } + return fmt.Errorf("already have constraint for %q, when parsing %q", dep, constraint) +} + +func (d dependencies) String() string { + var buf bytes.Buffer + + for idx, depName := range slices.Sorted(maps.Keys(d)) { + if idx > 0 { + _ = buf.WriteByte(';') + } + + buf.WriteString(depName) + buf.WriteString(d[depName].String()) + } + return buf.String() +} + func extractUnknownModules(err error) (map[string]*semver.Constraints, error) { deps := make(map[string]*semver.Constraints) if err == nil { @@ -289,7 +322,7 @@ func extractUnknownModules(err error) (map[string]*semver.Constraints, error) { // TODO(@mstoykov) potentially figure out some less "exceptionl workflow" solution type binaryIsNotSatisfyingDependenciesError struct { - deps map[string]*semver.Constraints + deps dependencies } func (r binaryIsNotSatisfyingDependenciesError) Error() string { diff --git a/internal/cmd/test_load_test.go b/internal/cmd/test_load_test.go index 251afe130bb..41b0c1b3556 100644 --- a/internal/cmd/test_load_test.go +++ b/internal/cmd/test_load_test.go @@ -40,7 +40,7 @@ func TestAnalyseUseConstraints(t *testing.T) { }) deps := make(map[string]*semver.Constraints) - err := analyseUseContraints([]string{"file:///script.js", "file:///faker.js"}, testutils.NewLogger(t), map[string]fsext.Fs{"file": fs}, deps) + err := analyseUseContraints([]string{"file:///script.js", "file:///faker.js"}, map[string]fsext.Fs{"file": fs}, deps) require.NoError(t, err) require.Len(t, deps, 1) From f024dad6f9b08962105d59392c35f15bf2ea1537 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 11:42:13 +0300 Subject: [PATCH 12/16] do not set the launcher to nil --- internal/cmd/launcher_test.go | 2 ++ internal/cmd/root.go | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index d8b3e909d2a..61cce6529cd 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -210,6 +210,7 @@ func TestLauncherLaunch(t *testing.T) { t.Parallel() ts := tests.NewGlobalTestState(t) + ts.Env["K6_OLD_RESOLUTION"] = "true" k6Args := append([]string{"k6"}, tc.k6Cmd) k6Args = append(k6Args, tc.k6Args...) @@ -277,6 +278,7 @@ func TestLauncherViaStdin(t *testing.T) { k6Args := []string{"k6", "archive", "-"} ts := tests.NewGlobalTestState(t) + ts.Env["K6_OLD_RESOLUTION"] = "true" ts.CmdArgs = k6Args // k6deps uses os package to access files. So we need to use it in the global state diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 05461f05477..ed1c5d6433f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,10 +48,7 @@ type rootCommand struct { // newRootCommand creates a root command with a default launcher func newRootCommand(gs *state.GlobalState) *rootCommand { - if gs.Env["K6_OLD_RESOLUTION"] == "true" { - return newRootWithLauncher(gs, newLauncher(gs)) - } - return newRootWithLauncher(gs, nil) + return newRootWithLauncher(gs, newLauncher(gs)) } // newRootWithLauncher creates a root command with a launcher. @@ -109,9 +106,13 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error } c.globalState.Logger.Debugf("k6 version: v%s", fullVersion()) + if c.globalState.Env["K6_OLD_RESOLUTION"] != "true" { + // do not use hte old resolution, let k6 handle it all + return nil + } // If automatic extension resolution is not enabled, continue with the regular k6 execution path - if !c.globalState.Flags.AutoExtensionResolution || c.launcher == nil { + if !c.globalState.Flags.AutoExtensionResolution { c.globalState.Logger.Debug("Automatic extension resolution is disabled.") return nil } From 49d1ae971ba3cee732ec34f771e905fe764c1775 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 11:51:45 +0300 Subject: [PATCH 13/16] Fix some corner case panics --- internal/cmd/launcher.go | 10 ++++------ internal/cmd/launcher_test.go | 8 +++++--- internal/cmd/test_load.go | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index f1cf182e822..90497487a9b 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -410,9 +410,7 @@ func extractToken(gs *state.GlobalState) (string, error) { return config.Token.String, nil } -func processUseDirectives(name string, text []byte) (dependencies, error) { - deps := make(dependencies) - +func processUseDirectives(name string, text []byte, deps dependencies) error { directives := findDirectives(text) for _, directive := range directives { @@ -425,7 +423,7 @@ func processUseDirectives(name string, text []byte) (dependencies, error) { if !strings.HasPrefix(directive, "with k6/x/") { err := deps.update("k6", directive) if err != nil { - return nil, fmt.Errorf("error while parsing use directives in %q: %w", name, err) + return fmt.Errorf("error while parsing use directives in %q: %w", name, err) } continue } @@ -433,11 +431,11 @@ func processUseDirectives(name string, text []byte) (dependencies, error) { dep, constraint, _ := strings.Cut(directive, " ") err := deps.update(dep, constraint) if err != nil { - return nil, fmt.Errorf("error while parsing use directives in %q: %w", name, err) + return fmt.Errorf("error while parsing use directives in %q: %w", name, err) } } - return deps, nil + return nil } func findDirectives(text []byte) []string { diff --git a/internal/cmd/launcher_test.go b/internal/cmd/launcher_test.go index 61cce6529cd..319512bbf80 100644 --- a/internal/cmd/launcher_test.go +++ b/internal/cmd/launcher_test.go @@ -673,10 +673,12 @@ func TestProcessUseDirectives(t *testing.T) { deps = nil } - m, err := processUseDirectives("name.js", []byte(test.input)) - assert.EqualValues(t, deps, m) + m := make(dependencies) + err := processUseDirectives("name.js", []byte(test.input), m) if len(test.expectedError) > 0 { - assert.ErrorContains(t, err, test.expectedError) + require.ErrorContains(t, err, test.expectedError) + } else { + require.EqualValues(t, deps, m) } }) } diff --git a/internal/cmd/test_load.go b/internal/cmd/test_load.go index 282475a34a8..87a8a0cd117 100644 --- a/internal/cmd/test_load.go +++ b/internal/cmd/test_load.go @@ -247,20 +247,17 @@ func analyseUseContraints(imports []string, fileSystems map[string]fsext.Fs, dep } // We always have URLs here with scheme and everything _, path, _ := strings.Cut(imported, "://") + if u.Scheme == "https" { + path = "/" + path + } data, err := fsext.ReadFile(fileSystems[u.Scheme], path) if err != nil { panic(err) } - newdeps, err := processUseDirectives(imported, data) + err = processUseDirectives(imported, data, deps) if err != nil { panic(err) } - for dep, constraint := range newdeps { - err := deps.update(dep, constraint.String()) - if err != nil { - return fmt.Errorf("error while parsing use directives in %q: %w", imported, err) - } - } } return nil } @@ -297,7 +294,10 @@ func (d dependencies) String() string { } buf.WriteString(depName) - buf.WriteString(d[depName].String()) + constraint := d[depName] + if constraint != nil { + buf.WriteString(constraint.String()) + } } return buf.String() } From 9df44ebd83c6472af25e3580a83880942f2422e6 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 13:35:17 +0300 Subject: [PATCH 14/16] Fix #5327 for K6_OLD_RESOLUTION as well --- internal/cmd/launcher.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 90497487a9b..6976ada428e 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -231,7 +231,7 @@ func (b *customBinary) run(gs *state.GlobalState) error { // isCustomBuildRequired checks if there is at least one dependency that are not satisfied by the binary // considering the version of k6 and any built-in extension -func isCustomBuildRequired(deps map[string]*semver.Constraints, k6Version string, exts []*ext.Extension) bool { +func isCustomBuildRequired(deps dependencies, k6Version string, exts []*ext.Extension) bool { if len(deps) == 0 { return false } @@ -329,7 +329,7 @@ func formatDependencies(deps map[string]string) string { // Presently, only the k6 input script or archive (if any) is passed to k6deps for scanning. // TODO: if k6 receives the input from stdin, it is not used for scanning because we don't know // if it is a script or an archive -func analyze(gs *state.GlobalState, args []string) (map[string]*semver.Constraints, error) { +func analyze(gs *state.GlobalState, args []string) (dependencies, error) { dopts := &k6deps.Options{ LookupEnv: func(key string) (string, bool) { v, ok := gs.Env[key]; return v, ok }, Manifest: k6deps.Source{Ignore: true}, @@ -364,7 +364,7 @@ func analyze(gs *state.GlobalState, args []string) (map[string]*semver.Constrain if err != nil { return nil, err } - result := make(map[string]*semver.Constraints, len(deps)) + result := make(dependencies, len(deps)) for n, dep := range deps { result[n] = dep.Constraints } From 25378d39d1329d4a7fc6e596bba53018a05633ab Mon Sep 17 00:00:00 2001 From: Mihail Stoykov <312246+mstoykov@users.noreply.github.com> Date: Fri, 24 Oct 2025 14:00:45 +0300 Subject: [PATCH 15/16] Update internal/cmd/root.go Co-authored-by: Ivan <2103732+codebien@users.noreply.github.com> --- internal/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ed1c5d6433f..f451d54df55 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -107,7 +107,7 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error c.globalState.Logger.Debugf("k6 version: v%s", fullVersion()) if c.globalState.Env["K6_OLD_RESOLUTION"] != "true" { - // do not use hte old resolution, let k6 handle it all + // do not use the old resolution, let k6 handle it all return nil } From ca4a1839bf8a7ae4151432e091106fbe58f94d1c Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Fri, 24 Oct 2025 18:49:07 +0300 Subject: [PATCH 16/16] propagate exit codes from subprocess --- internal/cmd/launcher.go | 10 +++++++ internal/cmd/root.go | 64 ++++++++++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/internal/cmd/launcher.go b/internal/cmd/launcher.go index 6976ada428e..b252e43b436 100644 --- a/internal/cmd/launcher.go +++ b/internal/cmd/launcher.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "errors" "fmt" "io/fs" "maps" @@ -22,6 +23,8 @@ import ( "go.k6.io/k6/cloudapi" "go.k6.io/k6/cmd/state" + "go.k6.io/k6/errext" + "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/ext" "go.k6.io/k6/internal/build" "go.k6.io/k6/lib/fsext" @@ -220,6 +223,10 @@ func (b *customBinary) run(gs *state.GlobalState) error { for { select { case err := <-done: + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return errext.WithExitCodeIfNone(errAlreadyReported, exitcodes.ExitCode(exitError.ExitCode())) //nolint:gosec + } return err case sig := <-sigC: gs.Logger. @@ -229,6 +236,9 @@ func (b *customBinary) run(gs *state.GlobalState) error { } } +// used just to signal we shouldn't print error again +var errAlreadyReported = fmt.Errorf("already reported error") + // isCustomBuildRequired checks if there is at least one dependency that are not satisfied by the binary // considering the version of k6 and any built-in extension func isCustomBuildRequired(deps dependencies, k6Version string, exts []*ext.Extension) bool { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f451d54df55..a11b6f1ac74 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -151,33 +151,20 @@ func (c *rootCommand) execute() { return } + newExitCode, err := handleNotSatisfiedDependancies(err, c) + + if err == nil { + exitCode = int(newExitCode) + return + } + var ecerr errext.HasExitCode if errors.As(err, &ecerr) { exitCode = int(ecerr.ExitCode()) } - var differentBinaryError binaryIsNotSatisfyingDependenciesError - - if errors.As(err, &differentBinaryError) { - deps := differentBinaryError.deps - c.globalState.Logger. - WithField("deps", deps). - Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + - " it's required to provision a custom binary.") - provisioner := newK6BuildProvisioner(c.globalState) - var customBinary commandExecutor - customBinary, err = provisioner.provision(constraintsMapToProvisionDependency(deps)) - if err != nil { - c.globalState.Logger. - WithError(err). - Error("Failed to provision a k6 binary with required dependencies." + - " Please, make sure to report this issue by opening a bug report.") - } else { - err = customBinary.run(c.globalState) - if err == nil { - return - } - } + if errors.Is(err, errAlreadyReported) { + return } errText, fields := errext.Format(err) @@ -187,6 +174,39 @@ func (c *rootCommand) execute() { } } +func handleNotSatisfiedDependancies(err error, c *rootCommand) (exitcodes.ExitCode, error) { + var unsatisfiedDependenciesErr binaryIsNotSatisfyingDependenciesError + + if !errors.As(err, &unsatisfiedDependenciesErr) { + return 0, err + } + deps := unsatisfiedDependenciesErr.deps + c.globalState.Logger. + WithField("deps", deps). + Info("Automatic extension resolution is enabled. The current k6 binary doesn't satisfy all dependencies," + + " it's required to provision a custom binary.") + provisioner := newK6BuildProvisioner(c.globalState) + var customBinary commandExecutor + customBinary, err = provisioner.provision(constraintsMapToProvisionDependency(deps)) + if err != nil { + err = errext.WithExitCodeIfNone(err, exitcodes.ScriptException) + c.globalState.Logger. + WithError(err). + Error("Failed to provision a k6 binary with required dependencies." + + " Please, make sure to report this issue by opening a bug report.") + return 0, err + } + + err = customBinary.run(c.globalState) + // this only happens if we actually ran the binary and it exited afterwads, in which case we propagate the exit code + var ecerr errext.HasExitCode + if errors.As(err, &ecerr) { + return ecerr.ExitCode(), err + } + + return 0, err +} + func (c *rootCommand) stopLoggers() { done := make(chan struct{}) go func() {