From 31e524f62928f10bed0aa72d8402fd1fac118eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Tue, 13 Dec 2022 17:14:39 +0100 Subject: [PATCH 1/5] Delete redundant lib package doc file There's already a lib/doc.go with much more documentation. --- lib/lib.go | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 lib/lib.go diff --git a/lib/lib.go b/lib/lib.go deleted file mode 100644 index f776679c8df..00000000000 --- a/lib/lib.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package lib contains most interfaces and base structs of k6. -package lib From 0f93267be62fce3690ac32ebe99a0921b85092a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Fri, 16 Dec 2022 13:29:01 +0100 Subject: [PATCH 2/5] Move cmd.globalState to new cmd/state package This allows us to use it in tests outside of the cmd package. See https://github.com/grafana/k6/issues/2459 --- cmd/archive.go | 10 +- cmd/archive_test.go | 37 ++-- cmd/cloud.go | 19 +- cmd/common.go | 15 +- cmd/config.go | 25 +-- cmd/config_consolidation_test.go | 26 ++- cmd/convert.go | 11 +- cmd/convert_test.go | 31 ++-- cmd/inspect.go | 7 +- cmd/integration_test.go | 309 ++++++++++++++++--------------- cmd/login.go | 4 +- cmd/login_cloud.go | 27 +-- cmd/login_influxdb.go | 11 +- cmd/outputs.go | 13 +- cmd/panic_integration_test.go | 13 +- cmd/pause.go | 9 +- cmd/resume.go | 9 +- cmd/root.go | 282 ++++++---------------------- cmd/root_test.go | 122 +----------- cmd/run.go | 25 +-- cmd/run_test.go | 39 ++-- cmd/runtime_options.go | 3 +- cmd/runtime_options_test.go | 13 +- cmd/scale.go | 9 +- cmd/state/doc.go | 4 + cmd/state/env.go | 22 +++ cmd/state/state.go | 167 +++++++++++++++++ cmd/state/test_state.go | 155 ++++++++++++++++ cmd/stats.go | 9 +- cmd/status.go | 9 +- cmd/test_load.go | 43 ++--- cmd/ui.go | 112 ++++------- cmd/version.go | 8 +- ui/console/doc.go | 2 + ui/console/writer.go | 41 ++++ 35 files changed, 879 insertions(+), 762 deletions(-) create mode 100644 cmd/state/doc.go create mode 100644 cmd/state/env.go create mode 100644 cmd/state/state.go create mode 100644 cmd/state/test_state.go create mode 100644 ui/console/doc.go create mode 100644 ui/console/writer.go diff --git a/cmd/archive.go b/cmd/archive.go index 3e0aa7a4440..0ffb3dc45dd 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -3,11 +3,13 @@ package cmd import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + + "go.k6.io/k6/cmd/state" ) // cmdArchive handles the `k6 archive` sub-command type cmdArchive struct { - gs *globalState + gs *state.GlobalState archiveOut string excludeEnvVars bool @@ -31,13 +33,13 @@ func (c *cmdArchive) run(cmd *cobra.Command, args []string) error { // Archive. arc := testRunState.Runner.MakeArchive() - f, err := c.gs.fs.Create(c.archiveOut) + f, err := c.gs.FS.Create(c.archiveOut) if err != nil { return err } if c.excludeEnvVars { - c.gs.logger.Debug("environment variables will be excluded from the archive") + c.gs.Logger.Debug("environment variables will be excluded from the archive") arc.Env = nil } @@ -66,7 +68,7 @@ func (c *cmdArchive) flagSet() *pflag.FlagSet { return flags } -func getCmdArchive(gs *globalState) *cobra.Command { +func getCmdArchive(gs *state.GlobalState) *cobra.Command { c := &cmdArchive{ gs: gs, archiveOut: "archive.tar", diff --git a/cmd/archive_test.go b/cmd/archive_test.go index e7a75c6e56c..d0db9562ea8 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" ) @@ -78,17 +79,17 @@ func TestArchiveThresholds(t *testing.T) { testScript, err := ioutil.ReadFile(testCase.testFilename) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, testCase.testFilename), testScript, 0o644)) - testState.args = []string{"k6", "archive", testCase.testFilename} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testCase.testFilename), testScript, 0o644)) + ts.CmdArgs = []string{"k6", "archive", testCase.testFilename} if testCase.noThresholds { - testState.args = append(testState.args, "--no-thresholds") + ts.CmdArgs = append(ts.CmdArgs, "--no-thresholds") } if testCase.wantErr { - testState.expectedExitCode = int(exitcodes.InvalidConfig) + ts.ExpectedExitCode = int(exitcodes.InvalidConfig) } - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() }) } } @@ -99,16 +100,16 @@ func TestArchiveContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, fileName), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags - testState.args = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", fileName} + ts.CmdArgs = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", fileName} - newRootCommand(testState.globalState).execute() - require.NoError(t, untar(t, testState.fs, "archive.tar", "tmp/")) + newRootCommand(ts.GlobalState).execute() + require.NoError(t, untar(t, ts.FS, "archive.tar", "tmp/")) - data, err := afero.ReadFile(testState.fs, "tmp/metadata.json") + data, err := afero.ReadFile(ts.FS, "tmp/metadata.json") require.NoError(t, err) metadata := struct { @@ -132,16 +133,16 @@ func TestArchiveNotContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, fileName), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags altogether with `--exclude-env-vars` flag - testState.args = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", "--exclude-env-vars", fileName} + ts.CmdArgs = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", "--exclude-env-vars", fileName} - newRootCommand(testState.globalState).execute() - require.NoError(t, untar(t, testState.fs, "archive.tar", "tmp/")) + newRootCommand(ts.GlobalState).execute() + require.NoError(t, untar(t, ts.FS, "archive.tar", "tmp/")) - data, err := afero.ReadFile(testState.fs, "tmp/metadata.json") + data, err := afero.ReadFile(ts.FS, "tmp/metadata.json") require.NoError(t, err) metadata := struct { diff --git a/cmd/cloud.go b/cmd/cloud.go index d664b608f91..1b43525c2c3 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/pflag" "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/lib" @@ -26,7 +27,7 @@ import ( // cmdCloud handles the `k6 cloud` sub-command type cmdCloud struct { - gs *globalState + gs *state.GlobalState showCloudLogs bool exitOnRunning bool @@ -38,7 +39,7 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, args []string) error { // We deliberately parse the env variables, to validate for wrong // values, even if we don't subsequently use them (if the respective // CLI flag was specified, since it has a higher priority). - if showCloudLogsEnv, ok := c.gs.envVars["K6_SHOW_CLOUD_LOGS"]; ok { + if showCloudLogsEnv, ok := c.gs.Env["K6_SHOW_CLOUD_LOGS"]; ok { showCloudLogsValue, err := strconv.ParseBool(showCloudLogsEnv) if err != nil { return fmt.Errorf("parsing K6_SHOW_CLOUD_LOGS returned an error: %w", err) @@ -48,7 +49,7 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, args []string) error { } } - if exitOnRunningEnv, ok := c.gs.envVars["K6_EXIT_ON_RUNNING"]; ok { + if exitOnRunningEnv, ok := c.gs.Env["K6_EXIT_ON_RUNNING"]; ok { exitOnRunningValue, err := strconv.ParseBool(exitOnRunningEnv) if err != nil { return fmt.Errorf("parsing K6_EXIT_ON_RUNNING returned an error: %w", err) @@ -112,7 +113,7 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { // Cloud config cloudConfig, err := cloudapi.GetConsolidatedConfig( - test.derivedConfig.Collectors["cloud"], c.gs.envVars, "", arc.Options.External) + test.derivedConfig.Collectors["cloud"], c.gs.Env, "", arc.Options.External) if err != nil { return err } @@ -146,10 +147,10 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { name = filepath.Base(test.sourceRootPath) } - globalCtx, globalCancel := context.WithCancel(c.gs.ctx) + globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() - logger := c.gs.logger + logger := c.gs.Logger // Start cloud test run modifyAndPrintBar(c.gs, progressBar, pb.WithConstProgress(0, "Validating script options")) @@ -281,8 +282,8 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { return errext.WithExitCodeIfNone(errors.New("Test progress error"), exitcodes.CloudFailedToGetProgress) } - if !c.gs.flags.quiet { - valueColor := getColor(c.gs.flags.noColor || !c.gs.stdOut.isTTY, color.FgCyan) + if !c.gs.Flags.Quiet { + valueColor := getColor(c.gs.Flags.NoColor || !c.gs.Stdout.IsTTY, color.FgCyan) printToStdout(c.gs, fmt.Sprintf( " test status: %s\n", valueColor.Sprint(testProgress.RunStatusText), )) @@ -314,7 +315,7 @@ func (c *cmdCloud) flagSet() *pflag.FlagSet { return flags } -func getCmdCloud(gs *globalState) *cobra.Command { +func getCmdCloud(gs *state.GlobalState) *cobra.Command { c := &cmdCloud{ gs: gs, showCloudLogs: true, diff --git a/cmd/common.go b/cmd/common.go index da0c1aaf841..2b43cb7f1cd 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib/types" ) @@ -65,17 +66,17 @@ func exactArgsWithMsg(n int, msg string) cobra.PositionalArgs { } } -func printToStdout(gs *globalState, s string) { - if _, err := fmt.Fprint(gs.stdOut, s); err != nil { - gs.logger.Errorf("could not print '%s' to stdout: %s", s, err.Error()) +func printToStdout(gs *state.GlobalState, s string) { + if _, err := fmt.Fprint(gs.Stdout, s); err != nil { + gs.Logger.Errorf("could not print '%s' to stdout: %s", s, err.Error()) } } // Trap Interrupts, SIGINTs and SIGTERMs and call the given. -func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop func(os.Signal)) (stop func()) { +func handleTestAbortSignals(gs *state.GlobalState, gracefulStopHandler, onHardStop func(os.Signal)) (stop func()) { sigC := make(chan os.Signal, 2) done := make(chan struct{}) - gs.signalNotify(sigC, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + gs.SignalNotify(sigC, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { select { @@ -92,7 +93,7 @@ func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop fun } // If we get a second signal, we immediately exit, so something like // https://github.com/k6io/k6/issues/971 never happens again - gs.osExit(int(exitcodes.ExternalAbort)) + gs.OSExit(int(exitcodes.ExternalAbort)) case <-done: return } @@ -100,6 +101,6 @@ func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop fun return func() { close(done) - gs.signalStop(sigC) + gs.SignalStop(sigC) } } diff --git a/cmd/config.go b/cmd/config.go index c131ef4db04..ea0ac8fd756 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" @@ -104,10 +105,10 @@ func getConfig(flags *pflag.FlagSet) (Config, error) { // an error. The only situation in which an error won't be returned is if the // user didn't explicitly specify a config file path and the default config file // doesn't exist. -func readDiskConfig(globalState *globalState) (Config, error) { +func readDiskConfig(gs *state.GlobalState) (Config, error) { // Try to see if the file exists in the supplied filesystem - if _, err := globalState.fs.Stat(globalState.flags.configFilePath); err != nil { - if os.IsNotExist(err) && globalState.flags.configFilePath == globalState.defaultFlags.configFilePath { + if _, err := gs.FS.Stat(gs.Flags.ConfigFilePath); err != nil { + if os.IsNotExist(err) && gs.Flags.ConfigFilePath == gs.DefaultFlags.ConfigFilePath { // If the file doesn't exist, but it was the default config file (i.e. the user // didn't specify anything), silence the error err = nil @@ -115,31 +116,31 @@ func readDiskConfig(globalState *globalState) (Config, error) { return Config{}, err } - data, err := afero.ReadFile(globalState.fs, globalState.flags.configFilePath) + data, err := afero.ReadFile(gs.FS, gs.Flags.ConfigFilePath) if err != nil { - return Config{}, fmt.Errorf("couldn't load the configuration from %q: %w", globalState.flags.configFilePath, err) + return Config{}, fmt.Errorf("couldn't load the configuration from %q: %w", gs.Flags.ConfigFilePath, err) } var conf Config err = json.Unmarshal(data, &conf) if err != nil { - return Config{}, fmt.Errorf("couldn't parse the configuration from %q: %w", globalState.flags.configFilePath, err) + return Config{}, fmt.Errorf("couldn't parse the configuration from %q: %w", gs.Flags.ConfigFilePath, err) } return conf, nil } // Serializes the configuration to a JSON file and writes it in the supplied // location on the supplied filesystem -func writeDiskConfig(globalState *globalState, conf Config) error { +func writeDiskConfig(gs *state.GlobalState, conf Config) error { data, err := json.MarshalIndent(conf, "", " ") if err != nil { return err } - if err := globalState.fs.MkdirAll(filepath.Dir(globalState.flags.configFilePath), 0o755); err != nil { + if err := gs.FS.MkdirAll(filepath.Dir(gs.Flags.ConfigFilePath), 0o755); err != nil { return err } - return afero.WriteFile(globalState.fs, globalState.flags.configFilePath, data, 0o644) + return afero.WriteFile(gs.FS, gs.Flags.ConfigFilePath, data, 0o644) } // Reads configuration variables from the environment. @@ -162,14 +163,14 @@ func readEnvConfig(envMap map[string]string) (Config, error) { // - set some defaults if they weren't previously specified // TODO: add better validation, more explicit default values and improve consistency between formats // TODO: accumulate all errors and differentiate between the layers? -func getConsolidatedConfig(globalState *globalState, cliConf Config, runnerOpts lib.Options) (conf Config, err error) { +func getConsolidatedConfig(gs *state.GlobalState, cliConf Config, runnerOpts lib.Options) (conf Config, err error) { // TODO: use errext.WithExitCodeIfNone(err, exitcodes.InvalidConfig) where it makes sense? - fileConf, err := readDiskConfig(globalState) + fileConf, err := readDiskConfig(gs) if err != nil { return conf, err } - envConf, err := readEnvConfig(globalState.envVars) + envConf, err := readEnvConfig(gs.Env) if err != nil { return conf, err } diff --git a/cmd/config_consolidation_test.go b/cmd/config_consolidation_test.go index 1dc27c49ba9..776d7456e09 100644 --- a/cmd/config_consolidation_test.go +++ b/cmd/config_consolidation_test.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "path/filepath" "testing" "time" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/lib/executor" "go.k6.io/k6/lib/types" @@ -143,11 +143,9 @@ type configConsolidationTestCase struct { } func getConfigConsolidationTestCases() []configConsolidationTestCase { + defaultFlags := state.GetDefaultFlags(".config") defaultConfig := func(jsonConfig string) afero.Fs { - return getFS([]file{{ - filepath.Join(".config", "loadimpact", "k6", defaultConfigFileName), // TODO: improve - jsonConfig, - }}) + return getFS([]file{{defaultFlags.ConfigFilePath, jsonConfig}}) } I := null.IntFrom // shortcut for "Valid" (i.e. user-specified) ints // This is a function, because some of these test cases actually need for the init() functions @@ -488,15 +486,15 @@ func getConfigConsolidationTestCases() []configConsolidationTestCase { func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd string) { t.Logf("Test for `k6 %s` with opts=%#v and exp=%#v\n", subCmd, testCase.options, testCase.expected) - ts := newGlobalTestState(t) - ts.args = append([]string{"k6", subCmd}, testCase.options.cli...) - ts.envVars = buildEnvMap(testCase.options.env) + ts := state.NewGlobalTestState(t) + ts.CmdArgs = append([]string{"k6", subCmd}, testCase.options.cli...) + ts.Env = state.BuildEnvMap(testCase.options.env) if testCase.options.fs != nil { - ts.globalState.fs = testCase.options.fs + ts.GlobalState.FS = testCase.options.fs } - rootCmd := newRootCommand(ts.globalState) - cmd, args, err := rootCmd.cmd.Find(ts.args[1:]) + rootCmd := newRootCommand(ts.GlobalState) + cmd, args, err := rootCmd.cmd.Find(ts.CmdArgs[1:]) require.NoError(t, err) err = cmd.ParseFlags(args) @@ -526,7 +524,7 @@ func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd stri if testCase.options.runner != nil { opts = *testCase.options.runner } - consolidatedConfig, err := getConsolidatedConfig(ts.globalState, cliConf, opts) + consolidatedConfig, err := getConsolidatedConfig(ts.GlobalState, cliConf, opts) if testCase.expected.consolidationError { require.Error(t, err) return @@ -534,14 +532,14 @@ func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd stri require.NoError(t, err) derivedConfig := consolidatedConfig - derivedConfig.Options, err = executor.DeriveScenariosFromShortcuts(consolidatedConfig.Options, ts.logger) + derivedConfig.Options, err = executor.DeriveScenariosFromShortcuts(consolidatedConfig.Options, ts.Logger) if testCase.expected.derivationError { require.Error(t, err) return } require.NoError(t, err) - if warnings := ts.loggerHook.Drain(); testCase.expected.logWarning { + if warnings := ts.LoggerHook.Drain(); testCase.expected.logWarning { assert.NotEmpty(t, warnings) } else { assert.Empty(t, warnings) diff --git a/cmd/convert.go b/cmd/convert.go index c5c3c1640d0..e8af06bf9ed 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -8,13 +8,14 @@ import ( "github.com/spf13/cobra" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/converter/har" "go.k6.io/k6/lib" ) // TODO: split apart like `k6 run` and `k6 archive`? //nolint:funlen,gocognit -func getCmdConvert(globalState *globalState) *cobra.Command { +func getCmdConvert(gs *state.GlobalState) *cobra.Command { var ( convertOutput string optionsFilePath string @@ -48,7 +49,7 @@ func getCmdConvert(globalState *globalState) *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Parse the HAR file - r, err := globalState.fs.Open(args[0]) + r, err := gs.FS.Open(args[0]) if err != nil { return err } @@ -64,7 +65,7 @@ func getCmdConvert(globalState *globalState) *cobra.Command { options := lib.Options{MaxRedirects: null.IntFrom(0)} if optionsFilePath != "" { - optionsFileContents, readErr := afero.ReadFile(globalState.fs, optionsFilePath) + optionsFileContents, readErr := afero.ReadFile(gs.FS, optionsFilePath) if readErr != nil { return readErr } @@ -84,11 +85,11 @@ func getCmdConvert(globalState *globalState) *cobra.Command { // Write script content to stdout or file if convertOutput == "" || convertOutput == "-" { //nolint:nestif - if _, err := io.WriteString(globalState.stdOut, script); err != nil { + if _, err := io.WriteString(gs.Stdout, script); err != nil { return err } } else { - f, err := globalState.fs.Create(convertOutput) + f, err := gs.FS.Create(convertOutput) if err != nil { return err } diff --git a/cmd/convert_test.go b/cmd/convert_test.go index eeae1b9b9b1..d7b99987ca6 100644 --- a/cmd/convert_test.go +++ b/cmd/convert_test.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" ) const testHAR = ` @@ -107,16 +108,16 @@ func TestConvertCmdCorrelate(t *testing.T) { expectedTestPlan, err := ioutil.ReadFile("testdata/example.js") require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "correlate.har", har, 0o644)) - testState.args = []string{ + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "correlate.har", har, 0o644)) + ts.CmdArgs = []string{ "k6", "convert", "--output=result.js", "--correlate=true", "--no-batch=true", "--enable-status-code-checks=true", "--return-on-failed-check=true", "correlate.har", } - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() - result, err := afero.ReadFile(testState.fs, "result.js") + result, err := afero.ReadFile(ts.FS, "result.js") require.NoError(t, err) // Sanitizing to avoid windows problems with carriage returns @@ -142,24 +143,24 @@ func TestConvertCmdCorrelate(t *testing.T) { func TestConvertCmdStdout(t *testing.T) { t.Parallel() - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "stdout.har", []byte(testHAR), 0o644)) - testState.args = []string{"k6", "convert", "stdout.har"} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "stdout.har", []byte(testHAR), 0o644)) + ts.CmdArgs = []string{"k6", "convert", "stdout.har"} - newRootCommand(testState.globalState).execute() - assert.Equal(t, "Command \"convert\" is deprecated, please use har-to-k6 (https://github.com/grafana/har-to-k6) instead.\n"+testHARConvertResult, testState.stdOut.String()) + newRootCommand(ts.GlobalState).execute() + assert.Equal(t, "Command \"convert\" is deprecated, please use har-to-k6 (https://github.com/grafana/har-to-k6) instead.\n"+testHARConvertResult, ts.Stdout.String()) } func TestConvertCmdOutputFile(t *testing.T) { t.Parallel() - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "output.har", []byte(testHAR), 0o644)) - testState.args = []string{"k6", "convert", "--output", "result.js", "output.har"} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "output.har", []byte(testHAR), 0o644)) + ts.CmdArgs = []string{"k6", "convert", "--output", "result.js", "output.har"} - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() - output, err := afero.ReadFile(testState.fs, "result.js") + output, err := afero.ReadFile(ts.FS, "result.js") assert.NoError(t, err) assert.Equal(t, testHARConvertResult, string(output)) } diff --git a/cmd/inspect.go b/cmd/inspect.go index e08eada3766..8d963633909 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -5,12 +5,13 @@ import ( "github.com/spf13/cobra" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/lib/types" ) // TODO: split apart like `k6 run` and `k6 archive` -func getCmdInspect(gs *globalState) *cobra.Command { +func getCmdInspect(gs *state.GlobalState) *cobra.Command { var addExecReqs bool // inspectCmd represents the inspect command @@ -60,7 +61,9 @@ func getCmdInspect(gs *globalState) *cobra.Command { // If --execution-requirements is enabled, this will consolidate the config, // derive the value of `scenarios` and calculate the max test duration and VUs. -func inspectOutputWithExecRequirements(gs *globalState, cmd *cobra.Command, test *loadedTest) (interface{}, error) { +func inspectOutputWithExecRequirements( + gs *state.GlobalState, cmd *cobra.Command, test *loadedTest, +) (interface{}, error) { // we don't actually support CLI flags here, so we pass nil as the getter configuredTest, err := test.consolidateDeriveAndValidateConfig(gs, cmd, nil) if err != nil { diff --git a/cmd/integration_test.go b/cmd/integration_test.go index 42fc5a9a9cd..33ce25c7be2 100644 --- a/cmd/integration_test.go +++ b/cmd/integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "go.k6.io/k6/cloudapi" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" "go.k6.io/k6/lib/consts" @@ -32,81 +33,81 @@ import ( func TestVersion(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "version"} - newRootCommand(ts.globalState).execute() + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "version"} + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() assert.Contains(t, stdOut, "k6 v"+consts.Version) assert.Contains(t, stdOut, runtime.Version()) assert.Contains(t, stdOut, runtime.GOOS) assert.Contains(t, stdOut, runtime.GOARCH) assert.Contains(t, stdOut, "k6/x/alarmist") - assert.Empty(t, ts.stdErr.Bytes()) - assert.Empty(t, ts.loggerHook.Drain()) + assert.Empty(t, ts.Stderr.Bytes()) + assert.Empty(t, ts.LoggerHook.Drain()) } func TestSimpleTestStdin(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "run", "-"} - ts.stdIn = bytes.NewBufferString(`export default function() {};`) - newRootCommand(ts.globalState).execute() + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "run", "-"} + ts.Stdin = bytes.NewBufferString(`export default function() {};`) + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() assert.Contains(t, stdOut, "default: 1 iterations for each of 1 VUs") assert.Contains(t, stdOut, "1 complete and 0 interrupted iterations") - assert.Empty(t, ts.stdErr.Bytes()) - assert.Empty(t, ts.loggerHook.Drain()) + assert.Empty(t, ts.Stderr.Bytes()) + assert.Empty(t, ts.LoggerHook.Drain()) } func TestStdoutAndStderrAreEmptyWithQuietAndHandleSummary(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "--quiet", "run", "-"} - ts.stdIn = bytes.NewBufferString(` + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "--quiet", "run", "-"} + ts.Stdin = bytes.NewBufferString(` export default function() {}; export function handleSummary(data) { return {}; // silence the end of test summary }; `) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - assert.Empty(t, ts.stdErr.Bytes()) - assert.Empty(t, ts.stdOut.Bytes()) - assert.Empty(t, ts.loggerHook.Drain()) + assert.Empty(t, ts.Stderr.Bytes()) + assert.Empty(t, ts.Stdout.Bytes()) + assert.Empty(t, ts.LoggerHook.Drain()) } func TestStdoutAndStderrAreEmptyWithQuietAndLogsForwarded(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) + ts := state.NewGlobalTestState(t) // TODO: add a test with relative path - logFilePath := filepath.Join(ts.cwd, "test.log") + logFilePath := filepath.Join(ts.Cwd, "test.log") - ts.args = []string{ + ts.CmdArgs = []string{ "k6", "--quiet", "--log-output", "file=" + logFilePath, "--log-format", "raw", "run", "--no-summary", "-", } - ts.stdIn = bytes.NewBufferString(` + ts.Stdin = bytes.NewBufferString(` console.log('init'); export default function() { console.log('foo'); }; `) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() // The test state hook still catches this message - assert.True(t, testutils.LogContains(ts.loggerHook.Drain(), logrus.InfoLevel, `foo`)) + assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.InfoLevel, `foo`)) // But it's not shown on stderr or stdout - assert.Empty(t, ts.stdErr.Bytes()) - assert.Empty(t, ts.stdOut.Bytes()) + assert.Empty(t, ts.Stderr.Bytes()) + assert.Empty(t, ts.Stdout.Bytes()) // Instead it should be in the log file - logContents, err := afero.ReadFile(ts.fs, logFilePath) + logContents, err := afero.ReadFile(ts.FS, logFilePath) require.NoError(t, err) assert.Equal(t, "init\ninit\nfoo\n", string(logContents)) //nolint:dupword } @@ -114,25 +115,25 @@ func TestStdoutAndStderrAreEmptyWithQuietAndLogsForwarded(t *testing.T) { func TestRelativeLogPathWithSetupAndTeardown(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) + ts := state.NewGlobalTestState(t) - ts.args = []string{"k6", "--log-output", "file=test.log", "--log-format", "raw", "run", "-i", "2", "-"} - ts.stdIn = bytes.NewBufferString(` + ts.CmdArgs = []string{"k6", "--log-output", "file=test.log", "--log-format", "raw", "run", "-i", "2", "-"} + ts.Stdin = bytes.NewBufferString(` console.log('init'); export default function() { console.log('foo'); }; export function setup() { console.log('bar'); }; export function teardown() { console.log('baz'); }; `) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() // The test state hook still catches these messages - logEntries := ts.loggerHook.Drain() + logEntries := ts.LoggerHook.Drain() assert.True(t, testutils.LogContains(logEntries, logrus.InfoLevel, `foo`)) assert.True(t, testutils.LogContains(logEntries, logrus.InfoLevel, `bar`)) assert.True(t, testutils.LogContains(logEntries, logrus.InfoLevel, `baz`)) // And check that the log file also contains everything - logContents, err := afero.ReadFile(ts.fs, filepath.Join(ts.cwd, "test.log")) + logContents, err := afero.ReadFile(ts.FS, filepath.Join(ts.Cwd, "test.log")) require.NoError(t, err) assert.Equal(t, "init\ninit\ninit\nbar\nfoo\nfoo\ninit\nbaz\ninit\n", string(logContents)) //nolint:dupword } @@ -140,42 +141,42 @@ func TestRelativeLogPathWithSetupAndTeardown(t *testing.T) { func TestWrongCliFlagIterations(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "run", "--iterations", "foo", "-"} - ts.stdIn = bytes.NewBufferString(`export default function() {};`) + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "run", "--iterations", "foo", "-"} + ts.Stdin = bytes.NewBufferString(`export default function() {};`) // TODO: check for exitcodes.InvalidConfig after https://github.com/loadimpact/k6/issues/883 is done... - ts.expectedExitCode = -1 - newRootCommand(ts.globalState).execute() - assert.True(t, testutils.LogContains(ts.loggerHook.Drain(), logrus.ErrorLevel, `invalid argument "foo"`)) + ts.ExpectedExitCode = -1 + newRootCommand(ts.GlobalState).execute() + assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `invalid argument "foo"`)) } func TestWrongEnvVarIterations(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "run", "--vus", "2", "-"} - ts.envVars["K6_ITERATIONS"] = "4" - ts.stdIn = bytes.NewBufferString(`export default function() {};`) + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "run", "--vus", "2", "-"} + ts.Env["K6_ITERATIONS"] = "4" + ts.Stdin = bytes.NewBufferString(`export default function() {};`) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, "4 iterations shared among 2 VUs") assert.Contains(t, stdOut, "4 complete and 0 interrupted iterations") - assert.Empty(t, ts.stdErr.Bytes()) - assert.Empty(t, ts.loggerHook.Drain()) + assert.Empty(t, ts.Stderr.Bytes()) + assert.Empty(t, ts.LoggerHook.Drain()) } -func getSingleFileTestState(t *testing.T, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *globalTestState { +func getSingleFileTestState(t *testing.T, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *state.GlobalTestState { if cliFlags == nil { cliFlags = []string{"-v", "--log-output=stdout"} } - ts := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(ts.fs, filepath.Join(ts.cwd, "test.js"), []byte(script), 0o644)) - ts.args = append(append([]string{"k6", "run"}, cliFlags...), "test.js") - ts.expectedExitCode = int(expExitCode) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(script), 0o644)) + ts.CmdArgs = append(append([]string{"k6", "run"}, cliFlags...), "test.js") + ts.ExpectedExitCode = int(expExitCode) return ts } @@ -241,23 +242,23 @@ func TestMetricsAndThresholds(t *testing.T) { } ` ts := getSingleFileTestState(t, script, []string{"--quiet", "--log-format=raw"}, 0) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() expLogLines := []string{ `setup() start`, `setup() end`, `default({"foo":"bar"})`, `default({"foo":"bar"})`, `teardown({"foo":"bar"})`, `handleSummary()`, } - logHookEntries := ts.loggerHook.Drain() + logHookEntries := ts.LoggerHook.Drain() require.Len(t, logHookEntries, len(expLogLines)) for i, expLogLine := range expLogLines { assert.Equal(t, expLogLine, logHookEntries[i].Message) } - assert.Equal(t, strings.Join(expLogLines, "\n")+"\n", ts.stdErr.String()) + assert.Equal(t, strings.Join(expLogLines, "\n")+"\n", ts.Stderr.String()) var summary map[string]interface{} - require.NoError(t, json.Unmarshal(ts.stdOut.Bytes(), &summary)) + require.NoError(t, json.Unmarshal(ts.Stdout.Bytes(), &summary)) metrics, ok := summary["metrics"].(map[string]interface{}) require.True(t, ok) @@ -274,24 +275,24 @@ func TestMetricsAndThresholds(t *testing.T) { func TestSSLKEYLOGFILEAbsolute(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - testSSLKEYLOGFILE(t, ts, filepath.Join(ts.cwd, "ssl.log")) + ts := state.NewGlobalTestState(t) + testSSLKEYLOGFILE(t, ts, filepath.Join(ts.Cwd, "ssl.log")) } func TestSSLKEYLOGFILEARelative(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) + ts := state.NewGlobalTestState(t) testSSLKEYLOGFILE(t, ts, "./ssl.log") } -func testSSLKEYLOGFILE(t *testing.T, ts *globalTestState, filePath string) { +func testSSLKEYLOGFILE(t *testing.T, ts *state.GlobalTestState, filePath string) { t.Helper() // TODO don't use insecureSkipTLSVerify when/if tlsConfig is given to the runner from outside tb := httpmultibin.NewHTTPMultiBin(t) - ts.args = []string{"k6", "run", "-"} - ts.envVars["SSLKEYLOGFILE"] = filePath - ts.stdIn = bytes.NewReader([]byte(tb.Replacer.Replace(` + ts.CmdArgs = []string{"k6", "run", "-"} + ts.Env["SSLKEYLOGFILE"] = filePath + ts.Stdin = bytes.NewReader([]byte(tb.Replacer.Replace(` import http from "k6/http" export const options = { hosts: { @@ -305,11 +306,11 @@ func testSSLKEYLOGFILE(t *testing.T, ts *globalTestState, filePath string) { } `))) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() assert.True(t, - testutils.LogContains(ts.loggerHook.Drain(), logrus.WarnLevel, "SSLKEYLOGFILE was specified")) - sslloglines, err := afero.ReadFile(ts.fs, filepath.Join(ts.cwd, "ssl.log")) + testutils.LogContains(ts.LoggerHook.Drain(), logrus.WarnLevel, "SSLKEYLOGFILE was specified")) + sslloglines, err := afero.ReadFile(ts.FS, filepath.Join(ts.Cwd, "ssl.log")) require.NoError(t, err) // TODO maybe have multiple depending on the ciphers used as that seems to change it assert.Regexp(t, "^CLIENT_[A-Z_]+ [0-9a-f]+ [0-9a-f]+\n", string(sslloglines)) @@ -318,9 +319,9 @@ func testSSLKEYLOGFILE(t *testing.T, ts *globalTestState, filePath string) { func TestThresholdDeprecationWarnings(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "run", "--system-tags", "url,error,vu,iter,scenario", "-"} - ts.stdIn = bytes.NewReader([]byte(` + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "run", "--system-tags", "url,error,vu,iter,scenario", "-"} + ts.Stdin = bytes.NewReader([]byte(` export const options = { thresholds: { 'http_req_duration{url:https://test.k6.io}': ['p(95)<500', 'p(99)<1000'], @@ -333,9 +334,9 @@ func TestThresholdDeprecationWarnings(t *testing.T) { export default function () { }`, )) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - logs := ts.loggerHook.Drain() + logs := ts.LoggerHook.Drain() // We no longer warn about this assert.False(t, testutils.LogContains(logs, logrus.WarnLevel, "http_req_duration{url:https://test.k6.io}")) @@ -366,9 +367,9 @@ func TestExecutionTestOptionsDefaultValues(t *testing.T) { ` ts := getSingleFileTestState(t, script, []string{"--iterations", "1"}, 0) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - loglines := ts.loggerHook.Drain() + loglines := ts.LoggerHook.Drain() require.Len(t, loglines, 1) expected := `{"paused":null,"executionSegment":null,"executionSegmentSequence":null,"noSetup":null,"setupTimeout":null,"noTeardown":null,"teardownTimeout":null,"rps":null,"dns":{"ttl":null,"select":null,"policy":null},"maxRedirects":null,"userAgent":null,"batch":null,"batchPerHost":null,"httpDebug":null,"insecureSkipTLSVerify":null,"tlsCipherSuites":null,"tlsVersion":null,"tlsAuth":null,"throw":null,"thresholds":null,"blacklistIPs":null,"blockHostnames":null,"hosts":null,"noConnectionReuse":null,"noVUConnectionReuse":null,"minIterationDuration":null,"ext":null,"summaryTrendStats":["avg", "min", "med", "max", "p(90)", "p(95)"],"summaryTimeUnit":null,"systemTags":["check","error","error_code","expected_response","group","method","name","proto","scenario","service","status","subproto","tls_version","url"],"tags":null,"metricSamplesBufferSize":null,"noCookiesReset":null,"discardResponseBodies":null,"consoleOutput":null,"scenarios":{"default":{"vus":null,"iterations":1,"executor":"shared-iterations","maxDuration":null,"startTime":null,"env":null,"tags":null,"gracefulStop":null,"exec":null}},"localIPs":null}` @@ -394,10 +395,10 @@ func TestSubMetricThresholdNoData(t *testing.T) { } ` ts := getSingleFileTestState(t, script, []string{"--quiet"}, 0) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - assert.Len(t, ts.loggerHook.Drain(), 0) - assert.Contains(t, ts.stdOut.String(), ` + assert.Len(t, ts.LoggerHook.Drain(), 0) + assert.Contains(t, ts.Stdout.String(), ` one..................: 0 0/s { tag:xyz }........: 0 0/s two..................: 42`) @@ -468,7 +469,7 @@ func getCloudTestEndChecker(t *testing.T, expRunStatus lib.RunStatus, expResultS func getSimpleCloudOutputTestState( t *testing.T, script string, cliFlags []string, expRunStatus lib.RunStatus, expResultStatus cloudapi.ResultStatus, expExitCode exitcodes.ExitCode, -) *globalTestState { +) *state.GlobalTestState { if cliFlags == nil { cliFlags = []string{"-v", "--log-output=stdout"} } @@ -476,7 +477,7 @@ func getSimpleCloudOutputTestState( srv := getCloudTestEndChecker(t, expRunStatus, expResultStatus) ts := getSingleFileTestState(t, script, cliFlags, expExitCode) - ts.envVars["K6_CLOUD_HOST"] = srv.URL + ts.Env["K6_CLOUD_HOST"] = srv.URL return ts } @@ -515,14 +516,14 @@ func TestSetupTeardownThresholds(t *testing.T) { `) ts := getSimpleCloudOutputTestState(t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() assert.Contains(t, stdOut, `✓ http_reqs......................: 7`) assert.Contains(t, stdOut, `✓ iterations.....................: 5`) assert.Contains(t, stdOut, `✓ setup_teardown.................: 2`) - logMsgs := ts.loggerHook.Drain() + logMsgs := ts.LoggerHook.Drain() for _, msg := range logMsgs { if msg.Level != logrus.DebugLevel { assert.Failf(t, "unexpected log message", "level %s, msg '%s'", msg.Level, msg.Message) @@ -562,10 +563,10 @@ func TestThresholdsFailed(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusFailed, exitcodes.ThresholdsHaveFailed, ) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - assert.True(t, testutils.LogContains(ts.loggerHook.Drain(), logrus.ErrorLevel, `some thresholds have failed`)) - stdOut := ts.stdOut.String() + assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `some thresholds have failed`)) + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, ` ✓ iterations...........: 3`) assert.Contains(t, stdOut, ` ✗ { scenario:sc1 }...: 1`) @@ -603,10 +604,10 @@ func TestAbortedByThreshold(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedThreshold, cloudapi.ResultStatusFailed, exitcodes.ThresholdsHaveFailed, ) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - assert.True(t, testutils.LogContains(ts.loggerHook.Drain(), logrus.ErrorLevel, `test run aborted by failed thresholds`)) - stdOut := ts.stdOut.String() + assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `test run aborted by failed thresholds`)) + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `✗ iterations`) assert.Contains(t, stdOut, `teardown() called`) @@ -652,12 +653,12 @@ func TestAbortedByUserWithGoodThresholds(t *testing.T) { asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "simple iter 2") - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - logs := ts.loggerHook.Drain() + logs := ts.LoggerHook.Drain() assert.False(t, testutils.LogContains(logs, logrus.ErrorLevel, `some thresholds have failed`)) assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, `test run aborted by signal`)) - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `✓ iterations`) assert.Contains(t, stdOut, `✓ tc`) @@ -669,7 +670,7 @@ func TestAbortedByUserWithGoodThresholds(t *testing.T) { } func asyncWaitForStdoutAndRun( - t *testing.T, ts *globalTestState, attempts int, interval time.Duration, expText string, callback func(), + t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, callback func(), ) { wg := &sync.WaitGroup{} wg.Add(1) @@ -677,9 +678,9 @@ func asyncWaitForStdoutAndRun( defer wg.Done() reachedCondition := false for i := 0; i < attempts; i++ { - ts.outMutex.Lock() - stdOut := ts.stdOut.String() - ts.outMutex.Unlock() + ts.OutMutex.Lock() + stdOut := ts.Stdout.String() + ts.OutMutex.Unlock() if strings.Contains(stdOut, expText) { t.Logf("found '%s' in the process stdout on try %d at t=%s", expText, i, time.Now()) @@ -695,9 +696,9 @@ func asyncWaitForStdoutAndRun( return // everything is fine } - ts.outMutex.Lock() - stdOut := ts.stdOut.String() - ts.outMutex.Unlock() + ts.OutMutex.Lock() + stdOut := ts.Stdout.String() + ts.OutMutex.Unlock() t.Log(stdOut) require.FailNow( t, "expected output not found", "did not find the text '%s' in the process stdout after %d attempts (%s)", @@ -709,10 +710,10 @@ func asyncWaitForStdoutAndRun( } func asyncWaitForStdoutAndStopTestWithInterruptSignal( - t *testing.T, ts *globalTestState, attempts int, interval time.Duration, expText string, + t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, ) { sendSignal := make(chan struct{}) - ts.globalState.signalNotify = func(c chan<- os.Signal, signals ...os.Signal) { + ts.GlobalState.SignalNotify = func(c chan<- os.Signal, signals ...os.Signal) { isAbortNotify := false for _, s := range signals { if s == os.Interrupt { @@ -729,7 +730,7 @@ func asyncWaitForStdoutAndStopTestWithInterruptSignal( close(sendSignal) }() } - ts.globalState.signalStop = func(c chan<- os.Signal) { /* noop */ } + ts.GlobalState.SignalStop = func(c chan<- os.Signal) { /* noop */ } asyncWaitForStdoutAndRun(t, ts, attempts, interval, expText, func() { t.Log("expected stdout text was found, sending interrupt signal...") @@ -739,11 +740,11 @@ func asyncWaitForStdoutAndStopTestWithInterruptSignal( } func asyncWaitForStdoutAndStopTestFromRESTAPI( - t *testing.T, ts *globalTestState, attempts int, interval time.Duration, expText string, + t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, ) { asyncWaitForStdoutAndRun(t, ts, attempts, interval, expText, func() { req, err := http.NewRequestWithContext( - ts.ctx, http.MethodPatch, fmt.Sprintf("http://%s/v1/status", ts.flags.address), + ts.Ctx, http.MethodPatch, fmt.Sprintf("http://%s/v1/status", ts.Flags.Address), bytes.NewBufferString(`{"data":{"type":"status","id":"default","attributes":{"stopped":true}}}`), ) require.NoError(t, err) @@ -781,9 +782,9 @@ func TestAbortedByUserWithRestAPI(t *testing.T) { asyncWaitForStdoutAndStopTestFromRESTAPI(t, ts, 15, time.Second, "a simple iteration") - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `a simple iteration`) assert.Contains(t, stdOut, `teardown() called`) @@ -818,17 +819,17 @@ func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) { srv := getCloudTestEndChecker(t, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed) - ts := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(ts.fs, filepath.Join(ts.cwd, "test.js"), []byte(mainScript), 0o644)) - require.NoError(t, afero.WriteFile(ts.fs, filepath.Join(ts.cwd, "bar.js"), []byte(depScript), 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(mainScript), 0o644)) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "bar.js"), []byte(depScript), 0o644)) - ts.envVars["K6_CLOUD_HOST"] = srv.URL - ts.args = []string{"k6", "run", "-v", "--out", "cloud", "--log-output=stdout", "test.js"} - ts.expectedExitCode = int(exitcodes.ScriptException) + ts.Env["K6_CLOUD_HOST"] = srv.URL + ts.CmdArgs = []string{"k6", "run", "-v", "--out", "cloud", "--log-output=stdout", "test.js"} + ts.ExpectedExitCode = int(exitcodes.ScriptException) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `wonky setup`) @@ -842,14 +843,14 @@ func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) { assert.Contains(t, stdOut, "bogus summary") } -func runTestWithNoLinger(t *testing.T, ts *globalTestState) { - newRootCommand(ts.globalState).execute() +func runTestWithNoLinger(t *testing.T, ts *state.GlobalTestState) { + newRootCommand(ts.GlobalState).execute() } -func runTestWithLinger(t *testing.T, ts *globalTestState) { - ts.args = append(ts.args, "--linger") +func runTestWithLinger(t *testing.T, ts *state.GlobalTestState) { + ts.CmdArgs = append(ts.CmdArgs, "--linger") asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "Linger set; waiting for Ctrl+C") - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() } func TestAbortedByScriptSetupError(t *testing.T) { @@ -869,8 +870,8 @@ func TestAbortedByScriptSetupError(t *testing.T) { export function handleSummary() { return {stdout: '\n\n\nbogus summary\n\n\n'};} ` - doChecks := func(t *testing.T, ts *globalTestState) { - stdOut := ts.stdOut.String() + doChecks := func(t *testing.T, ts *state.GlobalTestState) { + stdOut := ts.Stdout.String() assert.Contains(t, stdOut, "Error: foo") assert.Contains(t, stdOut, "wonky setup") assert.NotContains(t, stdOut, "nice teardown") // do not execute teardown if setup failed @@ -908,8 +909,8 @@ func TestAbortedByScriptTeardownError(t *testing.T) { export function handleSummary() { return {stdout: '\n\n\nbogus summary\n\n\n'};} ` - doChecks := func(t *testing.T, ts *globalTestState) { - stdOut := ts.stdOut.String() + doChecks := func(t *testing.T, ts *state.GlobalTestState) { + stdOut := ts.Stdout.String() assert.Contains(t, stdOut, "Error: foo") assert.Contains(t, stdOut, "nice setup") assert.Contains(t, stdOut, "wonky teardown") @@ -929,13 +930,13 @@ func TestAbortedByScriptTeardownError(t *testing.T) { }) } -func testAbortedByScriptError(t *testing.T, script string, runTest func(*testing.T, *globalTestState)) *globalTestState { +func testAbortedByScriptError(t *testing.T, script string, runTest func(*testing.T, *state.GlobalTestState)) *state.GlobalTestState { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed, exitcodes.ScriptException, ) runTest(t, ts) - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) assert.Contains(t, stdOut, `level=debug msg="Metrics processing finished!"`) @@ -956,8 +957,8 @@ func TestAbortedByTestAbortFirstInitCode(t *testing.T) { ` ts := getSingleFileTestState(t, script, nil, exitcodes.ScriptAborted) - newRootCommand(ts.globalState).execute() - stdOut := ts.stdOut.String() + newRootCommand(ts.GlobalState).execute() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, "test aborted: foo") assert.NotContains(t, stdOut, "bogus summary") @@ -1073,14 +1074,14 @@ func TestAbortedByScriptAbortInTeardown(t *testing.T) { } func testAbortedByScriptTestAbort( - t *testing.T, shouldHaveMetrics bool, script string, runTest func(*testing.T, *globalTestState), -) *globalTestState { + t *testing.T, shouldHaveMetrics bool, script string, runTest func(*testing.T, *state.GlobalTestState), +) *state.GlobalTestState { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedUser, cloudapi.ResultStatusPassed, exitcodes.ScriptAborted, ) runTest(t, ts) - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, "test aborted: foo") assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) @@ -1120,9 +1121,9 @@ func TestAbortedByInterruptDuringVUInit(t *testing.T) { t, script, nil, lib.RunStatusAbortedSystem, cloudapi.ResultStatusPassed, exitcodes.GenericEngine, ) asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "VU init sleeping for a while") - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `level=debug msg="Stopping k6 in response to signal..." sig=interrupt`) @@ -1151,9 +1152,9 @@ func TestAbortedByScriptInitError(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed, exitcodes.ScriptException, ) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, `level=error msg="Error: oops in 2\n\tat file:///`) assert.Contains(t, stdOut, `hint="error while initializing VU #2 (script exception)"`) @@ -1249,9 +1250,9 @@ func TestMetricTagAndSetupDataIsolation(t *testing.T) { t, script, []string{"--quiet", "--log-output", "stdout"}, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0, ) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Equal(t, 12, strings.Count(stdOut, "✓")) } @@ -1372,19 +1373,19 @@ func TestActiveVUsCount(t *testing.T) { ` ts := getSingleFileTestState(t, script, []string{"--compatibility-mode", "base", "--out", "json=results.json"}, 0) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) - jsonResults, err := afero.ReadFile(ts.fs, "results.json") + jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) // t.Log(string(jsonResults)) assert.Equal(t, float64(10), max(getSampleValues(t, jsonResults, "vus_max", nil))) assert.Equal(t, float64(10), max(getSampleValues(t, jsonResults, "vus", nil))) assert.Equal(t, float64(0), sum(getSampleValues(t, jsonResults, "iterations", nil))) - logEntries := ts.loggerHook.Drain() + logEntries := ts.LoggerHook.Drain() assert.Len(t, logEntries, 4) for i, logEntry := range logEntries { assert.Equal(t, logrus.WarnLevel, logEntry.Level) @@ -1420,7 +1421,7 @@ func TestMinIterationDuration(t *testing.T) { ts := getSimpleCloudOutputTestState(t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0) start := time.Now() - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() elapsed := time.Since(start) assert.Greater(t, elapsed, 5*time.Second, "expected more time to have passed because of minIterationDuration") assert.Less( @@ -1428,7 +1429,7 @@ func TestMinIterationDuration(t *testing.T) { "expected less time to have passed because minIterationDuration should not affect setup() and teardown() ", ) - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) assert.Contains(t, stdOut, "✓ test_counter.........: 3") } @@ -1498,14 +1499,14 @@ func TestRunTags(t *testing.T) { "-u", "2", "--log-output=stdout", "--out", "json=results.json", "--tag", "foo=bar", "--tag", "test=mest", "--tag", "over=written", }, 0) - ts.envVars["K6_ITERATIONS"] = "3" - ts.envVars["K6_INSECURE_SKIP_TLS_VERIFY"] = "true" - newRootCommand(ts.globalState).execute() + ts.Env["K6_ITERATIONS"] = "3" + ts.Env["K6_INSECURE_SKIP_TLS_VERIFY"] = "true" + newRootCommand(ts.GlobalState).execute() - stdOut := ts.stdOut.String() + stdOut := ts.Stdout.String() t.Log(stdOut) - jsonResults, err := afero.ReadFile(ts.fs, "results.json") + jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) expTags := map[string]string{"foo": "bar", "test": "mest", "over": "written", "scenario": "default"} @@ -1544,17 +1545,17 @@ func TestRunTags(t *testing.T) { func TestPrometheusRemoteWriteOutput(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "run", "--out", "experimental-prometheus-rw", "-"} - ts.stdIn = bytes.NewBufferString(` + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "run", "--out", "experimental-prometheus-rw", "-"} + ts.Stdin = bytes.NewBufferString(` import exec from 'k6/execution'; export default function () {}; `) - newRootCommand(ts.globalState).execute() - ts.outMutex.Lock() - stdOut := ts.stdOut.String() - ts.outMutex.Unlock() + newRootCommand(ts.GlobalState).execute() + ts.OutMutex.Lock() + stdOut := ts.Stdout.String() + ts.OutMutex.Unlock() assert.Contains(t, stdOut, "output: Prometheus remote write") } diff --git a/cmd/login.go b/cmd/login.go index 0911966ad56..902ad2ae3d2 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -2,10 +2,12 @@ package cmd import ( "github.com/spf13/cobra" + + "go.k6.io/k6/cmd/state" ) // getCmdLogin returns the `k6 login` sub-command, together with its children. -func getCmdLogin(gs *globalState) *cobra.Command { +func getCmdLogin(gs *state.GlobalState) *cobra.Command { loginCmd := &cobra.Command{ Use: "login", Short: "Authenticate with a service", diff --git a/cmd/login_cloud.go b/cmd/login_cloud.go index 556ee29d2f6..8a150dcb52d 100644 --- a/cmd/login_cloud.go +++ b/cmd/login_cloud.go @@ -12,12 +12,13 @@ import ( "gopkg.in/guregu/null.v3" "go.k6.io/k6/cloudapi" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib/consts" "go.k6.io/k6/ui" ) //nolint:funlen,gocognit -func getCmdLoginCloud(globalState *globalState) *cobra.Command { +func getCmdLoginCloud(gs *state.GlobalState) *cobra.Command { // loginCloudCommand represents the 'login cloud' command loginCloudCommand := &cobra.Command{ Use: "cloud", @@ -36,7 +37,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, k6 login cloud`[1:], Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - currentDiskConf, err := readDiskConfig(globalState) + currentDiskConf, err := readDiskConfig(gs) if err != nil { return err } @@ -53,7 +54,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, // We want to use this fully consolidated config for things like // host addresses, so users can overwrite them with env vars. consolidatedCurrentConfig, err := cloudapi.GetConsolidatedConfig( - currentJSONConfigRaw, globalState.envVars, "", nil) + currentJSONConfigRaw, gs.Env, "", nil) if err != nil { return err } @@ -67,7 +68,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) - printToStdout(globalState, " token reset\n") + printToStdout(gs, " token reset\n") case show.Bool: case token.Valid: newCloudConf.Token = token @@ -85,10 +86,10 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, }, } if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert - globalState.logger.Warn("Stdin is not a terminal, falling back to plain text input") + gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") } var vals map[string]string - vals, err = form.Run(globalState.stdIn, globalState.stdOut) + vals, err = form.Run(gs.Stdin, gs.Stdout) if err != nil { return err } @@ -96,7 +97,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, password := vals["Password"] client := cloudapi.NewClient( - globalState.logger, + gs.Logger, "", consolidatedCurrentConfig.Host.String, consts.Version, @@ -122,17 +123,17 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, if err != nil { return err } - if err := writeDiskConfig(globalState, currentDiskConf); err != nil { + if err := writeDiskConfig(gs, currentDiskConf); err != nil { return err } if newCloudConf.Token.Valid { - valueColor := getColor(globalState.flags.noColor || !globalState.stdOut.isTTY, color.FgCyan) - if !globalState.flags.quiet { - printToStdout(globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan) + if !gs.Flags.Quiet { + printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) } - printToStdout(globalState, fmt.Sprintf( - "Logged in successfully, token saved in %s\n", globalState.flags.configFilePath, + printToStdout(gs, fmt.Sprintf( + "Logged in successfully, token saved in %s\n", gs.Flags.ConfigFilePath, )) } return nil diff --git a/cmd/login_influxdb.go b/cmd/login_influxdb.go index df85dd634c0..b6f7e7962b0 100644 --- a/cmd/login_influxdb.go +++ b/cmd/login_influxdb.go @@ -9,12 +9,13 @@ import ( "golang.org/x/term" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/output/influxdb" "go.k6.io/k6/ui" ) //nolint:funlen -func getCmdLoginInfluxDB(globalState *globalState) *cobra.Command { +func getCmdLoginInfluxDB(gs *state.GlobalState) *cobra.Command { // loginInfluxDBCommand represents the 'login influxdb' command loginInfluxDBCommand := &cobra.Command{ Use: "influxdb [uri]", @@ -24,7 +25,7 @@ func getCmdLoginInfluxDB(globalState *globalState) *cobra.Command { This will set the default server used when just "-o influxdb" is passed.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - config, err := readDiskConfig(globalState) + config, err := readDiskConfig(gs) if err != nil { return err } @@ -70,9 +71,9 @@ This will set the default server used when just "-o influxdb" is passed.`, }, } if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert - globalState.logger.Warn("Stdin is not a terminal, falling back to plain text input") + gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") } - vals, err := form.Run(globalState.stdIn, globalState.stdOut) + vals, err := form.Run(gs.Stdin, gs.Stdout) if err != nil { return err } @@ -97,7 +98,7 @@ This will set the default server used when just "-o influxdb" is passed.`, if err != nil { return err } - return writeDiskConfig(globalState, config) + return writeDiskConfig(gs, config) }, } return loginInfluxDBCommand diff --git a/cmd/outputs.go b/cmd/outputs.go index 3a83260c84d..71b5ae821e9 100644 --- a/cmd/outputs.go +++ b/cmd/outputs.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/ext" "go.k6.io/k6/lib" "go.k6.io/k6/output" @@ -68,7 +69,7 @@ func getPossibleIDList(constrs map[string]output.Constructor) string { } func createOutputs( - gs *globalState, test *loadedAndConfiguredTest, executionPlan []lib.ExecutionStep, + gs *state.GlobalState, test *loadedAndConfiguredTest, executionPlan []lib.ExecutionStep, ) ([]output.Output, error) { outputConstructors, err := getAllOutputConstructors() if err != nil { @@ -76,11 +77,11 @@ func createOutputs( } baseParams := output.Params{ ScriptPath: test.source.URL, - Logger: gs.logger, - Environment: gs.envVars, - StdOut: gs.stdOut, - StdErr: gs.stdErr, - FS: gs.fs, + Logger: gs.Logger, + Environment: gs.Env, + StdOut: gs.Stdout, + StdErr: gs.Stderr, + FS: gs.FS, ScriptOptions: test.derivedConfig.Options, RuntimeOptions: test.preInitState.RuntimeOptions, ExecutionPlan: executionPlan, diff --git a/cmd/panic_integration_test.go b/cmd/panic_integration_test.go index 19b17f4d0bf..2864d96c693 100644 --- a/cmd/panic_integration_test.go +++ b/cmd/panic_integration_test.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/js/modules" "go.k6.io/k6/lib/testutils" @@ -84,14 +85,14 @@ func TestRunScriptPanicsErrorsAndAbort(t *testing.T) { t.Parallel() testFilename := "script.js" - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, testFilename), []byte(tc.testScript), 0o644)) - testState.args = []string{"k6", "run", testFilename} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testFilename), []byte(tc.testScript), 0o644)) + ts.CmdArgs = []string{"k6", "run", testFilename} - testState.expectedExitCode = int(exitcodes.ScriptAborted) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(exitcodes.ScriptAborted) + newRootCommand(ts.GlobalState).execute() - logs := testState.loggerHook.Drain() + logs := ts.LoggerHook.Drain() assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, tc.expectedLogMessage)) assert.False(t, testutils.LogContains(logs, logrus.InfoLevel, "lorem ipsum")) diff --git a/cmd/pause.go b/cmd/pause.go index db02029f89f..c6618898f94 100644 --- a/cmd/pause.go +++ b/cmd/pause.go @@ -6,9 +6,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdPause(globalState *globalState) *cobra.Command { +func getCmdPause(gs *state.GlobalState) *cobra.Command { // pauseCmd represents the pause command pauseCmd := &cobra.Command{ Use: "pause", @@ -17,17 +18,17 @@ func getCmdPause(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{ + status, err := c.SetStatus(gs.Ctx, v1.Status{ Paused: null.BoolFrom(true), }) if err != nil { return err } - return yamlPrint(globalState.stdOut, status) + return yamlPrint(gs.Stdout, status) }, } return pauseCmd diff --git a/cmd/resume.go b/cmd/resume.go index 373cbd285c3..b83a100704c 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -6,9 +6,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdResume(globalState *globalState) *cobra.Command { +func getCmdResume(gs *state.GlobalState) *cobra.Command { // resumeCmd represents the resume command resumeCmd := &cobra.Command{ Use: "resume", @@ -17,18 +18,18 @@ func getCmdResume(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{ + status, err := c.SetStatus(gs.Ctx, v1.Status{ Paused: null.BoolFrom(false), }) if err != nil { return err } - return yamlPrint(globalState.stdOut, status) + return yamlPrint(gs.Stdout, status) }, } return resumeCmd diff --git a/cmd/root.go b/cmd/root.go index 19f7c87c32b..ddf5dc7884e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,197 +4,34 @@ import ( "context" "errors" "fmt" - "io" "io/ioutil" stdlog "log" - "os" - "os/signal" - "path/filepath" "strconv" "strings" - "sync" "time" - "github.com/mattn/go-colorable" - "github.com/mattn/go-isatty" "github.com/sirupsen/logrus" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/lib/consts" "go.k6.io/k6/log" ) -const ( - defaultConfigFileName = "config.json" - waitRemoteLoggerTimeout = time.Second * 5 -) - -// globalFlags contains global config values that apply for all k6 sub-commands. -type globalFlags struct { - configFilePath string - quiet bool - noColor bool - address string - logOutput string - logFormat string - verbose bool -} - -// globalState contains the globalFlags and accessors for most of the global -// process-external state like CLI arguments, env vars, standard input, output -// and error, etc. In practice, most of it is normally accessed through the `os` -// package from the Go stdlib. -// -// We group them here so we can prevent direct access to them from the rest of -// the k6 codebase. This gives us the ability to mock them and have robust and -// easy-to-write integration-like tests to check the k6 end-to-end behavior in -// any simulated conditions. -// -// `newGlobalState()` returns a globalState object with the real `os` -// parameters, while `newGlobalTestState()` can be used in tests to create -// simulated environments. -type globalState struct { - ctx context.Context - - fs afero.Fs - getwd func() (string, error) - args []string - envVars map[string]string - - defaultFlags, flags globalFlags - - outMutex *sync.Mutex - stdOut, stdErr *consoleWriter - stdIn io.Reader - - osExit func(int) - signalNotify func(chan<- os.Signal, ...os.Signal) - signalStop func(chan<- os.Signal) - - logger *logrus.Logger - fallbackLogger logrus.FieldLogger -} - -// Ideally, this should be the only function in the whole codebase where we use -// global variables and functions from the os package. Anywhere else, things -// like os.Stdout, os.Stderr, os.Stdin, os.Getenv(), etc. should be removed and -// the respective properties of globalState used instead. -func newGlobalState(ctx context.Context) *globalState { - isDumbTerm := os.Getenv("TERM") == "dumb" - stdoutTTY := !isDumbTerm && (isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())) - stderrTTY := !isDumbTerm && (isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())) - outMutex := &sync.Mutex{} - stdOut := &consoleWriter{os.Stdout, colorable.NewColorable(os.Stdout), stdoutTTY, outMutex, nil} - stdErr := &consoleWriter{os.Stderr, colorable.NewColorable(os.Stderr), stderrTTY, outMutex, nil} - - envVars := buildEnvMap(os.Environ()) - _, noColorsSet := envVars["NO_COLOR"] // even empty values disable colors - logger := &logrus.Logger{ - Out: stdErr, - Formatter: &logrus.TextFormatter{ - ForceColors: stderrTTY, - DisableColors: !stderrTTY || noColorsSet || envVars["K6_NO_COLOR"] != "", - }, - Hooks: make(logrus.LevelHooks), - Level: logrus.InfoLevel, - } - - confDir, err := os.UserConfigDir() - if err != nil { - logger.WithError(err).Warn("could not get config directory") - confDir = ".config" - } - - defaultFlags := getDefaultFlags(confDir) - - return &globalState{ - ctx: ctx, - fs: afero.NewOsFs(), - getwd: os.Getwd, - args: append(make([]string, 0, len(os.Args)), os.Args...), // copy - envVars: envVars, - defaultFlags: defaultFlags, - flags: getFlags(defaultFlags, envVars), - outMutex: outMutex, - stdOut: stdOut, - stdErr: stdErr, - stdIn: os.Stdin, - osExit: os.Exit, - signalNotify: signal.Notify, - signalStop: signal.Stop, - logger: logger, - fallbackLogger: &logrus.Logger{ // we may modify the other one - Out: stdErr, - Formatter: new(logrus.TextFormatter), // no fancy formatting here - Hooks: make(logrus.LevelHooks), - Level: logrus.InfoLevel, - }, - } -} - -func getDefaultFlags(homeFolder string) globalFlags { - return globalFlags{ - address: "localhost:6565", - configFilePath: filepath.Join(homeFolder, "loadimpact", "k6", defaultConfigFileName), - logOutput: "stderr", - } -} - -func getFlags(defaultFlags globalFlags, env map[string]string) globalFlags { - result := defaultFlags - - // TODO: add env vars for the rest of the values (after adjusting - // rootCmdPersistentFlagSet(), of course) - - if val, ok := env["K6_CONFIG"]; ok { - result.configFilePath = val - } - if val, ok := env["K6_LOG_OUTPUT"]; ok { - result.logOutput = val - } - if val, ok := env["K6_LOG_FORMAT"]; ok { - result.logFormat = val - } - if env["K6_NO_COLOR"] != "" { - result.noColor = true - } - // Support https://no-color.org/, even an empty value should disable the - // color output from k6. - if _, ok := env["NO_COLOR"]; ok { - result.noColor = true - } - return result -} - -func parseEnvKeyValue(kv string) (string, string) { - if idx := strings.IndexRune(kv, '='); idx != -1 { - return kv[:idx], kv[idx+1:] - } - return kv, "" -} - -func buildEnvMap(environ []string) map[string]string { - env := make(map[string]string, len(environ)) - for _, kv := range environ { - k, v := parseEnvKeyValue(kv) - env[k] = v - } - return env -} +const waitRemoteLoggerTimeout = time.Second * 5 // This is to keep all fields needed for the main/root k6 command type rootCommand struct { - globalState *globalState + globalState *state.GlobalState cmd *cobra.Command loggerStopped <-chan struct{} loggerIsRemote bool } -func newRootCommand(gs *globalState) *rootCommand { +func newRootCommand(gs *state.GlobalState) *rootCommand { c := &rootCommand{ globalState: gs, } @@ -202,19 +39,19 @@ func newRootCommand(gs *globalState) *rootCommand { rootCmd := &cobra.Command{ Use: "k6", Short: "a next-generation load generator", - Long: "\n" + getBanner(c.globalState.flags.noColor || !c.globalState.stdOut.isTTY), + Long: "\n" + getBanner(gs.Flags.NoColor || !gs.Stdout.IsTTY), SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: c.persistentPreRunE, } rootCmd.PersistentFlags().AddFlagSet(rootCmdPersistentFlagSet(gs)) - rootCmd.SetArgs(gs.args[1:]) - rootCmd.SetOut(gs.stdOut) - rootCmd.SetErr(gs.stdErr) // TODO: use gs.logger.WriterLevel(logrus.ErrorLevel)? - rootCmd.SetIn(gs.stdIn) + rootCmd.SetArgs(gs.CmdArgs[1:]) + rootCmd.SetOut(gs.Stdout) + rootCmd.SetErr(gs.Stderr) // TODO: use gs.logger.WriterLevel(logrus.ErrorLevel)? + rootCmd.SetIn(gs.Stdin) - subCommands := []func(*globalState) *cobra.Command{ + subCommands := []func(*state.GlobalState) *cobra.Command{ getCmdArchive, getCmdCloud, getCmdConvert, getCmdInspect, getCmdLogin, getCmdPause, getCmdResume, getCmdScale, getCmdRun, getCmdStats, getCmdStatus, getCmdVersion, @@ -241,15 +78,15 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error c.loggerIsRemote = true } - stdlog.SetOutput(c.globalState.logger.Writer()) - c.globalState.logger.Debugf("k6 version: v%s", consts.FullVersion()) + stdlog.SetOutput(c.globalState.Logger.Writer()) + c.globalState.Logger.Debugf("k6 version: v%s", consts.FullVersion()) return nil } func (c *rootCommand) execute() { - ctx, cancel := context.WithCancel(c.globalState.ctx) + ctx, cancel := context.WithCancel(c.globalState.Ctx) defer cancel() - c.globalState.ctx = ctx + c.globalState.Ctx = ctx err := c.cmd.Execute() if err == nil { @@ -277,21 +114,20 @@ func (c *rootCommand) execute() { fields["hint"] = herr.Hint() } - c.globalState.logger.WithFields(fields).Error(errText) + c.globalState.Logger.WithFields(fields).Error(errText) if c.loggerIsRemote { - c.globalState.fallbackLogger.WithFields(fields).Error(errText) + c.globalState.FallbackLogger.WithFields(fields).Error(errText) cancel() c.waitRemoteLogger() } - c.globalState.osExit(exitCode) + c.globalState.OSExit(exitCode) } // Execute adds all child commands to the root command sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - gs := newGlobalState(context.Background()) - + gs := state.NewGlobalState(context.Background()) newRootCommand(gs).execute() } @@ -300,49 +136,49 @@ func (c *rootCommand) waitRemoteLogger() { select { case <-c.loggerStopped: case <-time.After(waitRemoteLoggerTimeout): - c.globalState.fallbackLogger.Errorf("Remote logger didn't stop in %s", waitRemoteLoggerTimeout) + c.globalState.FallbackLogger.Errorf("Remote logger didn't stop in %s", waitRemoteLoggerTimeout) } } } -func rootCmdPersistentFlagSet(gs *globalState) *pflag.FlagSet { +func rootCmdPersistentFlagSet(gs *state.GlobalState) *pflag.FlagSet { flags := pflag.NewFlagSet("", pflag.ContinueOnError) // TODO: refactor this config, the default value management with pflag is // simply terrible... :/ // - // We need to use `gs.flags.` both as the destination and as + // We need to use `gs.Flags.` both as the destination and as // the value here, since the config values could have already been set by // their respective environment variables. However, we then also have to // explicitly set the DefValue to the respective default value from - // `gs.defaultFlags.`, so that the `k6 --help` message is + // `gs.DefaultFlags.`, so that the `k6 --help` message is // not messed up... - flags.StringVar(&gs.flags.logOutput, "log-output", gs.flags.logOutput, + flags.StringVar(&gs.Flags.LogOutput, "log-output", gs.Flags.LogOutput, "change the output for k6 logs, possible values are stderr,stdout,none,loki[=host:port],file[=./path.fileformat]") - flags.Lookup("log-output").DefValue = gs.defaultFlags.logOutput + flags.Lookup("log-output").DefValue = gs.DefaultFlags.LogOutput - flags.StringVar(&gs.flags.logFormat, "logformat", gs.flags.logFormat, "log output format") + flags.StringVar(&gs.Flags.LogFormat, "logformat", gs.Flags.LogFormat, "log output format") oldLogFormat := flags.Lookup("logformat") oldLogFormat.Hidden = true oldLogFormat.Deprecated = "log-format" - oldLogFormat.DefValue = gs.defaultFlags.logFormat - flags.StringVar(&gs.flags.logFormat, "log-format", gs.flags.logFormat, "log output format") - flags.Lookup("log-format").DefValue = gs.defaultFlags.logFormat + oldLogFormat.DefValue = gs.DefaultFlags.LogFormat + flags.StringVar(&gs.Flags.LogFormat, "log-format", gs.Flags.LogFormat, "log output format") + flags.Lookup("log-format").DefValue = gs.DefaultFlags.LogFormat - flags.StringVarP(&gs.flags.configFilePath, "config", "c", gs.flags.configFilePath, "JSON config file") + flags.StringVarP(&gs.Flags.ConfigFilePath, "config", "c", gs.Flags.ConfigFilePath, "JSON config file") // And we also need to explicitly set the default value for the usage message here, so things // like `K6_CONFIG="blah" k6 run -h` don't produce a weird usage message - flags.Lookup("config").DefValue = gs.defaultFlags.configFilePath + flags.Lookup("config").DefValue = gs.DefaultFlags.ConfigFilePath must(cobra.MarkFlagFilename(flags, "config")) - flags.BoolVar(&gs.flags.noColor, "no-color", gs.flags.noColor, "disable colored output") - flags.Lookup("no-color").DefValue = strconv.FormatBool(gs.defaultFlags.noColor) + flags.BoolVar(&gs.Flags.NoColor, "no-color", gs.Flags.NoColor, "disable colored output") + flags.Lookup("no-color").DefValue = strconv.FormatBool(gs.DefaultFlags.NoColor) // TODO: support configuring these through environment variables as well? // either with croconf or through the hack above... - flags.BoolVarP(&gs.flags.verbose, "verbose", "v", gs.defaultFlags.verbose, "enable verbose logging") - flags.BoolVarP(&gs.flags.quiet, "quiet", "q", gs.defaultFlags.quiet, "disable progress updates") - flags.StringVarP(&gs.flags.address, "address", "a", gs.defaultFlags.address, "address for the REST API server") + flags.BoolVarP(&gs.Flags.Verbose, "verbose", "v", gs.DefaultFlags.Verbose, "enable verbose logging") + flags.BoolVarP(&gs.Flags.Quiet, "quiet", "q", gs.DefaultFlags.Quiet, "disable progress updates") + flags.StringVarP(&gs.Flags.Address, "address", "a", gs.DefaultFlags.Address, "address for the REST API server") return flags } @@ -362,60 +198,60 @@ func (c *rootCommand) setupLoggers() (<-chan struct{}, error) { ch := make(chan struct{}) close(ch) - if c.globalState.flags.verbose { - c.globalState.logger.SetLevel(logrus.DebugLevel) + if c.globalState.Flags.Verbose { + c.globalState.Logger.SetLevel(logrus.DebugLevel) } loggerForceColors := false // disable color by default - switch line := c.globalState.flags.logOutput; { + switch line := c.globalState.Flags.LogOutput; { case line == "stderr": - loggerForceColors = !c.globalState.flags.noColor && c.globalState.stdErr.isTTY - c.globalState.logger.SetOutput(c.globalState.stdErr) + loggerForceColors = !c.globalState.Flags.NoColor && c.globalState.Stderr.IsTTY + c.globalState.Logger.SetOutput(c.globalState.Stderr) case line == "stdout": - loggerForceColors = !c.globalState.flags.noColor && c.globalState.stdOut.isTTY - c.globalState.logger.SetOutput(c.globalState.stdOut) + loggerForceColors = !c.globalState.Flags.NoColor && c.globalState.Stdout.IsTTY + c.globalState.Logger.SetOutput(c.globalState.Stdout) case line == "none": - c.globalState.logger.SetOutput(ioutil.Discard) + c.globalState.Logger.SetOutput(ioutil.Discard) case strings.HasPrefix(line, "loki"): ch = make(chan struct{}) // TODO: refactor, get it from the constructor - hook, err := log.LokiFromConfigLine(c.globalState.ctx, c.globalState.fallbackLogger, line, ch) + hook, err := log.LokiFromConfigLine(c.globalState.Ctx, c.globalState.FallbackLogger, line, ch) if err != nil { return nil, err } - c.globalState.logger.AddHook(hook) - c.globalState.logger.SetOutput(ioutil.Discard) // don't output to anywhere else - c.globalState.flags.logFormat = "raw" + c.globalState.Logger.AddHook(hook) + c.globalState.Logger.SetOutput(ioutil.Discard) // don't output to anywhere else + c.globalState.Flags.LogFormat = "raw" case strings.HasPrefix(line, "file"): ch = make(chan struct{}) // TODO: refactor, get it from the constructor hook, err := log.FileHookFromConfigLine( - c.globalState.ctx, c.globalState.fs, c.globalState.getwd, - c.globalState.fallbackLogger, line, ch, + c.globalState.Ctx, c.globalState.FS, c.globalState.Getwd, + c.globalState.FallbackLogger, line, ch, ) if err != nil { return nil, err } - c.globalState.logger.AddHook(hook) - c.globalState.logger.SetOutput(ioutil.Discard) + c.globalState.Logger.AddHook(hook) + c.globalState.Logger.SetOutput(ioutil.Discard) default: return nil, fmt.Errorf("unsupported log output '%s'", line) } - switch c.globalState.flags.logFormat { + switch c.globalState.Flags.LogFormat { case "raw": - c.globalState.logger.SetFormatter(&RawFormatter{}) - c.globalState.logger.Debug("Logger format: RAW") + c.globalState.Logger.SetFormatter(&RawFormatter{}) + c.globalState.Logger.Debug("Logger format: RAW") case "json": - c.globalState.logger.SetFormatter(&logrus.JSONFormatter{}) - c.globalState.logger.Debug("Logger format: JSON") + c.globalState.Logger.SetFormatter(&logrus.JSONFormatter{}) + c.globalState.Logger.Debug("Logger format: JSON") default: - c.globalState.logger.SetFormatter(&logrus.TextFormatter{ - ForceColors: loggerForceColors, DisableColors: c.globalState.flags.noColor, + c.globalState.Logger.SetFormatter(&logrus.TextFormatter{ + ForceColors: loggerForceColors, DisableColors: c.globalState.Flags.NoColor, }) - c.globalState.logger.Debug("Logger format: TEXT") + c.globalState.Logger.Debug("Logger format: TEXT") } return ch, nil } diff --git a/cmd/root_test.go b/cmd/root_test.go index 452ad247132..f928fddb163 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,22 +2,15 @@ package cmd import ( "bytes" - "context" "fmt" - "net" "net/http" "os" - "os/signal" - "runtime" - "strconv" - "sync" "sync/atomic" "testing" "github.com/sirupsen/logrus" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib/testutils" "go.uber.org/goleak" ) @@ -73,123 +66,26 @@ func TestMain(m *testing.M) { exitCode = m.Run() } -type globalTestState struct { - *globalState - cancel func() - - stdOut, stdErr *bytes.Buffer - loggerHook *testutils.SimpleLogrusHook - - cwd string - - expectedExitCode int -} - -var portRangeStart uint64 = 6565 //nolint:gochecknoglobals - -func getFreeBindAddr(t *testing.T) string { - for i := 0; i < 100; i++ { - port := atomic.AddUint64(&portRangeStart, 1) - addr := net.JoinHostPort("localhost", strconv.FormatUint(port, 10)) - - listener, err := net.Listen("tcp", addr) - if err != nil { - continue // port was busy for some reason - } - defer func() { - assert.NoError(t, listener.Close()) - }() - return addr - } - t.Fatal("could not get a free port") - return "" -} - -func newGlobalTestState(t *testing.T) *globalTestState { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - fs := &afero.MemMapFs{} - cwd := "/test/" - if runtime.GOOS == "windows" { - cwd = "c:\\test\\" - } - require.NoError(t, fs.MkdirAll(cwd, 0o755)) - - logger := logrus.New() - logger.SetLevel(logrus.InfoLevel) - logger.Out = testutils.NewTestOutput(t) - hook := &testutils.SimpleLogrusHook{HookedLevels: logrus.AllLevels} - logger.AddHook(hook) - - ts := &globalTestState{ - cwd: cwd, - cancel: cancel, - loggerHook: hook, - stdOut: new(bytes.Buffer), - stdErr: new(bytes.Buffer), - } - - osExitCalled := false - defaultOsExitHandle := func(exitCode int) { - cancel() - osExitCalled = true - assert.Equal(t, ts.expectedExitCode, exitCode) - } - - t.Cleanup(func() { - if ts.expectedExitCode > 0 { - // Ensure that, if we expected to receive an error, our `os.Exit()` mock - // function was actually called. - assert.Truef(t, osExitCalled, "expected exit code %d, but the os.Exit() mock was not called", ts.expectedExitCode) - } - }) - - outMutex := &sync.Mutex{} - defaultFlags := getDefaultFlags(".config") - defaultFlags.address = getFreeBindAddr(t) - - ts.globalState = &globalState{ - ctx: ctx, - fs: fs, - getwd: func() (string, error) { return ts.cwd, nil }, - args: []string{}, - envVars: map[string]string{"K6_NO_USAGE_REPORT": "true"}, - defaultFlags: defaultFlags, - flags: defaultFlags, - outMutex: outMutex, - stdOut: &consoleWriter{nil, ts.stdOut, false, outMutex, nil}, - stdErr: &consoleWriter{nil, ts.stdErr, false, outMutex, nil}, - stdIn: new(bytes.Buffer), - osExit: defaultOsExitHandle, - signalNotify: signal.Notify, - signalStop: signal.Stop, - logger: logger, - fallbackLogger: testutils.NewLogger(t).WithField("fallback", true), - } - return ts -} - func TestDeprecatedOptionWarning(t *testing.T) { t.Parallel() - ts := newGlobalTestState(t) - ts.args = []string{"k6", "--logformat", "json", "run", "-"} - ts.stdIn = bytes.NewBuffer([]byte(` + ts := state.NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "--logformat", "json", "run", "-"} + ts.Stdin = bytes.NewBuffer([]byte(` console.log('foo'); export default function() { console.log('bar'); }; `)) - newRootCommand(ts.globalState).execute() + newRootCommand(ts.GlobalState).execute() - logMsgs := ts.loggerHook.Drain() + logMsgs := ts.LoggerHook.Drain() assert.True(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "foo")) assert.True(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "bar")) - assert.Contains(t, ts.stdErr.String(), `"level":"info","msg":"foo","source":"console"`) - assert.Contains(t, ts.stdErr.String(), `"level":"info","msg":"bar","source":"console"`) + assert.Contains(t, ts.Stderr.String(), `"level":"info","msg":"foo","source":"console"`) + assert.Contains(t, ts.Stderr.String(), `"level":"info","msg":"bar","source":"console"`) // TODO: after we get rid of cobra, actually emit this message to stderr // and, ideally, through the log, not just print it... assert.False(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "logformat")) - assert.Contains(t, ts.stdOut.String(), `--logformat has been deprecated`) + assert.Contains(t, ts.Stdout.String(), `--logformat has been deprecated`) } diff --git a/cmd/run.go b/cmd/run.go index 67f7456c15d..fac3c66e17a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/pflag" "go.k6.io/k6/api" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/core" "go.k6.io/k6/core/local" "go.k6.io/k6/errext" @@ -30,7 +31,7 @@ import ( // cmdRun handles the `k6 run` sub-command type cmdRun struct { - gs *globalState + gs *state.GlobalState } // TODO: split apart some more @@ -61,7 +62,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { // - The globalCtx is cancelled only after we're completely done with the // test execution and any --linger has been cleared, so that the Engine // can start winding down its metrics processing. - globalCtx, globalCancel := context.WithCancel(c.gs.ctx) + globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() lingerCtx, lingerCancel := context.WithCancel(globalCtx) defer lingerCancel() @@ -115,7 +116,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { } // Spin up the REST API server, if not disabled. - if c.gs.flags.address != "" { + if c.gs.Flags.Address != "" { //nolint:nestif initBar.Modify(pb.WithConstProgress(0, "Init API server")) apiWG := &sync.WaitGroup{} @@ -126,15 +127,15 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { defer srvCancel() // TODO: send the ExecutionState and MetricsEngine instead of the Engine - srv := api.GetServer(c.gs.flags.address, engine, logger) + srv := api.GetServer(c.gs.Flags.Address, engine, logger) go func() { defer apiWG.Done() - logger.Debugf("Starting the REST API server on %s", c.gs.flags.address) + logger.Debugf("Starting the REST API server on %s", c.gs.Flags.Address) if aerr := srv.ListenAndServe(); aerr != nil && !errors.Is(aerr, http.ErrServerClosed) { // Only exit k6 if the user has explicitly set the REST API address if cmd.Flags().Lookup("address").Changed { logger.WithError(aerr).Error("Error from API server") - c.gs.osExit(int(exitcodes.CannotStartRESTAPI)) + c.gs.OSExit(int(exitcodes.CannotStartRESTAPI)) } else { logger.WithError(aerr).Warn("Error from API server") } @@ -229,15 +230,15 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { Metrics: engine.MetricsEngine.ObservedMetrics, RootGroup: execScheduler.GetRunner().GetDefaultGroup(), TestRunDuration: executionState.GetCurrentTestRunDuration(), - NoColor: c.gs.flags.noColor, + NoColor: c.gs.Flags.NoColor, UIState: lib.UIState{ - IsStdOutTTY: c.gs.stdOut.isTTY, - IsStdErrTTY: c.gs.stdErr.isTTY, + IsStdOutTTY: c.gs.Stdout.IsTTY, + IsStdErrTTY: c.gs.Stderr.IsTTY, }, }) engine.MetricsEngine.MetricsLock.Unlock() if hsErr == nil { - hsErr = handleSummaryResult(c.gs.fs, c.gs.stdOut, c.gs.stdErr, summaryResult) + hsErr = handleSummaryResult(c.gs.FS, c.gs.Stdout, c.gs.Stderr, summaryResult) } if hsErr != nil { logger.WithError(hsErr).Error("failed to handle the end-of-test summary") @@ -250,7 +251,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { // do nothing, we were interrupted by Ctrl+C already default: logger.Debug("Linger set; waiting for Ctrl+C...") - if !c.gs.flags.quiet { + if !c.gs.Flags.Quiet { printToStdout(c.gs, "Linger set; waiting for Ctrl+C...") } <-lingerCtx.Done() @@ -287,7 +288,7 @@ func (c *cmdRun) flagSet() *pflag.FlagSet { return flags } -func getCmdRun(gs *globalState) *cobra.Command { +func getCmdRun(gs *state.GlobalState) *cobra.Command { c := &cmdRun{ gs: gs, } diff --git a/cmd/run_test.go b/cmd/run_test.go index f7b041f60c9..8b4090d9ee3 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib/fsext" @@ -200,14 +201,14 @@ func TestRunScriptErrorsAndAbort(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) - testState.args = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) + ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() - logs := testState.loggerHook.Drain() + logs := ts.LoggerHook.Drain() if tc.expErr != "" { assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, tc.expErr)) @@ -255,12 +256,12 @@ func TestInvalidOptionsThresholdErrExitCode(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) - testState.args = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) + ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() }) } } @@ -305,20 +306,20 @@ func TestThresholdsRuntimeBehavior(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) - testState.args = []string{"k6", "run", tc.testFilename} - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.CmdArgs = []string{"k6", "run", tc.testFilename} + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() if tc.expStdoutContains != "" { - assert.Contains(t, testState.stdOut.String(), tc.expStdoutContains) + assert.Contains(t, ts.Stdout.String(), tc.expStdoutContains) } if tc.expStdoutNotContains != "" { - log.Println(testState.stdOut.String()) - assert.NotContains(t, testState.stdOut.String(), tc.expStdoutNotContains) + log.Println(ts.Stdout.String()) + assert.NotContains(t, ts.Stdout.String(), tc.expStdoutNotContains) } }) } diff --git a/cmd/runtime_options.go b/cmd/runtime_options.go index 2a62afc8030..78e2f36ee7d 100644 --- a/cmd/runtime_options.go +++ b/cmd/runtime_options.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" ) @@ -113,7 +114,7 @@ func getRuntimeOptions(flags *pflag.FlagSet, environment map[string]string) (lib return opts, err } for _, kv := range envVars { - k, v := parseEnvKeyValue(kv) + k, v := state.ParseEnvKeyValue(kv) // Allow only alphanumeric ASCII variable names for now if !userEnvVarName.MatchString(k) { return opts, fmt.Errorf("invalid environment variable name '%s'", k) diff --git a/cmd/runtime_options_test.go b/cmd/runtime_options_test.go index c339eabc5f4..81b6ebe949f 100644 --- a/cmd/runtime_options_test.go +++ b/cmd/runtime_options_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" @@ -58,21 +59,21 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/script.js", jsCode.Bytes(), 0o644)) - ts := newGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test + ts := state.NewGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test registry := metrics.NewRegistry() test := &loadedTest{ sourceRootPath: "script.js", source: &loader.SourceData{Data: jsCode.Bytes(), URL: &url.URL{Path: "/script.js", Scheme: "file"}}, fileSystems: map[string]afero.Fs{"file": fs}, preInitState: &lib.TestPreInitState{ - Logger: ts.logger, + Logger: ts.Logger, RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), }, } - require.NoError(t, test.initializeFirstRunner(ts.globalState)) + require.NoError(t, test.initializeFirstRunner(ts.GlobalState)) archive := test.initRunner.MakeArchive() archiveBuf := &bytes.Buffer{} @@ -84,7 +85,7 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { source: &loader.SourceData{Data: archiveBuf.Bytes(), URL: &url.URL{Path: "/script.tar", Scheme: "file"}}, fileSystems: map[string]afero.Fs{"file": fs}, preInitState: &lib.TestPreInitState{ - Logger: ts.logger, + Logger: ts.Logger, RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), @@ -93,11 +94,11 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { } archTest := getRunnerErr(lib.RuntimeOptions{}) - require.NoError(t, archTest.initializeFirstRunner(ts.globalState)) + require.NoError(t, archTest.initializeFirstRunner(ts.GlobalState)) for key, val := range tc.expRTOpts.Env { archTest = getRunnerErr(lib.RuntimeOptions{Env: map[string]string{key: "almost " + val}}) - require.NoError(t, archTest.initializeFirstRunner(ts.globalState)) + require.NoError(t, archTest.initializeFirstRunner(ts.GlobalState)) assert.Equal(t, archTest.initRunner.MakeArchive().Env[key], "almost "+val) } } diff --git a/cmd/scale.go b/cmd/scale.go index 0da23c3f424..b64e14b4f84 100644 --- a/cmd/scale.go +++ b/cmd/scale.go @@ -7,9 +7,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdScale(globalState *globalState) *cobra.Command { +func getCmdScale(gs *state.GlobalState) *cobra.Command { // scaleCmd represents the scale command scaleCmd := &cobra.Command{ Use: "scale", @@ -24,16 +25,16 @@ func getCmdScale(globalState *globalState) *cobra.Command { return errors.New("Specify either -u/--vus or -m/--max") //nolint:golint,stylecheck } - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{VUs: vus, VUsMax: max}) + status, err := c.SetStatus(gs.Ctx, v1.Status{VUs: vus, VUsMax: max}) if err != nil { return err } - return yamlPrint(globalState.stdOut, status) + return yamlPrint(gs.Stdout, status) }, } diff --git a/cmd/state/doc.go b/cmd/state/doc.go new file mode 100644 index 00000000000..425dc09243d --- /dev/null +++ b/cmd/state/doc.go @@ -0,0 +1,4 @@ +// Package state contains the types and functionality used for keeping track of +// cmd-related values that are used globally throughout k6. It also exposes some +// related test types and helpers. +package state diff --git a/cmd/state/env.go b/cmd/state/env.go new file mode 100644 index 00000000000..147b763b0c6 --- /dev/null +++ b/cmd/state/env.go @@ -0,0 +1,22 @@ +package state + +import "strings" + +// ParseEnvKeyValue splits an environment variable string into key and value. +func ParseEnvKeyValue(kv string) (string, string) { + if idx := strings.IndexRune(kv, '='); idx != -1 { + return kv[:idx], kv[idx+1:] + } + return kv, "" +} + +// BuildEnvMap returns a map from raw environment values, such as returned from +// os.Environ(). +func BuildEnvMap(environ []string) map[string]string { + env := make(map[string]string, len(environ)) + for _, kv := range environ { + k, v := ParseEnvKeyValue(kv) + env[k] = v + } + return env +} diff --git a/cmd/state/state.go b/cmd/state/state.go new file mode 100644 index 00000000000..9bb6b908a8f --- /dev/null +++ b/cmd/state/state.go @@ -0,0 +1,167 @@ +package state + +import ( + "context" + "io" + "os" + "os/signal" + "path/filepath" + "sync" + + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "go.k6.io/k6/ui/console" +) + +const defaultConfigFileName = "config.json" + +// GlobalState contains the GlobalFlags and accessors for most of the global +// process-external state like CLI arguments, env vars, standard input, output +// and error, etc. In practice, most of it is normally accessed through the `os` +// package from the Go stdlib. +// +// We group them here so we can prevent direct access to them from the rest of +// the k6 codebase. This gives us the ability to mock them and have robust and +// easy-to-write integration-like tests to check the k6 end-to-end behavior in +// any simulated conditions. +// +// `NewGlobalState()` returns a globalState object with the real `os` +// parameters, while `NewGlobalTestState()` can be used in tests to create +// simulated environments. +type GlobalState struct { + Ctx context.Context + + FS afero.Fs + Getwd func() (string, error) + CmdArgs []string + Env map[string]string + + DefaultFlags, Flags GlobalFlags + + OutMutex *sync.Mutex + Stdout, Stderr *console.Writer + Stdin io.Reader + + OSExit func(int) + SignalNotify func(chan<- os.Signal, ...os.Signal) + SignalStop func(chan<- os.Signal) + + Logger *logrus.Logger + FallbackLogger logrus.FieldLogger +} + +// NewGlobalState returns a new GlobalState with the given ctx. +// Ideally, this should be the only function in the whole codebase where we use +// global variables and functions from the os package. Anywhere else, things +// like os.Stdout, os.Stderr, os.Stdin, os.Getenv(), etc. should be removed and +// the respective properties of globalState used instead. +func NewGlobalState(ctx context.Context) *GlobalState { + isDumbTerm := os.Getenv("TERM") == "dumb" + stdoutTTY := !isDumbTerm && (isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())) + stderrTTY := !isDumbTerm && (isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd())) + outMutex := &sync.Mutex{} + stdout := &console.Writer{ + RawOut: os.Stdout, + Mutex: outMutex, + Writer: colorable.NewColorable(os.Stdout), + IsTTY: stdoutTTY, + } + stderr := &console.Writer{ + RawOut: os.Stderr, + Mutex: outMutex, + Writer: colorable.NewColorable(os.Stderr), + IsTTY: stderrTTY, + } + + env := BuildEnvMap(os.Environ()) + _, noColorsSet := env["NO_COLOR"] // even empty values disable colors + logger := &logrus.Logger{ + Out: stderr, + Formatter: &logrus.TextFormatter{ + ForceColors: stderrTTY, + DisableColors: !stderrTTY || noColorsSet || env["K6_NO_COLOR"] != "", + }, + Hooks: make(logrus.LevelHooks), + Level: logrus.InfoLevel, + } + + confDir, err := os.UserConfigDir() + if err != nil { + confDir = ".config" + } + + defaultFlags := GetDefaultFlags(confDir) + + return &GlobalState{ + Ctx: ctx, + FS: afero.NewOsFs(), + Getwd: os.Getwd, + CmdArgs: os.Args, + Env: env, + DefaultFlags: defaultFlags, + Flags: getFlags(defaultFlags, env), + OutMutex: outMutex, + Stdout: stdout, + Stderr: stderr, + Stdin: os.Stdin, + OSExit: os.Exit, + SignalNotify: signal.Notify, + SignalStop: signal.Stop, + Logger: logger, + FallbackLogger: &logrus.Logger{ // we may modify the other one + Out: stderr, + Formatter: new(logrus.TextFormatter), // no fancy formatting here + Hooks: make(logrus.LevelHooks), + Level: logrus.InfoLevel, + }, + } +} + +// GlobalFlags contains global config values that apply for all k6 sub-commands. +type GlobalFlags struct { + ConfigFilePath string + Quiet bool + NoColor bool + Address string + LogOutput string + LogFormat string + Verbose bool +} + +// GetDefaultFlags returns the default global flags. +func GetDefaultFlags(homeDir string) GlobalFlags { + return GlobalFlags{ + Address: "localhost:6565", + ConfigFilePath: filepath.Join(homeDir, "loadimpact", "k6", defaultConfigFileName), + LogOutput: "stderr", + } +} + +func getFlags(defaultFlags GlobalFlags, env map[string]string) GlobalFlags { + result := defaultFlags + + // TODO: add env vars for the rest of the values (after adjusting + // rootCmdPersistentFlagSet(), of course) + + if val, ok := env["K6_CONFIG"]; ok { + result.ConfigFilePath = val + } + if val, ok := env["K6_LOG_OUTPUT"]; ok { + result.LogOutput = val + } + if val, ok := env["K6_LOG_FORMAT"]; ok { + result.LogFormat = val + } + if env["K6_NO_COLOR"] != "" { + result.NoColor = true + } + // Support https://no-color.org/, even an empty value should disable the + // color output from k6. + if _, ok := env["NO_COLOR"]; ok { + result.NoColor = true + } + return result +} diff --git a/cmd/state/test_state.go b/cmd/state/test_state.go new file mode 100644 index 00000000000..8e980d35c53 --- /dev/null +++ b/cmd/state/test_state.go @@ -0,0 +1,155 @@ +package state + +import ( + "bytes" + "context" + "io" + "net" + "os/signal" + "runtime" + "strconv" + "sync" + "sync/atomic" + "testing" + + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.k6.io/k6/lib/testutils" + "go.k6.io/k6/ui/console" +) + +// GlobalTestState is a wrapper around GlobalState for use in tests. +type GlobalTestState struct { + *GlobalState + Cancel func() + + Stdout, Stderr *bytes.Buffer + LoggerHook *testutils.SimpleLogrusHook + + Cwd string + + ExpectedExitCode int +} + +// NewGlobalTestState returns an initialized GlobalTestState, mocking all +// GlobalState fields for use in tests. +func NewGlobalTestState(t *testing.T) *GlobalTestState { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + fs := &afero.MemMapFs{} + cwd := "/test/" // TODO: Make this relative to the test? + if runtime.GOOS == "windows" { + cwd = "c:\\test\\" + } + require.NoError(t, fs.MkdirAll(cwd, 0o755)) + + logger := logrus.New() + logger.SetLevel(logrus.InfoLevel) + logger.Out = testutils.NewTestOutput(t) + hook := &testutils.SimpleLogrusHook{HookedLevels: logrus.AllLevels} + logger.AddHook(hook) + + ts := &GlobalTestState{ + Cwd: cwd, + Cancel: cancel, + LoggerHook: hook, + Stdout: new(bytes.Buffer), + Stderr: new(bytes.Buffer), + } + + osExitCalled := false + defaultOsExitHandle := func(exitCode int) { + cancel() + osExitCalled = true + assert.Equal(t, ts.ExpectedExitCode, exitCode) + } + + t.Cleanup(func() { + if ts.ExpectedExitCode > 0 { + // Ensure that, if we expected to receive an error, our `os.Exit()` mock + // function was actually called. + assert.Truef(t, + osExitCalled, + "expected exit code %d, but the os.Exit() mock was not called", + ts.ExpectedExitCode, + ) + } + }) + + outMutex := &sync.Mutex{} + defaultFlags := GetDefaultFlags(".config") + defaultFlags.Address = getFreeBindAddr(t) + + ts.GlobalState = &GlobalState{ + Ctx: ctx, + FS: fs, + Getwd: func() (string, error) { return ts.Cwd, nil }, + CmdArgs: []string{}, + Env: map[string]string{"K6_NO_USAGE_REPORT": "true"}, + DefaultFlags: defaultFlags, + Flags: defaultFlags, + OutMutex: outMutex, + Stdout: &console.Writer{ + Mutex: outMutex, + Writer: ts.Stdout, + IsTTY: false, + }, + Stderr: &console.Writer{ + Mutex: outMutex, + Writer: ts.Stderr, + IsTTY: false, + }, + Stdin: new(bytes.Buffer), + OSExit: defaultOsExitHandle, + SignalNotify: signal.Notify, + SignalStop: signal.Stop, + Logger: logger, + FallbackLogger: testutils.NewLogger(t).WithField("fallback", true), + } + + return ts +} + +// TestOSFileW is the mock implementation of stdout/stderr. +type TestOSFileW struct { + io.Writer +} + +// Fd returns a mock file descriptor ID. +func (f *TestOSFileW) Fd() uintptr { + return 0 +} + +// TestOSFileR is the mock implementation of stdin. +type TestOSFileR struct { + io.Reader +} + +// Fd returns a mock file descriptor ID. +func (f *TestOSFileR) Fd() uintptr { + return 0 +} + +var portRangeStart uint64 = 6565 //nolint:gochecknoglobals + +func getFreeBindAddr(t *testing.T) string { + for i := 0; i < 100; i++ { + port := atomic.AddUint64(&portRangeStart, 1) + addr := net.JoinHostPort("localhost", strconv.FormatUint(port, 10)) + + listener, err := net.Listen("tcp", addr) + if err != nil { + continue // port was busy for some reason + } + defer func() { + assert.NoError(t, listener.Close()) + }() + return addr + } + + t.Fatal("could not get a free port") + return "" +} diff --git a/cmd/stats.go b/cmd/stats.go index 8d61832fac1..5b91c731149 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -4,9 +4,10 @@ import ( "github.com/spf13/cobra" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdStats(globalState *globalState) *cobra.Command { +func getCmdStats(gs *state.GlobalState) *cobra.Command { // statsCmd represents the stats command statsCmd := &cobra.Command{ Use: "stats", @@ -15,16 +16,16 @@ func getCmdStats(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - metrics, err := c.Metrics(globalState.ctx) + metrics, err := c.Metrics(gs.Ctx) if err != nil { return err } - return yamlPrint(globalState.stdOut, metrics) + return yamlPrint(gs.Stdout, metrics) }, } return statsCmd diff --git a/cmd/status.go b/cmd/status.go index 69a66d19e54..39490ab0c15 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -4,9 +4,10 @@ import ( "github.com/spf13/cobra" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdStatus(globalState *globalState) *cobra.Command { +func getCmdStatus(gs *state.GlobalState) *cobra.Command { // statusCmd represents the status command statusCmd := &cobra.Command{ Use: "status", @@ -15,16 +16,16 @@ func getCmdStatus(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.Status(globalState.ctx) + status, err := c.Status(gs.Ctx) if err != nil { return err } - return yamlPrint(globalState.stdOut, status) + return yamlPrint(gs.Stdout, status) }, } return statusCmd diff --git a/cmd/test_load.go b/cmd/test_load.go index 510a27cec82..71014534ff7 100644 --- a/cmd/test_load.go +++ b/cmd/test_load.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/js" @@ -40,32 +41,32 @@ type loadedTest struct { keyLogger io.Closer } -func loadTest(gs *globalState, cmd *cobra.Command, args []string) (*loadedTest, error) { +func loadTest(gs *state.GlobalState, cmd *cobra.Command, args []string) (*loadedTest, error) { if len(args) < 1 { return nil, fmt.Errorf("k6 needs at least one argument to load the test") } sourceRootPath := args[0] - gs.logger.Debugf("Resolving and reading test '%s'...", sourceRootPath) + gs.Logger.Debugf("Resolving and reading test '%s'...", sourceRootPath) src, fileSystems, pwd, err := readSource(gs, sourceRootPath) if err != nil { return nil, err } resolvedPath := src.URL.String() - gs.logger.Debugf( + gs.Logger.Debugf( "'%s' resolved to '%s' and successfully loaded %d bytes!", sourceRootPath, resolvedPath, len(src.Data), ) - gs.logger.Debugf("Gathering k6 runtime options...") - runtimeOptions, err := getRuntimeOptions(cmd.Flags(), gs.envVars) + gs.Logger.Debugf("Gathering k6 runtime options...") + runtimeOptions, err := getRuntimeOptions(cmd.Flags(), gs.Env) if err != nil { return nil, err } registry := metrics.NewRegistry() state := &lib.TestPreInitState{ - Logger: gs.logger, + Logger: gs.Logger, RuntimeOptions: runtimeOptions, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), @@ -75,22 +76,22 @@ func loadTest(gs *globalState, cmd *cobra.Command, args []string) (*loadedTest, pwd: pwd, sourceRootPath: sourceRootPath, source: src, - fs: gs.fs, + fs: gs.FS, fileSystems: fileSystems, preInitState: state, } - gs.logger.Debugf("Initializing k6 runner for '%s' (%s)...", sourceRootPath, resolvedPath) + gs.Logger.Debugf("Initializing k6 runner for '%s' (%s)...", sourceRootPath, resolvedPath) if err := test.initializeFirstRunner(gs); err != nil { return nil, fmt.Errorf("could not initialize '%s': %w", sourceRootPath, err) } - gs.logger.Debug("Runner successfully initialized!") + gs.Logger.Debug("Runner successfully initialized!") return test, nil } -func (lt *loadedTest) initializeFirstRunner(gs *globalState) error { +func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { testPath := lt.source.URL.String() - logger := gs.logger.WithField("test_path", testPath) + logger := gs.Logger.WithField("test_path", testPath) testType := lt.preInitState.RuntimeOptions.TestType.String if testType == "" { @@ -154,14 +155,14 @@ func (lt *loadedTest) initializeFirstRunner(gs *globalState) error { // readSource is a small wrapper around loader.ReadSource returning // result of the load and filesystems map -func readSource(globalState *globalState, filename string) (*loader.SourceData, map[string]afero.Fs, string, error) { - pwd, err := globalState.getwd() +func readSource(gs *state.GlobalState, filename string) (*loader.SourceData, map[string]afero.Fs, string, error) { + pwd, err := gs.Getwd() if err != nil { return nil, nil, "", err } - filesystems := loader.CreateFilesystems(globalState.fs) - src, err := loader.ReadSource(globalState.logger, filename, pwd, filesystems, globalState.stdIn) + filesystems := loader.CreateFilesystems(gs.FS) + src, err := loader.ReadSource(gs.Logger, filename, pwd, filesystems, gs.Stdin) return src, filesystems, pwd, err } @@ -173,12 +174,12 @@ func detectTestType(data []byte) string { } func (lt *loadedTest) consolidateDeriveAndValidateConfig( - gs *globalState, cmd *cobra.Command, + gs *state.GlobalState, cmd *cobra.Command, cliConfGetter func(flags *pflag.FlagSet) (Config, error), // TODO: obviate ) (*loadedAndConfiguredTest, error) { var cliConfig Config if cliConfGetter != nil { - gs.logger.Debug("Parsing CLI flags...") + gs.Logger.Debug("Parsing CLI flags...") var err error cliConfig, err = cliConfGetter(cmd.Flags()) if err != nil { @@ -186,13 +187,13 @@ func (lt *loadedTest) consolidateDeriveAndValidateConfig( } } - gs.logger.Debug("Consolidating config layers...") + gs.Logger.Debug("Consolidating config layers...") consolidatedConfig, err := getConsolidatedConfig(gs, cliConfig, lt.initRunner.GetOptions()) if err != nil { return nil, err } - gs.logger.Debug("Parsing thresholds and validating config...") + gs.Logger.Debug("Parsing thresholds and validating config...") // Parse the thresholds, only if the --no-threshold flag is not set. // If parsing the threshold expressions failed, consider it as an // invalid configuration error. @@ -210,7 +211,7 @@ func (lt *loadedTest) consolidateDeriveAndValidateConfig( } } - derivedConfig, err := deriveAndValidateConfig(consolidatedConfig, lt.initRunner.IsExecutable, gs.logger) + derivedConfig, err := deriveAndValidateConfig(consolidatedConfig, lt.initRunner.IsExecutable, gs.Logger) if err != nil { return nil, err } @@ -231,7 +232,7 @@ type loadedAndConfiguredTest struct { } func loadAndConfigureTest( - gs *globalState, cmd *cobra.Command, args []string, + gs *state.GlobalState, cmd *cobra.Command, args []string, cliConfigGetter func(flags *pflag.FlagSet) (Config, error), ) (*loadedAndConfiguredTest, error) { test, err := loadTest(gs, cmd, args) diff --git a/cmd/ui.go b/cmd/ui.go index b492370b9ba..b8db2f1be55 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -1,7 +1,6 @@ package cmd import ( - "bytes" "context" "fmt" "io" @@ -17,6 +16,7 @@ import ( "gopkg.in/yaml.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/lib/consts" "go.k6.io/k6/output" @@ -32,38 +32,6 @@ const ( defaultTermWidth = 80 ) -// A writer that syncs writes with a mutex and, if the output is a TTY, clears before newlines. -type consoleWriter struct { - rawOut *os.File - writer io.Writer - isTTY bool - mutex *sync.Mutex - - // Used for flicker-free persistent objects like the progressbars - persistentText func() -} - -func (w *consoleWriter) Write(p []byte) (n int, err error) { - origLen := len(p) - if w.isTTY { - // Add a TTY code to erase till the end of line with each new line - // TODO: check how cross-platform this is... - p = bytes.ReplaceAll(p, []byte{'\n'}, []byte{'\x1b', '[', '0', 'K', '\n'}) - } - - w.mutex.Lock() - n, err = w.writer.Write(p) - if w.persistentText != nil { - w.persistentText() - } - w.mutex.Unlock() - - if err != nil && n < origLen { - return n, err - } - return origLen, err -} - // getColor returns the requested color, or an uncolored object, depending on // the value of noColor. The explicit EnableColor() and DisableColor() are // needed because the library checks os.Stdout itself otherwise... @@ -84,20 +52,20 @@ func getBanner(noColor bool) string { return c.Sprint(consts.Banner()) } -func printBanner(gs *globalState) { - if gs.flags.quiet { +func printBanner(gs *state.GlobalState) { + if gs.Flags.Quiet { return // do not print banner when --quiet is enabled } - banner := getBanner(gs.flags.noColor || !gs.stdOut.isTTY) - _, err := fmt.Fprintf(gs.stdOut, "\n%s\n\n", banner) + banner := getBanner(gs.Flags.NoColor || !gs.Stdout.IsTTY) + _, err := fmt.Fprintf(gs.Stdout, "\n%s\n\n", banner) if err != nil { - gs.logger.Warnf("could not print k6 banner message to stdout: %s", err.Error()) + gs.Logger.Warnf("could not print k6 banner message to stdout: %s", err.Error()) } } -func printBar(gs *globalState, bar *pb.ProgressBar) { - if gs.flags.quiet { +func printBar(gs *state.GlobalState, bar *pb.ProgressBar) { + if gs.Flags.Quiet { return } end := "\n" @@ -105,7 +73,7 @@ func printBar(gs *globalState, bar *pb.ProgressBar) { // stateless... basically first render the left and right parts, so we know // how long the longest line is, and how much space we have for the progress widthDelta := -defaultTermWidth - if gs.stdOut.isTTY { + if gs.Stdout.IsTTY { // If we're in a TTY, instead of printing the bar and going to the next // line, erase everything till the end of the line and return to the // start, so that the next print will overwrite the same line. @@ -119,7 +87,7 @@ func printBar(gs *globalState, bar *pb.ProgressBar) { printToStdout(gs, rendered.String()+end) } -func modifyAndPrintBar(gs *globalState, bar *pb.ProgressBar, options ...pb.ProgressBarOption) { +func modifyAndPrintBar(gs *state.GlobalState, bar *pb.ProgressBar, options ...pb.ProgressBarOption) { bar.Modify(options...) printBar(gs, bar) } @@ -127,10 +95,10 @@ func modifyAndPrintBar(gs *globalState, bar *pb.ProgressBar, options ...pb.Progr // Print execution description for both cloud and local execution. // TODO: Clean this up as part of #1499 or #1427 func printExecutionDescription( - gs *globalState, execution, filename, outputOverride string, conf Config, + gs *state.GlobalState, execution, filename, outputOverride string, conf Config, et *lib.ExecutionTuple, execPlan []lib.ExecutionStep, outputs []output.Output, ) { - noColor := gs.flags.noColor || !gs.stdOut.isTTY + noColor := gs.Flags.NoColor || !gs.Stdout.IsTTY valueColor := getColor(noColor, color.FgCyan) buf := &strings.Builder{} @@ -171,8 +139,8 @@ func printExecutionDescription( } fmt.Fprintf(buf, "\n") - if gs.flags.quiet { - gs.logger.Debug(buf.String()) + if gs.Flags.Quiet { + gs.Logger.Debug(buf.String()) } else { printToStdout(gs, buf.String()) } @@ -273,15 +241,15 @@ func renderMultipleBars( // TODO: don't use global variables... // //nolint:funlen,gocognit -func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, logger *logrus.Logger) { - if gs.flags.quiet { +func showProgress(ctx context.Context, gs *state.GlobalState, pbs []*pb.ProgressBar, logger *logrus.Logger) { + if gs.Flags.Quiet { return } var errTermGetSize bool termWidth := defaultTermWidth - if gs.stdOut.isTTY { - tw, _, err := term.GetSize(int(gs.stdOut.rawOut.Fd())) + if gs.Stdout.IsTTY { + tw, _, err := term.GetSize(int(gs.Stdout.RawOut.Fd())) if !(tw > 0) || err != nil { errTermGetSize = true logger.WithError(err).Warn("error getting terminal size") @@ -305,7 +273,7 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l printProgressBars := func() { progressBarsLastRenderLock.Lock() - _, _ = gs.stdOut.writer.Write(progressBarsLastRender) + _, _ = gs.Stdout.Writer.Write(progressBarsLastRender) progressBarsLastRenderLock.Unlock() } @@ -313,7 +281,7 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l // Default to responsive progress bars when in an interactive terminal renderProgressBars := func(goBack bool) { barText, longestLine := renderMultipleBars( - gs.flags.noColor, gs.stdOut.isTTY, goBack, maxLeft, termWidth, widthDelta, pbs, + gs.Flags.NoColor, gs.Stdout.IsTTY, goBack, maxLeft, termWidth, widthDelta, pbs, ) widthDelta = termWidth - longestLine - termPadding progressBarsLastRenderLock.Lock() @@ -322,10 +290,10 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l } // Otherwise fallback to fixed compact progress bars - if !gs.stdOut.isTTY { + if !gs.Stdout.IsTTY { widthDelta = -pb.DefaultWidth renderProgressBars = func(goBack bool) { - barText, _ := renderMultipleBars(gs.flags.noColor, gs.stdOut.isTTY, goBack, maxLeft, termWidth, widthDelta, pbs) + barText, _ := renderMultipleBars(gs.Flags.NoColor, gs.Stdout.IsTTY, goBack, maxLeft, termWidth, widthDelta, pbs) progressBarsLastRenderLock.Lock() progressBarsLastRender = []byte(barText) progressBarsLastRenderLock.Unlock() @@ -335,26 +303,26 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l // TODO: make configurable? updateFreq := 1 * time.Second var stdoutFD int - if gs.stdOut.isTTY { - stdoutFD = int(gs.stdOut.rawOut.Fd()) + if gs.Stdout.IsTTY { + stdoutFD = int(gs.Stdout.RawOut.Fd()) updateFreq = 100 * time.Millisecond - gs.outMutex.Lock() - gs.stdOut.persistentText = printProgressBars - gs.stdErr.persistentText = printProgressBars - gs.outMutex.Unlock() + gs.OutMutex.Lock() + gs.Stdout.PersistentText = printProgressBars + gs.Stderr.PersistentText = printProgressBars + gs.OutMutex.Unlock() defer func() { - gs.outMutex.Lock() - gs.stdOut.persistentText = nil - gs.stdErr.persistentText = nil - gs.outMutex.Unlock() + gs.OutMutex.Lock() + gs.Stdout.PersistentText = nil + gs.Stderr.PersistentText = nil + gs.OutMutex.Unlock() }() } var winch chan os.Signal if sig := getWinchSignal(); sig != nil { winch = make(chan os.Signal, 10) - gs.signalNotify(winch, sig) - defer gs.signalStop(winch) + gs.SignalNotify(winch, sig) + defer gs.SignalStop(winch) } ticker := time.NewTicker(updateFreq) @@ -363,12 +331,12 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l select { case <-ctxDone: renderProgressBars(false) - gs.outMutex.Lock() + gs.OutMutex.Lock() printProgressBars() - gs.outMutex.Unlock() + gs.OutMutex.Unlock() return case <-winch: - if gs.stdOut.isTTY && !errTermGetSize { + if gs.Stdout.IsTTY && !errTermGetSize { // More responsive progress bar resizing on platforms with SIGWINCH (*nix) tw, _, err := term.GetSize(stdoutFD) if tw > 0 && err == nil { @@ -377,7 +345,7 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l } case <-ticker.C: // Default ticker-based progress bar resizing - if gs.stdOut.isTTY && !errTermGetSize && winch == nil { + if gs.Stdout.IsTTY && !errTermGetSize && winch == nil { tw, _, err := term.GetSize(stdoutFD) if tw > 0 && err == nil { termWidth = tw @@ -385,9 +353,9 @@ func showProgress(ctx context.Context, gs *globalState, pbs []*pb.ProgressBar, l } } renderProgressBars(true) - gs.outMutex.Lock() + gs.OutMutex.Lock() printProgressBars() - gs.outMutex.Unlock() + gs.OutMutex.Unlock() } } diff --git a/cmd/version.go b/cmd/version.go index e9b39a2fa4c..9a9e71d1891 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -5,26 +5,26 @@ import ( "strings" "github.com/spf13/cobra" - + "go.k6.io/k6/cmd/state" "go.k6.io/k6/ext" "go.k6.io/k6/lib/consts" ) -func getCmdVersion(globalState *globalState) *cobra.Command { +func getCmdVersion(gs *state.GlobalState) *cobra.Command { // versionCmd represents the version command. return &cobra.Command{ Use: "version", Short: "Show application version", Long: `Show the application version and exit.`, Run: func(_ *cobra.Command, _ []string) { - printToStdout(globalState, fmt.Sprintf("k6 v%s\n", consts.FullVersion())) + printToStdout(gs, fmt.Sprintf("k6 v%s\n", consts.FullVersion())) if exts := ext.GetAll(); len(exts) > 0 { extsDesc := make([]string, 0, len(exts)) for _, e := range exts { extsDesc = append(extsDesc, fmt.Sprintf(" %s", e.String())) } - printToStdout(globalState, fmt.Sprintf("Extensions:\n%s\n", + printToStdout(gs, fmt.Sprintf("Extensions:\n%s\n", strings.Join(extsDesc, "\n"))) } }, diff --git a/ui/console/doc.go b/ui/console/doc.go new file mode 100644 index 00000000000..5f24698f34a --- /dev/null +++ b/ui/console/doc.go @@ -0,0 +1,2 @@ +// Package console implements the command-line UI for k6. +package console diff --git a/ui/console/writer.go b/ui/console/writer.go new file mode 100644 index 00000000000..9e42e08fe44 --- /dev/null +++ b/ui/console/writer.go @@ -0,0 +1,41 @@ +package console + +import ( + "bytes" + "io" + "os" + "sync" +) + +// Writer syncs writes with a mutex and, if the output is a TTY, clears before +// newlines. +type Writer struct { + RawOut *os.File + Mutex *sync.Mutex + Writer io.Writer + IsTTY bool + + // Used for flicker-free persistent objects like the progressbars + PersistentText func() +} + +func (w *Writer) Write(p []byte) (n int, err error) { + origLen := len(p) + if w.IsTTY { + // Add a TTY code to erase till the end of line with each new line + // TODO: check how cross-platform this is... + p = bytes.ReplaceAll(p, []byte{'\n'}, []byte{'\x1b', '[', '0', 'K', '\n'}) + } + + w.Mutex.Lock() + n, err = w.Writer.Write(p) + if w.PersistentText != nil { + w.PersistentText() + } + w.Mutex.Unlock() + + if err != nil && n < origLen { + return n, err + } + return origLen, err +} From 2378549e62f1298b9965a9a737c8a2aca30e914a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Fri, 16 Dec 2022 13:29:44 +0100 Subject: [PATCH 3/5] Move integration tests from cmd to cmd/tests See https://github.com/grafana/k6/issues/2459 --- cmd/archive_test.go | 8 +- cmd/config_consolidation_test.go | 3 +- cmd/convert_test.go | 8 +- cmd/panic_integration_test.go | 4 +- cmd/root.go | 7 + cmd/root_test.go | 58 +--- cmd/run_test.go | 8 +- cmd/runtime_options_test.go | 4 +- .../cmd_test.go} | 295 +++++++++--------- cmd/tests/doc.go | 6 + cmd/{state => tests}/test_state.go | 30 +- cmd/tests/tests.go | 65 ++++ cmd/tests/tests_test.go | 10 + 13 files changed, 262 insertions(+), 244 deletions(-) rename cmd/{integration_test.go => tests/cmd_test.go} (86%) create mode 100644 cmd/tests/doc.go rename cmd/{state => tests}/test_state.go (86%) create mode 100644 cmd/tests/tests.go create mode 100644 cmd/tests/tests_test.go diff --git a/cmd/archive_test.go b/cmd/archive_test.go index d0db9562ea8..8144fbb86fc 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/require" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/errext/exitcodes" ) @@ -79,7 +79,7 @@ func TestArchiveThresholds(t *testing.T) { testScript, err := ioutil.ReadFile(testCase.testFilename) require.NoError(t, err) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testCase.testFilename), testScript, 0o644)) ts.CmdArgs = []string{"k6", "archive", testCase.testFilename} if testCase.noThresholds { @@ -100,7 +100,7 @@ func TestArchiveContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags @@ -133,7 +133,7 @@ func TestArchiveNotContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags altogether with `--exclude-env-vars` flag diff --git a/cmd/config_consolidation_test.go b/cmd/config_consolidation_test.go index 776d7456e09..d82fb66d28c 100644 --- a/cmd/config_consolidation_test.go +++ b/cmd/config_consolidation_test.go @@ -11,6 +11,7 @@ import ( "gopkg.in/guregu/null.v3" "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/lib" "go.k6.io/k6/lib/executor" "go.k6.io/k6/lib/types" @@ -486,7 +487,7 @@ func getConfigConsolidationTestCases() []configConsolidationTestCase { func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd string) { t.Logf("Test for `k6 %s` with opts=%#v and exp=%#v\n", subCmd, testCase.options, testCase.expected) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) ts.CmdArgs = append([]string{"k6", subCmd}, testCase.options.cli...) ts.Env = state.BuildEnvMap(testCase.options.env) if testCase.options.fs != nil { diff --git a/cmd/convert_test.go b/cmd/convert_test.go index d7b99987ca6..57156ee8e93 100644 --- a/cmd/convert_test.go +++ b/cmd/convert_test.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" ) const testHAR = ` @@ -108,7 +108,7 @@ func TestConvertCmdCorrelate(t *testing.T) { expectedTestPlan, err := ioutil.ReadFile("testdata/example.js") require.NoError(t, err) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, "correlate.har", har, 0o644)) ts.CmdArgs = []string{ "k6", "convert", "--output=result.js", "--correlate=true", "--no-batch=true", @@ -143,7 +143,7 @@ func TestConvertCmdCorrelate(t *testing.T) { func TestConvertCmdStdout(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, "stdout.har", []byte(testHAR), 0o644)) ts.CmdArgs = []string{"k6", "convert", "stdout.har"} @@ -154,7 +154,7 @@ func TestConvertCmdStdout(t *testing.T) { func TestConvertCmdOutputFile(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, "output.har", []byte(testHAR), 0o644)) ts.CmdArgs = []string{"k6", "convert", "--output", "result.js", "output.har"} diff --git a/cmd/panic_integration_test.go b/cmd/panic_integration_test.go index 2864d96c693..03a4bf2258c 100644 --- a/cmd/panic_integration_test.go +++ b/cmd/panic_integration_test.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/js/modules" "go.k6.io/k6/lib/testutils" @@ -85,7 +85,7 @@ func TestRunScriptPanicsErrorsAndAbort(t *testing.T) { t.Parallel() testFilename := "script.js" - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testFilename), []byte(tc.testScript), 0o644)) ts.CmdArgs = []string{"k6", "run", testFilename} diff --git a/cmd/root.go b/cmd/root.go index ddf5dc7884e..86e966a5704 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -131,6 +131,13 @@ func Execute() { newRootCommand(gs).execute() } +// ExecuteWithGlobalState runs the root command with an existing GlobalState. +// This is needed by integration tests, and we don't want to modify the +// Execute() signature to avoid breaking k6 extensions. +func ExecuteWithGlobalState(gs *state.GlobalState) { + newRootCommand(gs).execute() +} + func (c *rootCommand) waitRemoteLogger() { if c.loggerIsRemote { select { diff --git a/cmd/root_test.go b/cmd/root_test.go index f928fddb163..a1b3d3d0283 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,74 +2,22 @@ package cmd import ( "bytes" - "fmt" - "net/http" - "os" - "sync/atomic" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/lib/testutils" - "go.uber.org/goleak" ) -type blockingTransport struct { - fallback http.RoundTripper - forbiddenHosts map[string]bool - counter uint32 -} - -func (bt *blockingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - host := req.URL.Hostname() - if bt.forbiddenHosts[host] { - atomic.AddUint32(&bt.counter, 1) - panic(fmt.Errorf("trying to make forbidden request to %s during test", host)) - } - return bt.fallback.RoundTrip(req) -} - func TestMain(m *testing.M) { - exitCode := 1 // error out by default - defer func() { - os.Exit(exitCode) - }() - - bt := &blockingTransport{ - fallback: http.DefaultTransport, - forbiddenHosts: map[string]bool{ - "ingest.k6.io": true, - "cloudlogs.k6.io": true, - "app.k6.io": true, - "reports.k6.io": true, - }, - } - http.DefaultTransport = bt - defer func() { - if bt.counter > 0 { - fmt.Printf("Expected blocking transport count to be 0 but was %d\n", bt.counter) //nolint:forbidigo - exitCode = 2 - } - }() - - defer func() { - // TODO: figure out why logrus' `Entry.WriterLevel` goroutine sticks - // around and remove this exception. - opt := goleak.IgnoreTopFunction("io.(*pipe).read") - if err := goleak.Find(opt); err != nil { - fmt.Println(err) //nolint:forbidigo - exitCode = 3 - } - }() - - exitCode = m.Run() + tests.Main(m) } func TestDeprecatedOptionWarning(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "--logformat", "json", "run", "-"} ts.Stdin = bytes.NewBuffer([]byte(` console.log('foo'); diff --git a/cmd/run_test.go b/cmd/run_test.go index 8b4090d9ee3..0905505dd54 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -18,7 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib/fsext" @@ -201,7 +201,7 @@ func TestRunScriptErrorsAndAbort(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) @@ -256,7 +256,7 @@ func TestInvalidOptionsThresholdErrExitCode(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) @@ -306,7 +306,7 @@ func TestThresholdsRuntimeBehavior(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - ts := state.NewGlobalTestState(t) + ts := tests.NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) ts.CmdArgs = []string{"k6", "run", tc.testFilename} diff --git a/cmd/runtime_options_test.go b/cmd/runtime_options_test.go index 81b6ebe949f..02fb1b08137 100644 --- a/cmd/runtime_options_test.go +++ b/cmd/runtime_options_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/guregu/null.v3" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd/tests" "go.k6.io/k6/lib" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" @@ -59,7 +59,7 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/script.js", jsCode.Bytes(), 0o644)) - ts := state.NewGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test + ts := tests.NewGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test registry := metrics.NewRegistry() test := &loadedTest{ sourceRootPath: "script.js", diff --git a/cmd/integration_test.go b/cmd/tests/cmd_test.go similarity index 86% rename from cmd/integration_test.go rename to cmd/tests/cmd_test.go index 33ce25c7be2..8ceb7fa0112 100644 --- a/cmd/integration_test.go +++ b/cmd/tests/cmd_test.go @@ -1,4 +1,4 @@ -package cmd +package tests import ( "bytes" @@ -22,7 +22,7 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "go.k6.io/k6/cloudapi" - "go.k6.io/k6/cmd/state" + "go.k6.io/k6/cmd" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" "go.k6.io/k6/lib/consts" @@ -33,16 +33,16 @@ import ( func TestVersion(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "version"} - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - assert.Contains(t, stdOut, "k6 v"+consts.Version) - assert.Contains(t, stdOut, runtime.Version()) - assert.Contains(t, stdOut, runtime.GOOS) - assert.Contains(t, stdOut, runtime.GOARCH) - assert.Contains(t, stdOut, "k6/x/alarmist") + stdout := ts.Stdout.String() + assert.Contains(t, stdout, "k6 v"+consts.Version) + assert.Contains(t, stdout, runtime.Version()) + assert.Contains(t, stdout, runtime.GOOS) + assert.Contains(t, stdout, runtime.GOARCH) + assert.NotContains(t, stdout[:len(stdout)-1], "\n") assert.Empty(t, ts.Stderr.Bytes()) assert.Empty(t, ts.LoggerHook.Drain()) @@ -51,22 +51,23 @@ func TestVersion(t *testing.T) { func TestSimpleTestStdin(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "run", "-"} ts.Stdin = bytes.NewBufferString(`export default function() {};`) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - assert.Contains(t, stdOut, "default: 1 iterations for each of 1 VUs") - assert.Contains(t, stdOut, "1 complete and 0 interrupted iterations") + stdout := ts.Stdout.String() + assert.Contains(t, stdout, "default: 1 iterations for each of 1 VUs") + assert.Contains(t, stdout, "1 complete and 0 interrupted iterations") assert.Empty(t, ts.Stderr.Bytes()) assert.Empty(t, ts.LoggerHook.Drain()) } +// TODO: Remove this? It doesn't test anything AFAICT... func TestStdoutAndStderrAreEmptyWithQuietAndHandleSummary(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "--quiet", "run", "-"} ts.Stdin = bytes.NewBufferString(` export default function() {}; @@ -74,7 +75,7 @@ func TestStdoutAndStderrAreEmptyWithQuietAndHandleSummary(t *testing.T) { return {}; // silence the end of test summary }; `) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.Empty(t, ts.Stderr.Bytes()) assert.Empty(t, ts.Stdout.Bytes()) @@ -84,7 +85,7 @@ func TestStdoutAndStderrAreEmptyWithQuietAndHandleSummary(t *testing.T) { func TestStdoutAndStderrAreEmptyWithQuietAndLogsForwarded(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) // TODO: add a test with relative path logFilePath := filepath.Join(ts.Cwd, "test.log") @@ -97,7 +98,7 @@ func TestStdoutAndStderrAreEmptyWithQuietAndLogsForwarded(t *testing.T) { console.log('init'); export default function() { console.log('foo'); }; `) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) // The test state hook still catches this message assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.InfoLevel, `foo`)) @@ -115,7 +116,7 @@ func TestStdoutAndStderrAreEmptyWithQuietAndLogsForwarded(t *testing.T) { func TestRelativeLogPathWithSetupAndTeardown(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "--log-output", "file=test.log", "--log-format", "raw", "run", "-i", "2", "-"} ts.Stdin = bytes.NewBufferString(` @@ -124,7 +125,7 @@ func TestRelativeLogPathWithSetupAndTeardown(t *testing.T) { export function setup() { console.log('bar'); }; export function teardown() { console.log('baz'); }; `) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) // The test state hook still catches these messages logEntries := ts.LoggerHook.Drain() @@ -141,39 +142,39 @@ func TestRelativeLogPathWithSetupAndTeardown(t *testing.T) { func TestWrongCliFlagIterations(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "run", "--iterations", "foo", "-"} ts.Stdin = bytes.NewBufferString(`export default function() {};`) // TODO: check for exitcodes.InvalidConfig after https://github.com/loadimpact/k6/issues/883 is done... ts.ExpectedExitCode = -1 - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `invalid argument "foo"`)) } func TestWrongEnvVarIterations(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "run", "--vus", "2", "-"} ts.Env["K6_ITERATIONS"] = "4" ts.Stdin = bytes.NewBufferString(`export default function() {};`) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, "4 iterations shared among 2 VUs") - assert.Contains(t, stdOut, "4 complete and 0 interrupted iterations") + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "4 iterations shared among 2 VUs") + assert.Contains(t, stdout, "4 complete and 0 interrupted iterations") assert.Empty(t, ts.Stderr.Bytes()) assert.Empty(t, ts.LoggerHook.Drain()) } -func getSingleFileTestState(t *testing.T, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *state.GlobalTestState { +func getSingleFileTestState(t *testing.T, script string, cliFlags []string, expExitCode exitcodes.ExitCode) *GlobalTestState { if cliFlags == nil { cliFlags = []string{"-v", "--log-output=stdout"} } - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(script), 0o644)) ts.CmdArgs = append(append([]string{"k6", "run"}, cliFlags...), "test.js") ts.ExpectedExitCode = int(expExitCode) @@ -242,7 +243,7 @@ func TestMetricsAndThresholds(t *testing.T) { } ` ts := getSingleFileTestState(t, script, []string{"--quiet", "--log-format=raw"}, 0) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) expLogLines := []string{ `setup() start`, `setup() end`, `default({"foo":"bar"})`, @@ -275,17 +276,17 @@ func TestMetricsAndThresholds(t *testing.T) { func TestSSLKEYLOGFILEAbsolute(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) testSSLKEYLOGFILE(t, ts, filepath.Join(ts.Cwd, "ssl.log")) } func TestSSLKEYLOGFILEARelative(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) testSSLKEYLOGFILE(t, ts, "./ssl.log") } -func testSSLKEYLOGFILE(t *testing.T, ts *state.GlobalTestState, filePath string) { +func testSSLKEYLOGFILE(t *testing.T, ts *GlobalTestState, filePath string) { t.Helper() // TODO don't use insecureSkipTLSVerify when/if tlsConfig is given to the runner from outside @@ -306,7 +307,7 @@ func testSSLKEYLOGFILE(t *testing.T, ts *state.GlobalTestState, filePath string) } `))) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.WarnLevel, "SSLKEYLOGFILE was specified")) @@ -319,7 +320,7 @@ func testSSLKEYLOGFILE(t *testing.T, ts *state.GlobalTestState, filePath string) func TestThresholdDeprecationWarnings(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "run", "--system-tags", "url,error,vu,iter,scenario", "-"} ts.Stdin = bytes.NewReader([]byte(` export const options = { @@ -334,7 +335,7 @@ func TestThresholdDeprecationWarnings(t *testing.T) { export default function () { }`, )) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) logs := ts.LoggerHook.Drain() @@ -367,7 +368,7 @@ func TestExecutionTestOptionsDefaultValues(t *testing.T) { ` ts := getSingleFileTestState(t, script, []string{"--iterations", "1"}, 0) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) loglines := ts.LoggerHook.Drain() require.Len(t, loglines, 1) @@ -395,7 +396,7 @@ func TestSubMetricThresholdNoData(t *testing.T) { } ` ts := getSingleFileTestState(t, script, []string{"--quiet"}, 0) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.Len(t, ts.LoggerHook.Drain(), 0) assert.Contains(t, ts.Stdout.String(), ` @@ -469,7 +470,7 @@ func getCloudTestEndChecker(t *testing.T, expRunStatus lib.RunStatus, expResultS func getSimpleCloudOutputTestState( t *testing.T, script string, cliFlags []string, expRunStatus lib.RunStatus, expResultStatus cloudapi.ResultStatus, expExitCode exitcodes.ExitCode, -) *state.GlobalTestState { +) *GlobalTestState { if cliFlags == nil { cliFlags = []string{"-v", "--log-output=stdout"} } @@ -516,7 +517,7 @@ func TestSetupTeardownThresholds(t *testing.T) { `) ts := getSimpleCloudOutputTestState(t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) stdOut := ts.Stdout.String() assert.Contains(t, stdOut, `✓ http_reqs......................: 7`) @@ -563,15 +564,15 @@ func TestThresholdsFailed(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusFailed, exitcodes.ThresholdsHaveFailed, ) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `some thresholds have failed`)) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, ` ✓ iterations...........: 3`) - assert.Contains(t, stdOut, ` ✗ { scenario:sc1 }...: 1`) - assert.Contains(t, stdOut, ` ✗ { scenario:sc2 }...: 2`) - assert.Contains(t, stdOut, ` ✓ { scenario:sc3 }...: 0 0/s`) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, ` ✓ iterations...........: 3`) + assert.Contains(t, stdout, ` ✗ { scenario:sc1 }...: 1`) + assert.Contains(t, stdout, ` ✗ { scenario:sc2 }...: 2`) + assert.Contains(t, stdout, ` ✓ { scenario:sc3 }...: 0 0/s`) } func TestAbortedByThreshold(t *testing.T) { @@ -604,7 +605,7 @@ func TestAbortedByThreshold(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedThreshold, cloudapi.ResultStatusFailed, exitcodes.ThresholdsHaveFailed, ) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) assert.True(t, testutils.LogContains(ts.LoggerHook.Drain(), logrus.ErrorLevel, `test run aborted by failed thresholds`)) stdOut := ts.Stdout.String() @@ -653,24 +654,24 @@ func TestAbortedByUserWithGoodThresholds(t *testing.T) { asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "simple iter 2") - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) logs := ts.LoggerHook.Drain() assert.False(t, testutils.LogContains(logs, logrus.ErrorLevel, `some thresholds have failed`)) assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, `test run aborted by signal`)) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, `✓ iterations`) - assert.Contains(t, stdOut, `✓ tc`) - assert.Contains(t, stdOut, `✓ { group:::teardown }`) - assert.Contains(t, stdOut, `Stopping k6 in response to signal`) - assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) - assert.Contains(t, stdOut, `level=debug msg="Metrics processing finished!"`) - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `✓ iterations`) + assert.Contains(t, stdout, `✓ tc`) + assert.Contains(t, stdout, `✓ { group:::teardown }`) + assert.Contains(t, stdout, `Stopping k6 in response to signal`) + assert.Contains(t, stdout, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) + assert.Contains(t, stdout, `level=debug msg="Metrics processing finished!"`) + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) } func asyncWaitForStdoutAndRun( - t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, callback func(), + t *testing.T, ts *GlobalTestState, attempts int, interval time.Duration, expText string, callback func(), ) { wg := &sync.WaitGroup{} wg.Add(1) @@ -710,7 +711,7 @@ func asyncWaitForStdoutAndRun( } func asyncWaitForStdoutAndStopTestWithInterruptSignal( - t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, + t *testing.T, ts *GlobalTestState, attempts int, interval time.Duration, expText string, ) { sendSignal := make(chan struct{}) ts.GlobalState.SignalNotify = func(c chan<- os.Signal, signals ...os.Signal) { @@ -740,7 +741,7 @@ func asyncWaitForStdoutAndStopTestWithInterruptSignal( } func asyncWaitForStdoutAndStopTestFromRESTAPI( - t *testing.T, ts *state.GlobalTestState, attempts int, interval time.Duration, expText string, + t *testing.T, ts *GlobalTestState, attempts int, interval time.Duration, expText string, ) { asyncWaitForStdoutAndRun(t, ts, attempts, interval, expText, func() { req, err := http.NewRequestWithContext( @@ -782,17 +783,17 @@ func TestAbortedByUserWithRestAPI(t *testing.T) { asyncWaitForStdoutAndStopTestFromRESTAPI(t, ts, 15, time.Second, "a simple iteration") - newRootCommand(ts.GlobalState).execute() - - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, `a simple iteration`) - assert.Contains(t, stdOut, `teardown() called`) - assert.Contains(t, stdOut, `PATCH /v1/status`) - assert.Contains(t, stdOut, `run: stopped by user via REST API; exiting...`) - assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) - assert.Contains(t, stdOut, `level=debug msg="Metrics processing finished!"`) - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `a simple iteration`) + assert.Contains(t, stdout, `teardown() called`) + assert.Contains(t, stdout, `PATCH /v1/status`) + assert.Contains(t, stdout, `run: stopped by user via REST API; exiting...`) + assert.Contains(t, stdout, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) + assert.Contains(t, stdout, `level=debug msg="Metrics processing finished!"`) + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) } func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) { @@ -819,7 +820,7 @@ func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) { srv := getCloudTestEndChecker(t, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed) - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), []byte(mainScript), 0o644)) require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, "bar.js"), []byte(depScript), 0o644)) @@ -827,30 +828,30 @@ func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) { ts.CmdArgs = []string{"k6", "run", "-v", "--out", "cloud", "--log-output=stdout", "test.js"} ts.ExpectedExitCode = int(exitcodes.ScriptException) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, `wonky setup`) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `wonky setup`) rootPath := "file:///" if runtime.GOOS == "windows" { rootPath += "c:/" } - assert.Contains(t, stdOut, `level=error msg="Error: baz\n\tat baz (`+rootPath+`test/bar.js:6:9(3))\n\tat `+ + assert.Contains(t, stdout, `level=error msg="Error: baz\n\tat baz (`+rootPath+`test/bar.js:6:9(3))\n\tat `+ rootPath+`test/bar.js:3:3(3)\n\tat setup (`+rootPath+`test/test.js:5:3(9))\n" hint="script exception"`) - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) - assert.Contains(t, stdOut, "bogus summary") + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) + assert.Contains(t, stdout, "bogus summary") } -func runTestWithNoLinger(t *testing.T, ts *state.GlobalTestState) { - newRootCommand(ts.GlobalState).execute() +func runTestWithNoLinger(t *testing.T, ts *GlobalTestState) { + cmd.ExecuteWithGlobalState(ts.GlobalState) } -func runTestWithLinger(t *testing.T, ts *state.GlobalTestState) { +func runTestWithLinger(t *testing.T, ts *GlobalTestState) { ts.CmdArgs = append(ts.CmdArgs, "--linger") asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "Linger set; waiting for Ctrl+C") - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) } func TestAbortedByScriptSetupError(t *testing.T) { @@ -870,12 +871,12 @@ func TestAbortedByScriptSetupError(t *testing.T) { export function handleSummary() { return {stdout: '\n\n\nbogus summary\n\n\n'};} ` - doChecks := func(t *testing.T, ts *state.GlobalTestState) { - stdOut := ts.Stdout.String() - assert.Contains(t, stdOut, "Error: foo") - assert.Contains(t, stdOut, "wonky setup") - assert.NotContains(t, stdOut, "nice teardown") // do not execute teardown if setup failed - assert.Contains(t, stdOut, "bogus summary") + doChecks := func(t *testing.T, ts *GlobalTestState) { + stdout := ts.Stdout.String() + assert.Contains(t, stdout, "Error: foo") + assert.Contains(t, stdout, "wonky setup") + assert.NotContains(t, stdout, "nice teardown") // do not execute teardown if setup failed + assert.Contains(t, stdout, "bogus summary") } t.Run("noLinger", func(t *testing.T) { @@ -909,12 +910,12 @@ func TestAbortedByScriptTeardownError(t *testing.T) { export function handleSummary() { return {stdout: '\n\n\nbogus summary\n\n\n'};} ` - doChecks := func(t *testing.T, ts *state.GlobalTestState) { - stdOut := ts.Stdout.String() - assert.Contains(t, stdOut, "Error: foo") - assert.Contains(t, stdOut, "nice setup") - assert.Contains(t, stdOut, "wonky teardown") - assert.Contains(t, stdOut, "bogus summary") + doChecks := func(t *testing.T, ts *GlobalTestState) { + stdout := ts.Stdout.String() + assert.Contains(t, stdout, "Error: foo") + assert.Contains(t, stdout, "nice setup") + assert.Contains(t, stdout, "wonky teardown") + assert.Contains(t, stdout, "bogus summary") } t.Run("noLinger", func(t *testing.T) { @@ -930,18 +931,18 @@ func TestAbortedByScriptTeardownError(t *testing.T) { }) } -func testAbortedByScriptError(t *testing.T, script string, runTest func(*testing.T, *state.GlobalTestState)) *state.GlobalTestState { +func testAbortedByScriptError(t *testing.T, script string, runTest func(*testing.T, *GlobalTestState)) *GlobalTestState { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed, exitcodes.ScriptException, ) runTest(t, ts) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) - assert.Contains(t, stdOut, `level=debug msg="Metrics processing finished!"`) - assert.Contains(t, stdOut, `level=debug msg="Everything has finished, exiting k6!"`) - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) + assert.Contains(t, stdout, `level=debug msg="Metrics processing finished!"`) + assert.Contains(t, stdout, `level=debug msg="Everything has finished, exiting k6!"`) + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) return ts } @@ -957,11 +958,11 @@ func TestAbortedByTestAbortFirstInitCode(t *testing.T) { ` ts := getSingleFileTestState(t, script, nil, exitcodes.ScriptAborted) - newRootCommand(ts.GlobalState).execute() - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, "test aborted: foo") - assert.NotContains(t, stdOut, "bogus summary") + cmd.ExecuteWithGlobalState(ts.GlobalState) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "test aborted: foo") + assert.NotContains(t, stdout, "bogus summary") } func TestAbortedByTestAbortInNonFirstInitCode(t *testing.T) { @@ -1074,23 +1075,23 @@ func TestAbortedByScriptAbortInTeardown(t *testing.T) { } func testAbortedByScriptTestAbort( - t *testing.T, shouldHaveMetrics bool, script string, runTest func(*testing.T, *state.GlobalTestState), -) *state.GlobalTestState { + t *testing.T, shouldHaveMetrics bool, script string, runTest func(*testing.T, *GlobalTestState), +) *GlobalTestState { //nolint:unparam ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedUser, cloudapi.ResultStatusPassed, exitcodes.ScriptAborted, ) runTest(t, ts) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, "test aborted: foo") - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) - assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "test aborted: foo") + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=5 tainted=false`) + assert.Contains(t, stdout, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) if shouldHaveMetrics { - assert.Contains(t, stdOut, `level=debug msg="Metrics processing finished!"`) - assert.Contains(t, stdOut, "bogus summary") + assert.Contains(t, stdout, `level=debug msg="Metrics processing finished!"`) + assert.Contains(t, stdout, "bogus summary") } else { - assert.NotContains(t, stdOut, "bogus summary") + assert.NotContains(t, stdout, "bogus summary") } return ts } @@ -1121,7 +1122,7 @@ func TestAbortedByInterruptDuringVUInit(t *testing.T) { t, script, nil, lib.RunStatusAbortedSystem, cloudapi.ResultStatusPassed, exitcodes.GenericEngine, ) asyncWaitForStdoutAndStopTestWithInterruptSignal(t, ts, 15, time.Second, "VU init sleeping for a while") - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) stdOut := ts.Stdout.String() t.Log(stdOut) @@ -1152,14 +1153,14 @@ func TestAbortedByScriptInitError(t *testing.T) { ts := getSimpleCloudOutputTestState( t, script, nil, lib.RunStatusAbortedScriptError, cloudapi.ResultStatusPassed, exitcodes.ScriptException, ) - newRootCommand(ts.GlobalState).execute() - - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, `level=error msg="Error: oops in 2\n\tat file:///`) - assert.Contains(t, stdOut, `hint="error while initializing VU #2 (script exception)"`) - assert.Contains(t, stdOut, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) - assert.Contains(t, stdOut, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `level=error msg="Error: oops in 2\n\tat file:///`) + assert.Contains(t, stdout, `hint="error while initializing VU #2 (script exception)"`) + assert.Contains(t, stdout, `level=debug msg="Metrics emission of VUs and VUsMax metrics stopped"`) + assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=111 run_status=7 tainted=false`) } func TestMetricTagAndSetupDataIsolation(t *testing.T) { @@ -1250,11 +1251,11 @@ func TestMetricTagAndSetupDataIsolation(t *testing.T) { t, script, []string{"--quiet", "--log-output", "stdout"}, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0, ) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Equal(t, 12, strings.Count(stdOut, "✓")) + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Equal(t, 12, strings.Count(stdout, "✓")) } func getSampleValues(t *testing.T, jsonOutput []byte, metric string, tags map[string]string) []float64 { @@ -1373,10 +1374,10 @@ func TestActiveVUsCount(t *testing.T) { ` ts := getSingleFileTestState(t, script, []string{"--compatibility-mode", "base", "--out", "json=results.json"}, 0) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - t.Log(stdOut) + stdout := ts.Stdout.String() + t.Log(stdout) jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) @@ -1421,7 +1422,7 @@ func TestMinIterationDuration(t *testing.T) { ts := getSimpleCloudOutputTestState(t, script, nil, lib.RunStatusFinished, cloudapi.ResultStatusPassed, 0) start := time.Now() - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) elapsed := time.Since(start) assert.Greater(t, elapsed, 5*time.Second, "expected more time to have passed because of minIterationDuration") assert.Less( @@ -1429,9 +1430,9 @@ func TestMinIterationDuration(t *testing.T) { "expected less time to have passed because minIterationDuration should not affect setup() and teardown() ", ) - stdOut := ts.Stdout.String() - t.Log(stdOut) - assert.Contains(t, stdOut, "✓ test_counter.........: 3") + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "✓ test_counter.........: 3") } func TestRunTags(t *testing.T) { @@ -1501,10 +1502,10 @@ func TestRunTags(t *testing.T) { }, 0) ts.Env["K6_ITERATIONS"] = "3" ts.Env["K6_INSECURE_SKIP_TLS_VERIFY"] = "true" - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdOut := ts.Stdout.String() - t.Log(stdOut) + stdout := ts.Stdout.String() + t.Log(stdout) jsonResults, err := afero.ReadFile(ts.FS, "results.json") require.NoError(t, err) @@ -1545,17 +1546,17 @@ func TestRunTags(t *testing.T) { func TestPrometheusRemoteWriteOutput(t *testing.T) { t.Parallel() - ts := state.NewGlobalTestState(t) + ts := NewGlobalTestState(t) ts.CmdArgs = []string{"k6", "run", "--out", "experimental-prometheus-rw", "-"} ts.Stdin = bytes.NewBufferString(` import exec from 'k6/execution'; export default function () {}; `) - newRootCommand(ts.GlobalState).execute() + cmd.ExecuteWithGlobalState(ts.GlobalState) ts.OutMutex.Lock() - stdOut := ts.Stdout.String() + stdout := ts.Stdout.String() ts.OutMutex.Unlock() - assert.Contains(t, stdOut, "output: Prometheus remote write") + assert.Contains(t, stdout, "output: Prometheus remote write") } diff --git a/cmd/tests/doc.go b/cmd/tests/doc.go new file mode 100644 index 00000000000..8bc9fb202ba --- /dev/null +++ b/cmd/tests/doc.go @@ -0,0 +1,6 @@ +// Package tests contains integration tests that run k6 commands, and interact +// with standard I/O streams. They're the highest level tests we have, just +// below E2E tests that execute the k6 binary. Since they initialize all +// internal k6 components similarly to how a user would, they're very useful, +// but also very expensive to run. +package tests diff --git a/cmd/state/test_state.go b/cmd/tests/test_state.go similarity index 86% rename from cmd/state/test_state.go rename to cmd/tests/test_state.go index 8e980d35c53..e3d8b2a7139 100644 --- a/cmd/state/test_state.go +++ b/cmd/tests/test_state.go @@ -1,9 +1,8 @@ -package state +package tests import ( "bytes" "context" - "io" "net" "os/signal" "runtime" @@ -16,13 +15,14 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib/testutils" "go.k6.io/k6/ui/console" ) // GlobalTestState is a wrapper around GlobalState for use in tests. type GlobalTestState struct { - *GlobalState + *state.GlobalState Cancel func() Stdout, Stderr *bytes.Buffer @@ -80,10 +80,10 @@ func NewGlobalTestState(t *testing.T) *GlobalTestState { }) outMutex := &sync.Mutex{} - defaultFlags := GetDefaultFlags(".config") + defaultFlags := state.GetDefaultFlags(".config") defaultFlags.Address = getFreeBindAddr(t) - ts.GlobalState = &GlobalState{ + ts.GlobalState = &state.GlobalState{ Ctx: ctx, FS: fs, Getwd: func() (string, error) { return ts.Cwd, nil }, @@ -113,26 +113,6 @@ func NewGlobalTestState(t *testing.T) *GlobalTestState { return ts } -// TestOSFileW is the mock implementation of stdout/stderr. -type TestOSFileW struct { - io.Writer -} - -// Fd returns a mock file descriptor ID. -func (f *TestOSFileW) Fd() uintptr { - return 0 -} - -// TestOSFileR is the mock implementation of stdin. -type TestOSFileR struct { - io.Reader -} - -// Fd returns a mock file descriptor ID. -func (f *TestOSFileR) Fd() uintptr { - return 0 -} - var portRangeStart uint64 = 6565 //nolint:gochecknoglobals func getFreeBindAddr(t *testing.T) string { diff --git a/cmd/tests/tests.go b/cmd/tests/tests.go new file mode 100644 index 00000000000..c6f163eccfd --- /dev/null +++ b/cmd/tests/tests.go @@ -0,0 +1,65 @@ +package tests + +import ( + "fmt" + "net/http" + "os" + "sync/atomic" + "testing" + + "go.uber.org/goleak" +) + +type blockingTransport struct { + fallback http.RoundTripper + forbiddenHosts map[string]bool + counter uint32 +} + +func (bt *blockingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.URL.Hostname() + if bt.forbiddenHosts[host] { + atomic.AddUint32(&bt.counter, 1) + panic(fmt.Errorf("trying to make forbidden request to %s during test", host)) + } + return bt.fallback.RoundTrip(req) +} + +// Main is a TestMain function that can be imported by other test packages that +// want to use the blocking transport and other features useful for integration +// tests. +func Main(m *testing.M) { + exitCode := 1 // error out by default + defer func() { + os.Exit(exitCode) + }() + + bt := &blockingTransport{ + fallback: http.DefaultTransport, + forbiddenHosts: map[string]bool{ + "ingest.k6.io": true, + "cloudlogs.k6.io": true, + "app.k6.io": true, + "reports.k6.io": true, + }, + } + http.DefaultTransport = bt + defer func() { + if bt.counter > 0 { + fmt.Printf("Expected blocking transport count to be 0 but was %d\n", bt.counter) //nolint:forbidigo + exitCode = 2 + } + }() + + defer func() { + // TODO: figure out why logrus' `Entry.WriterLevel` goroutine sticks + // around and remove this exception. + opt := goleak.IgnoreTopFunction("io.(*pipe).read") + if err := goleak.Find(opt); err != nil { + fmt.Println(err) //nolint:forbidigo + exitCode = 3 + } + }() + + exitCode = m.Run() +} diff --git a/cmd/tests/tests_test.go b/cmd/tests/tests_test.go new file mode 100644 index 00000000000..efa11ef661d --- /dev/null +++ b/cmd/tests/tests_test.go @@ -0,0 +1,10 @@ +// Package tests contains integration tests for multiple packages. +package tests + +import ( + "testing" +) + +func TestMain(m *testing.M) { + Main(m) +} From f3cececda7cfc5b03501164878c9e55eec6ff62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Fri, 16 Dec 2022 12:38:30 +0100 Subject: [PATCH 4/5] Add helper method to SimpleLogrusHook to get logged lines --- lib/testutils/logrus_hook.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/testutils/logrus_hook.go b/lib/testutils/logrus_hook.go index 47465bae482..54ce80af767 100644 --- a/lib/testutils/logrus_hook.go +++ b/lib/testutils/logrus_hook.go @@ -37,6 +37,16 @@ func (smh *SimpleLogrusHook) Drain() []logrus.Entry { return res } +// Lines returns the logged lines. +func (smh *SimpleLogrusHook) Lines() []string { + entries := smh.Drain() + lines := make([]string, len(entries)) + for i, entry := range entries { + lines[i] = entry.Message + } + return lines +} + var _ logrus.Hook = &SimpleLogrusHook{} // LogContains is a helper function that checks the provided list of log entries From b03553b3541520ecb0df2db4f4779aab728cbbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Fri, 16 Dec 2022 12:40:11 +0100 Subject: [PATCH 5/5] Rewrite event loop tests as integration tests This also removes the duplication of the xk6-timers module, and uses it directly now that it's an experimental built-in module. Although, shouldn't these be xk6-timers tests rather than event loop ones? We have plenty of tests that indirectly test the event loop, and we could always add much simpler ones that don't involve the timers module. See https://github.com/grafana/k6/issues/2459 --- .../eventloop/eventloop_test.go | 219 ------------------ .../testmodules/events/events.go | 157 ------------- .../testmodules/events/events.js | 49 ---- .../testmodules/events/events_test.go | 43 ---- cmd/tests/eventloop_test.go | 131 +++++++++++ 5 files changed, 131 insertions(+), 468 deletions(-) delete mode 100644 cmd/integration_tests/eventloop/eventloop_test.go delete mode 100644 cmd/integration_tests/testmodules/events/events.go delete mode 100644 cmd/integration_tests/testmodules/events/events.js delete mode 100644 cmd/integration_tests/testmodules/events/events_test.go create mode 100644 cmd/tests/eventloop_test.go diff --git a/cmd/integration_tests/eventloop/eventloop_test.go b/cmd/integration_tests/eventloop/eventloop_test.go deleted file mode 100644 index 9e8e554b68c..00000000000 --- a/cmd/integration_tests/eventloop/eventloop_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package tests - -import ( - "context" - "io/ioutil" - "net/url" - "testing" - "time" - - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/require" - "go.k6.io/k6/cmd/integration_tests/testmodules/events" - "go.k6.io/k6/core/local" - "go.k6.io/k6/js" - "go.k6.io/k6/js/modules" - "go.k6.io/k6/lib" - "go.k6.io/k6/lib/executor" - "go.k6.io/k6/lib/testutils" - "go.k6.io/k6/lib/types" - "go.k6.io/k6/loader" - "go.k6.io/k6/metrics" - "gopkg.in/guregu/null.v3" -) - -func eventLoopTest(t *testing.T, script []byte, testHandle func(context.Context, lib.Runner, error, *testutils.SimpleLogrusHook)) { - logger := logrus.New() - logger.SetOutput(ioutil.Discard) - logHook := &testutils.SimpleLogrusHook{HookedLevels: []logrus.Level{logrus.InfoLevel, logrus.WarnLevel, logrus.ErrorLevel}} - logger.AddHook(logHook) - - registry := metrics.NewRegistry() - piState := &lib.TestPreInitState{ - Logger: logger, - Registry: registry, - BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), - } - - script = []byte("import {setTimeout} from 'k6/x/events';\n" + string(script)) - runner, err := js.New(piState, &loader.SourceData{URL: &url.URL{Path: "/script.js"}, Data: script}, nil) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - newOpts, err := executor.DeriveScenariosFromShortcuts(lib.Options{ - MetricSamplesBufferSize: null.NewInt(200, false), - TeardownTimeout: types.NullDurationFrom(time.Second), - SetupTimeout: types.NullDurationFrom(time.Second), - }.Apply(runner.GetOptions()), nil) - require.NoError(t, err) - require.Empty(t, newOpts.Validate()) - require.NoError(t, runner.SetOptions(newOpts)) - - testState := &lib.TestRunState{ - TestPreInitState: piState, - Options: newOpts, - Runner: runner, - RunTags: piState.Registry.RootTagSet().WithTagsFromMap(newOpts.RunTags), - } - - execScheduler, err := local.NewExecutionScheduler(testState) - require.NoError(t, err) - - samples := make(chan metrics.SampleContainer, newOpts.MetricSamplesBufferSize.Int64) - go func() { - for { - select { - case <-samples: - case <-ctx.Done(): - return - } - } - }() - - require.NoError(t, execScheduler.Init(ctx, samples)) - - errCh := make(chan error, 1) - go func() { errCh <- execScheduler.Run(ctx, ctx, samples) }() - - select { - case err := <-errCh: - testHandle(ctx, runner, err, logHook) - case <-time.After(10 * time.Second): - t.Fatal("timed out") - } -} - -func init() { - modules.Register("k6/x/events", events.New()) -} - -func TestEventLoop(t *testing.T) { - t.Parallel() - script := []byte(` - setTimeout(()=> {console.log("initcontext setTimeout")}, 200) - console.log("initcontext"); - export default function() { - setTimeout(()=> {console.log("default setTimeout")}, 200) - console.log("default"); - }; - export function setup() { - setTimeout(()=> {console.log("setup setTimeout")}, 200) - console.log("setup"); - }; - export function teardown() { - setTimeout(()=> {console.log("teardown setTimeout")}, 200) - console.log("teardown"); - }; - export function handleSummary() { - setTimeout(()=> {console.log("handleSummary setTimeout")}, 200) - console.log("handleSummary"); - }; -`) - eventLoopTest(t, script, func(ctx context.Context, runner lib.Runner, err error, logHook *testutils.SimpleLogrusHook) { - require.NoError(t, err) - _, err = runner.HandleSummary(ctx, &lib.Summary{RootGroup: &lib.Group{}}) - require.NoError(t, err) - entries := logHook.Drain() - msgs := make([]string, len(entries)) - for i, entry := range entries { - msgs[i] = entry.Message - } - require.Equal(t, []string{ - "initcontext", // first initialization - "initcontext setTimeout", - "initcontext", // for vu - "initcontext setTimeout", - "initcontext", // for setup - "initcontext setTimeout", - "setup", // setup - "setup setTimeout", - "default", // one iteration - "default setTimeout", - "initcontext", // for teardown - "initcontext setTimeout", - "teardown", // teardown - "teardown setTimeout", - "initcontext", // for handleSummary - "initcontext setTimeout", - "handleSummary", // handleSummary - "handleSummary setTimeout", - }, msgs) - }) -} - -func TestEventLoopCrossScenario(t *testing.T) { - t.Parallel() - script := []byte(` -import exec from "k6/execution" -export const options = { - scenarios: { - "first":{ - executor: "shared-iterations", - maxDuration: "1s", - iterations: 1, - vus: 1, - gracefulStop:"1s", - }, - "second": { - executor: "shared-iterations", - maxDuration: "1s", - iterations: 1, - vus: 1, - startTime: "3s", - } - } -} -export default function() { - let i = exec.scenario.name - setTimeout(()=> {console.log(i)}, 3000) -} -`) - - eventLoopTest(t, script, func(_ context.Context, _ lib.Runner, err error, logHook *testutils.SimpleLogrusHook) { - require.NoError(t, err) - entries := logHook.Drain() - msgs := make([]string, len(entries)) - for i, entry := range entries { - msgs[i] = entry.Message - } - require.Equal(t, []string{ - "setTimeout 1 was stopped because the VU iteration was interrupted", - "second", - }, msgs) - }) -} - -func TestEventLoopDoesntCrossIterations(t *testing.T) { - t.Parallel() - script := []byte(` -import { sleep } from "k6" -export const options = { - iterations: 2, - vus: 1, -} - -export default function() { - let i = __ITER; - setTimeout(()=> { console.log(i) }, 1000) - if (__ITER == 0) { - throw "just error" - } else { - sleep(1) - } -} -`) - - eventLoopTest(t, script, func(_ context.Context, _ lib.Runner, err error, logHook *testutils.SimpleLogrusHook) { - require.NoError(t, err) - entries := logHook.Drain() - msgs := make([]string, len(entries)) - for i, entry := range entries { - msgs[i] = entry.Message - } - require.Equal(t, []string{ - "setTimeout 1 was stopped because the VU iteration was interrupted", - "just error\n\tat /script.js:13:4(15)\n", "1", - }, msgs) - }) -} diff --git a/cmd/integration_tests/testmodules/events/events.go b/cmd/integration_tests/testmodules/events/events.go deleted file mode 100644 index e34c087c083..00000000000 --- a/cmd/integration_tests/testmodules/events/events.go +++ /dev/null @@ -1,157 +0,0 @@ -// Package events implements setInterval, setTimeout and co. Not to be used, mostly for testing purposes -package events - -import ( - "sync" - "sync/atomic" - "time" - - "github.com/dop251/goja" - "go.k6.io/k6/js/modules" -) - -// RootModule is the global module instance that will create module -// instances for each VU. -type RootModule struct{} - -// Events represents an instance of the events module. -type Events struct { - vu modules.VU - - timerStopCounter uint32 - timerStopsLock sync.Mutex - timerStops map[uint32]chan struct{} -} - -var ( - _ modules.Module = &RootModule{} - _ modules.Instance = &Events{} -) - -// New returns a pointer to a new RootModule instance. -func New() *RootModule { - return &RootModule{} -} - -// NewModuleInstance implements the modules.Module interface to return -// a new instance for each VU. -func (*RootModule) NewModuleInstance(vu modules.VU) modules.Instance { - return &Events{ - vu: vu, - timerStops: make(map[uint32]chan struct{}), - } -} - -// Exports returns the exports of the k6 module. -func (e *Events) Exports() modules.Exports { - return modules.Exports{ - Named: map[string]interface{}{ - "setTimeout": e.setTimeout, - "clearTimeout": e.clearTimeout, - "setInterval": e.setInterval, - "clearInterval": e.clearInterval, - }, - } -} - -func noop() error { return nil } - -func (e *Events) getTimerStopCh() (uint32, chan struct{}) { - id := atomic.AddUint32(&e.timerStopCounter, 1) - ch := make(chan struct{}) - e.timerStopsLock.Lock() - e.timerStops[id] = ch - e.timerStopsLock.Unlock() - return id, ch -} - -func (e *Events) stopTimerCh(id uint32) bool { //nolint:unparam - e.timerStopsLock.Lock() - defer e.timerStopsLock.Unlock() - ch, ok := e.timerStops[id] - if !ok { - return false - } - delete(e.timerStops, id) - close(ch) - return true -} - -func (e *Events) call(callback goja.Callable, args []goja.Value) error { - // TODO: investigate, not sure GlobalObject() is always the correct value for `this`? - _, err := callback(e.vu.Runtime().GlobalObject(), args...) - return err -} - -func (e *Events) setTimeout(callback goja.Callable, delay float64, args ...goja.Value) uint32 { - runOnLoop := e.vu.RegisterCallback() - id, stopCh := e.getTimerStopCh() - - if delay < 0 { - delay = 0 - } - - go func() { - timer := time.NewTimer(time.Duration(delay * float64(time.Millisecond))) - defer func() { - e.stopTimerCh(id) - if !timer.Stop() { - <-timer.C - } - }() - - select { - case <-timer.C: - runOnLoop(func() error { - return e.call(callback, args) - }) - case <-stopCh: - runOnLoop(noop) - case <-e.vu.Context().Done(): - e.vu.State().Logger.Warnf("setTimeout %d was stopped because the VU iteration was interrupted", id) - runOnLoop(noop) - } - }() - - return id -} - -func (e *Events) clearTimeout(id uint32) { - e.stopTimerCh(id) -} - -func (e *Events) setInterval(callback goja.Callable, delay float64, args ...goja.Value) uint32 { - runOnLoop := e.vu.RegisterCallback() - id, stopCh := e.getTimerStopCh() - - go func() { - ticker := time.NewTicker(time.Duration(delay * float64(time.Millisecond))) - defer func() { - e.stopTimerCh(id) - ticker.Stop() - }() - - for { - select { - case <-ticker.C: - runOnLoop(func() error { - runOnLoop = e.vu.RegisterCallback() - return e.call(callback, args) - }) - case <-stopCh: - runOnLoop(noop) - return - case <-e.vu.Context().Done(): - e.vu.State().Logger.Warnf("setInterval %d was stopped because the VU iteration was interrupted", id) - runOnLoop(noop) - return - } - } - }() - - return id -} - -func (e *Events) clearInterval(id uint32) { - e.stopTimerCh(id) -} diff --git a/cmd/integration_tests/testmodules/events/events.js b/cmd/integration_tests/testmodules/events/events.js deleted file mode 100644 index 7e5c98fd6e6..00000000000 --- a/cmd/integration_tests/testmodules/events/events.js +++ /dev/null @@ -1,49 +0,0 @@ -import exec from 'k6/execution'; -import { setTimeout, clearTimeout, setInterval, clearInterval } from 'k6/events' - - -export let options = { - scenarios: { - 'foo': { - executor: 'constant-vus', - vus: 1, - duration: '3.8s', - gracefulStop: '0s', - } - } -}; - -function debug(arg) { - let t = String((new Date()) - exec.scenario.startTime).padStart(6, ' ') - console.log(`[${t}ms, iter=${exec.scenario.iterationInTest}] ${arg}`); -} - -export default function () { - debug('default start'); - - let tickCount = 1; - let f0 = (arg) => { - debug(`${arg} ${tickCount++}`); - } - let t0 = setInterval(f0, 500, 'tick') - - let f1 = (arg) => { - debug(arg); - clearInterval(t0); - } - let t1 = setTimeout(f1, 2000, 'third'); - - let t2 = setTimeout(debug, 1500, 'never happening'); - - let f3 = (arg) => { - debug(arg); - clearTimeout(t2); - setTimeout(debug, 600, 'second'); - } - let t3 = setTimeout(f3, 1000, 'first'); - - debug('default end'); - if (exec.scenario.iterationInTest == 1) { - debug(`expected last iter, the interval ID is ${t0}, we also expect timer ${t1} to be interrupted`) - } -} diff --git a/cmd/integration_tests/testmodules/events/events_test.go b/cmd/integration_tests/testmodules/events/events_test.go deleted file mode 100644 index bdcb57cbf3d..00000000000 --- a/cmd/integration_tests/testmodules/events/events_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package events - -import ( - "context" - "testing" - - "github.com/dop251/goja" - "github.com/stretchr/testify/require" - "go.k6.io/k6/js/common" - "go.k6.io/k6/js/eventloop" - "go.k6.io/k6/js/modulestest" -) - -func TestSetTimeout(t *testing.T) { - t.Parallel() - rt := goja.New() - vu := &modulestest.VU{ - RuntimeField: rt, - InitEnvField: &common.InitEnvironment{}, - CtxField: context.Background(), - StateField: nil, - } - - m, ok := New().NewModuleInstance(vu).(*Events) - require.True(t, ok) - var log []string - require.NoError(t, rt.Set("events", m.Exports().Named)) - require.NoError(t, rt.Set("print", func(s string) { log = append(log, s) })) - loop := eventloop.New(vu) - vu.RegisterCallbackField = loop.RegisterCallback - - err := loop.Start(func() error { - _, err := vu.Runtime().RunString(` - events.setTimeout(()=> { - print("in setTimeout") - }) - print("outside setTimeout") - `) - return err - }) - require.NoError(t, err) - require.Equal(t, []string{"outside setTimeout", "in setTimeout"}, log) -} diff --git a/cmd/tests/eventloop_test.go b/cmd/tests/eventloop_test.go new file mode 100644 index 00000000000..2ba7ddbbbbf --- /dev/null +++ b/cmd/tests/eventloop_test.go @@ -0,0 +1,131 @@ +package tests + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd" +) + +func TestEventLoop(t *testing.T) { + t.Parallel() + script := []byte(` + setTimeout(()=> {console.log("initcontext setTimeout")}, 200) + console.log("initcontext"); + export default function() { + setTimeout(()=> {console.log("default setTimeout")}, 200) + console.log("default"); + }; + export function setup() { + setTimeout(()=> {console.log("setup setTimeout")}, 200) + console.log("setup"); + }; + export function teardown() { + setTimeout(()=> {console.log("teardown setTimeout")}, 200) + console.log("teardown"); + }; + export function handleSummary() { + setTimeout(()=> {console.log("handleSummary setTimeout")}, 200) + console.log("handleSummary"); + }; +`) + eventLoopTest(t, script, func(logLines []string) { + require.Equal(t, []string{ + "initcontext", // first initialization + "initcontext setTimeout", + "initcontext", // for vu + "initcontext setTimeout", + "initcontext", // for setup + "initcontext setTimeout", + "setup", // setup + "setup setTimeout", + "default", // one iteration + "default setTimeout", + "initcontext", // for teardown + "initcontext setTimeout", + "teardown", // teardown + "teardown setTimeout", + "initcontext", // for handleSummary + "initcontext setTimeout", + "handleSummary", // handleSummary + "handleSummary setTimeout", + }, logLines) + }) +} + +func TestEventLoopCrossScenario(t *testing.T) { + t.Parallel() + script := []byte(` + import exec from "k6/execution" + export const options = { + scenarios: { + "first":{ + executor: "shared-iterations", + maxDuration: "1s", + iterations: 1, + vus: 1, + gracefulStop:"1s", + }, + "second": { + executor: "shared-iterations", + maxDuration: "1s", + iterations: 1, + vus: 1, + startTime: "3s", + } + } + } + export default function() { + let i = exec.scenario.name + setTimeout(()=> {console.log(i)}, 3000) + } +`) + + eventLoopTest(t, script, func(logLines []string) { + require.Equal(t, []string{ + "setTimeout 1 was stopped because the VU iteration was interrupted", + "second", + }, logLines) + }) +} + +func TestEventLoopDoesntCrossIterations(t *testing.T) { + t.Parallel() + script := []byte(` + import { sleep } from "k6" + export const options = { + iterations: 2, + vus: 1, + } + + export default function() { + let i = __ITER; + setTimeout(()=> { console.log(i) }, 1000) + if (__ITER == 0) { + throw "just error" + } else { + sleep(1) + } + } +`) + + eventLoopTest(t, script, func(logLines []string) { + require.Equal(t, []string{ + "setTimeout 1 was stopped because the VU iteration was interrupted", + "just error\n\tat file:///-:13:4(15)\n", "1", + }, logLines) + }) +} + +func eventLoopTest(t *testing.T, script []byte, testHandle func(logLines []string)) { + ts := NewGlobalTestState(t) + ts.CmdArgs = []string{"k6", "--quiet", "run", "-"} + ts.Stdin = bytes.NewBuffer( + append([]byte("import { setTimeout } from 'k6/experimental/timers';\n"), script...), + ) + + cmd.ExecuteWithGlobalState(ts.GlobalState) + + testHandle(ts.LoggerHook.Lines()) +}