Skip to content

Commit 0fe53e0

Browse files
Local templates support in k6 new (#4618)
* Added support for local template files in the `k6 new` command * Do not create a script file if the template is invalid * Use fsext to read templates in `k6 new` command * Avoid using os package in `k6 new` tests except for the os.Remove() * Refine the test matcher message Co-authored-by: İnanç Gümüş <inanc.gumus@grafana.com> * Update the help message for the new command to include a note about custom templates * Do not shadow the return error in the defer function * Simplify the check whether a template type is a file path * Resolve template file path to an absolute path before reading it --------- Co-authored-by: İnanç Gümüş <inanc.gumus@grafana.com>
1 parent ebb5f67 commit 0fe53e0

File tree

3 files changed

+181
-30
lines changed

3 files changed

+181
-30
lines changed

internal/cmd/new.go

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cmd
22

33
import (
44
"fmt"
5+
"io"
6+
"strings"
57

68
"github.com/spf13/cobra"
79
"github.com/spf13/pflag"
@@ -23,12 +25,12 @@ func (c *newScriptCmd) flagSet() *pflag.FlagSet {
2325
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
2426
flags.SortFlags = false
2527
flags.BoolVarP(&c.overwriteFiles, "force", "f", false, "overwrite existing files")
26-
flags.StringVar(&c.templateType, "template", "minimal", "template type (choices: minimal, protocol, browser)")
28+
flags.StringVar(&c.templateType, "template", "minimal", "template type (choices: minimal, protocol, browser) or relative/absolute path to a custom template file") //nolint:lll
2729
flags.StringVar(&c.projectID, "project-id", "", "specify the Grafana Cloud project ID for the test")
2830
return flags
2931
}
3032

31-
func (c *newScriptCmd) run(_ *cobra.Command, args []string) error {
33+
func (c *newScriptCmd) run(_ *cobra.Command, args []string) (err error) {
3234
target := defaultNewScriptName
3335
if len(args) > 0 {
3436
target = args[0]
@@ -42,27 +44,8 @@ func (c *newScriptCmd) run(_ *cobra.Command, args []string) error {
4244
return fmt.Errorf("%s already exists. Use the `--force` flag to overwrite it", target)
4345
}
4446

45-
fd, err := c.gs.FS.Create(target)
46-
if err != nil {
47-
return err
48-
}
49-
50-
var closeErr error
51-
defer func() {
52-
if cerr := fd.Close(); cerr != nil {
53-
if _, err := fmt.Fprintf(c.gs.Stderr, "error closing file: %v\n", cerr); err != nil {
54-
closeErr = fmt.Errorf("error writing error message to stderr: %w", err)
55-
} else {
56-
closeErr = cerr
57-
}
58-
}
59-
}()
60-
61-
if closeErr != nil {
62-
return closeErr
63-
}
64-
65-
tm, err := templates.NewTemplateManager()
47+
// Initialize template manager and validate template before creating any files
48+
tm, err := templates.NewTemplateManager(c.gs.FS)
6649
if err != nil {
6750
return fmt.Errorf("error initializing template manager: %w", err)
6851
}
@@ -72,12 +55,36 @@ func (c *newScriptCmd) run(_ *cobra.Command, args []string) error {
7255
return fmt.Errorf("error retrieving template: %w", err)
7356
}
7457

58+
// Prepare template arguments
7559
argsStruct := templates.TemplateArgs{
7660
ScriptName: target,
7761
ProjectID: c.projectID,
7862
}
7963

80-
if err := templates.ExecuteTemplate(fd, tmpl, argsStruct); err != nil {
64+
// First render the template to a buffer to validate it
65+
var buf strings.Builder
66+
if err := templates.ExecuteTemplate(&buf, tmpl, argsStruct); err != nil {
67+
return fmt.Errorf("failed to execute template %s: %w", c.templateType, err)
68+
}
69+
70+
// Only create the file after template rendering succeeds
71+
fd, err := c.gs.FS.Create(target)
72+
if err != nil {
73+
return err
74+
}
75+
76+
defer func() {
77+
if cerr := fd.Close(); cerr != nil {
78+
if _, werr := fmt.Fprintf(c.gs.Stderr, "error closing file: %v\n", cerr); werr != nil {
79+
err = fmt.Errorf("error writing error message to stderr: %w", werr)
80+
} else {
81+
err = cerr
82+
}
83+
}
84+
}()
85+
86+
// Write the rendered content to the file
87+
if _, err := io.WriteString(fd, buf.String()); err != nil {
8188
return err
8289
}
8390

internal/cmd/new_test.go

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"path/filepath"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -99,11 +100,15 @@ func TestNewScriptCmd_InvalidTemplateType(t *testing.T) {
99100

100101
ts := tests.NewGlobalTestState(t)
101102
ts.CmdArgs = []string{"k6", "new", "--template", "invalid-template"}
102-
103103
ts.ExpectedExitCode = -1
104104

105105
newRootCommand(ts.GlobalState).execute()
106106
assert.Contains(t, ts.Stderr.String(), "invalid template type")
107+
108+
// Verify that no script file was created
109+
exists, err := fsext.Exists(ts.FS, defaultNewScriptName)
110+
require.NoError(t, err)
111+
assert.False(t, exists, "script file should not exist")
107112
}
108113

109114
func TestNewScriptCmd_ProjectID(t *testing.T) {
@@ -119,3 +124,101 @@ func TestNewScriptCmd_ProjectID(t *testing.T) {
119124

120125
assert.Contains(t, string(data), "projectID: 1422")
121126
}
127+
128+
func TestNewScriptCmd_LocalTemplate(t *testing.T) {
129+
t.Parallel()
130+
131+
ts := tests.NewGlobalTestState(t)
132+
133+
// Create template file in test temp directory
134+
templatePath := filepath.Join(t.TempDir(), "template.js")
135+
templateContent := `export default function() {
136+
console.log("Hello, world!");
137+
}`
138+
require.NoError(t, fsext.WriteFile(ts.FS, templatePath, []byte(templateContent), 0o600))
139+
140+
ts.CmdArgs = []string{"k6", "new", "--template", templatePath}
141+
142+
newRootCommand(ts.GlobalState).execute()
143+
144+
data, err := fsext.ReadFile(ts.FS, defaultNewScriptName)
145+
require.NoError(t, err)
146+
147+
assert.Equal(t, templateContent, string(data), "generated file should match the template content")
148+
}
149+
150+
func TestNewScriptCmd_LocalTemplateWith_ProjectID(t *testing.T) {
151+
t.Parallel()
152+
153+
ts := tests.NewGlobalTestState(t)
154+
155+
// Create template file in test temp directory
156+
templatePath := filepath.Join(t.TempDir(), "template.js")
157+
templateContent := `export default function() {
158+
// Template with {{ .ProjectID }} project ID
159+
console.log("Hello from project {{ .ProjectID }}");
160+
}`
161+
require.NoError(t, fsext.WriteFile(ts.FS, templatePath, []byte(templateContent), 0o600))
162+
163+
ts.CmdArgs = []string{"k6", "new", "--template", templatePath, "--project-id", "9876"}
164+
165+
newRootCommand(ts.GlobalState).execute()
166+
167+
data, err := fsext.ReadFile(ts.FS, defaultNewScriptName)
168+
require.NoError(t, err)
169+
170+
expectedContent := `export default function() {
171+
// Template with 9876 project ID
172+
console.log("Hello from project 9876");
173+
}`
174+
assert.Equal(t, expectedContent, string(data), "generated file should have project ID interpolated")
175+
}
176+
177+
func TestNewScriptCmd_LocalTemplate_NonExistentFile(t *testing.T) {
178+
t.Parallel()
179+
180+
ts := tests.NewGlobalTestState(t)
181+
ts.ExpectedExitCode = -1
182+
183+
// Use a path that we know doesn't exist in the temp directory
184+
nonExistentPath := filepath.Join(t.TempDir(), "nonexistent.js")
185+
186+
ts.CmdArgs = []string{"k6", "new", "--template", nonExistentPath}
187+
ts.ExpectedExitCode = -1
188+
189+
newRootCommand(ts.GlobalState).execute()
190+
191+
assert.Contains(t, ts.Stderr.String(), "failed to read template file")
192+
193+
// Verify that no script file was created
194+
exists, err := fsext.Exists(ts.FS, defaultNewScriptName)
195+
require.NoError(t, err)
196+
assert.False(t, exists, "script file should not exist")
197+
}
198+
199+
func TestNewScriptCmd_LocalTemplate_SyntaxError(t *testing.T) {
200+
t.Parallel()
201+
202+
ts := tests.NewGlobalTestState(t)
203+
ts.ExpectedExitCode = -1
204+
205+
// Create template file with invalid content in test temp directory
206+
templatePath := filepath.Join(t.TempDir(), "template.js")
207+
invalidTemplateContent := `export default function() {
208+
// Invalid template with {{ .InvalidField }} field
209+
console.log("This will cause an error");
210+
}`
211+
require.NoError(t, fsext.WriteFile(ts.FS, templatePath, []byte(invalidTemplateContent), 0o600))
212+
213+
ts.CmdArgs = []string{"k6", "new", "--template", templatePath, "--project-id", "9876"}
214+
ts.ExpectedExitCode = -1
215+
216+
newRootCommand(ts.GlobalState).execute()
217+
218+
assert.Contains(t, ts.Stderr.String(), "failed to execute template")
219+
220+
// Verify that no script file was created
221+
exists, err := fsext.Exists(ts.FS, defaultNewScriptName)
222+
require.NoError(t, err)
223+
assert.False(t, exists, "script file should not exist")
224+
}

internal/cmd/templates/templates.go

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import (
55
_ "embed"
66
"fmt"
77
"io"
8+
"path/filepath"
9+
"strings"
810
"text/template"
11+
12+
"go.k6.io/k6/lib/fsext"
913
)
1014

1115
//go:embed minimal.js
@@ -18,6 +22,7 @@ var protocolTemplateContent string
1822
var browserTemplateContent string
1923

2024
// Constants for template types
25+
// Template names should not contain path separators to not to be confused with file paths
2126
const (
2227
MinimalTemplate = "minimal"
2328
ProtocolTemplate = "protocol"
@@ -29,10 +34,11 @@ type TemplateManager struct {
2934
minimalTemplate *template.Template
3035
protocolTemplate *template.Template
3136
browserTemplate *template.Template
37+
fs fsext.Fs
3238
}
3339

3440
// NewTemplateManager initializes a new TemplateManager with parsed templates
35-
func NewTemplateManager() (*TemplateManager, error) {
41+
func NewTemplateManager(fs fsext.Fs) (*TemplateManager, error) {
3642
minimalTmpl, err := template.New(MinimalTemplate).Parse(minimalTemplateContent)
3743
if err != nil {
3844
return nil, fmt.Errorf("failed to parse minimal template: %w", err)
@@ -52,21 +58,56 @@ func NewTemplateManager() (*TemplateManager, error) {
5258
minimalTemplate: minimalTmpl,
5359
protocolTemplate: protocolTmpl,
5460
browserTemplate: browserTmpl,
61+
fs: fs,
5562
}, nil
5663
}
5764

5865
// GetTemplate selects the appropriate template based on the type
59-
func (tm *TemplateManager) GetTemplate(templateType string) (*template.Template, error) {
60-
switch templateType {
66+
func (tm *TemplateManager) GetTemplate(tpl string) (*template.Template, error) {
67+
// First check built-in templates
68+
switch tpl {
6169
case MinimalTemplate:
6270
return tm.minimalTemplate, nil
6371
case ProtocolTemplate:
6472
return tm.protocolTemplate, nil
6573
case BrowserTemplate:
6674
return tm.browserTemplate, nil
67-
default:
68-
return nil, fmt.Errorf("invalid template type: %s", templateType)
6975
}
76+
77+
// Then check if it's a file path
78+
if isFilePath(tpl) {
79+
tplPath, err := filepath.Abs(tpl)
80+
if err != nil {
81+
return nil, fmt.Errorf("failed to get absolute path for template %s: %w", tpl, err)
82+
}
83+
84+
// Read the template content using the provided filesystem
85+
content, err := fsext.ReadFile(tm.fs, tplPath)
86+
if err != nil {
87+
return nil, fmt.Errorf("failed to read template file %s: %w", tpl, err)
88+
}
89+
90+
tmpl, err := template.New(filepath.Base(tplPath)).Parse(string(content))
91+
if err != nil {
92+
return nil, fmt.Errorf("failed to parse template file %s: %w", tpl, err)
93+
}
94+
95+
return tmpl, nil
96+
}
97+
98+
// Check if there's a file with this name in current directory
99+
exists, err := fsext.Exists(tm.fs, fsext.JoinFilePath(".", tpl))
100+
if err == nil && exists {
101+
return nil, fmt.Errorf("invalid template type %q, did you mean ./%s?", tpl, tpl)
102+
}
103+
104+
return nil, fmt.Errorf("invalid template type %q", tpl)
105+
}
106+
107+
// isFilePath checks if the given string looks like a file path by detecting path separators
108+
// We assume that built-in template names don't contain path separators
109+
func isFilePath(path string) bool {
110+
return strings.ContainsRune(path, filepath.Separator) || strings.ContainsRune(path, '/')
70111
}
71112

72113
// TemplateArgs represents arguments passed to templates

0 commit comments

Comments
 (0)