From f2b0f3ad8a1b85fe0114ff5e9e4599c968854480 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] 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 | 267 ++++++-------------------- cmd/root_test.go | 122 +----------- cmd/run.go | 25 +-- cmd/run_test.go | 39 ++-- cmd/runtime_options_test.go | 13 +- cmd/scale.go | 9 +- cmd/state/doc.go | 4 + cmd/state/state.go | 168 +++++++++++++++++ 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 +- main.go | 5 +- ui/console/doc.go | 2 + ui/console/writer.go | 41 ++++ 34 files changed, 860 insertions(+), 747 deletions(-) create mode 100644 cmd/state/doc.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 3e0aa7a4440a..0ffb3dc45dd9 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 e7a75c6e56c0..d0db9562ea83 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 d664b608f91f..1b43525c2c36 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 da0c1aaf841d..2b43cb7f1cdd 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 c131ef4db04c..ea0ac8fd756a 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 1dc27c49ba98..6eeea8c71bbf 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 = lib.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 c5c3c1640d09..e8af06bf9ede 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 eeae1b9b9b12..d7b99987ca65 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 e08eada37661..8d963633909b 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 8ffc0ddf69a6..d6eb7e79c776 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, "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 0911966ad569..902ad2ae3d29 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 556ee29d2f6f..8a150dcb52d9 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 df85dd634c01..b6f7e7962b0f 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 3a83260c84d2..71b5ae821e9d 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 19b17f4d0bfa..2864d96c693d 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 db02029f89f8..c6618898f944 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 373cbd285c37..b83a100704c7 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 b83620fc1d65..43324f0820e5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,181 +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 -} +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, } @@ -186,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, @@ -225,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 { @@ -261,21 +114,19 @@ 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()) - +func Execute(gs *state.GlobalState) { newRootCommand(gs).execute() } @@ -284,49 +135,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 } @@ -346,60 +197,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 452ad2471329..f928fddb1634 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 67f7456c15d3..fac3c66e17aa 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 f7b041f60c92..8b4090d9ee3c 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_test.go b/cmd/runtime_options_test.go index c339eabc5f44..81b6ebe949ff 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 0da23c3f424a..b64e14b4f841 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 000000000000..425dc09243da --- /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/state.go b/cmd/state/state.go new file mode 100644 index 000000000000..e3b5cc358f9f --- /dev/null +++ b/cmd/state/state.go @@ -0,0 +1,168 @@ +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/lib" + "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 := lib.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 000000000000..8e980d35c53c --- /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 8d61832fac13..5b91c7311493 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 69a66d19e546..39490ab0c15f 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 510a27cec823..71014534ff7a 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 b492370b9ba6..b8db2f1be550 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 e9b39a2fa4c9..9a9e71d1891e 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/main.go b/main.go index fb58c5f23dd1..1eb81c649883 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "context" + "go.k6.io/k6/cmd" + "go.k6.io/k6/cmd/state" ) func main() { - cmd.Execute() + cmd.Execute(state.NewGlobalState(context.Background())) } diff --git a/ui/console/doc.go b/ui/console/doc.go new file mode 100644 index 000000000000..5f24698f34a7 --- /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 000000000000..9e42e08fe441 --- /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 +}