diff --git a/examples/experimental/fs/bonjour.txt b/examples/experimental/fs/bonjour.txt new file mode 100644 index 00000000000..589326a33ee --- /dev/null +++ b/examples/experimental/fs/bonjour.txt @@ -0,0 +1 @@ +Bonjour, tout le monde! \ No newline at end of file diff --git a/examples/experimental/fs/fs.js b/examples/experimental/fs/fs.js new file mode 100644 index 00000000000..5ff3d0a00b3 --- /dev/null +++ b/examples/experimental/fs/fs.js @@ -0,0 +1,52 @@ +import { open, SeekMode } from "k6/experimental/fs"; + +export const options = { + vus: 100, + iterations: 1000, +}; + +// k6 doesn't support async in the init context. We use a top-level async function for `await`. +// +// Each Virtual User gets its own `file` copy. +// So, operations like `seek` or `read` won't impact other VUs. +let file; +(async function () { + file = await open("bonjour.txt"); +})(); + +export default async function () { + // About information about the file + const fileinfo = await file.stat(); + if (fileinfo.name != "bonjour.txt") { + throw new Error("Unexpected file name"); + } + + const buffer = new Uint8Array(4); + + let totalBytesRead = 0; + while (true) { + // Read into the buffer + const bytesRead = await file.read(buffer); + if (bytesRead == null) { + // EOF + break; + } + + // Do something useful with the content of the buffer + + totalBytesRead += bytesRead; + + // If bytesRead is less than the buffer size, we've read the whole file + if (bytesRead < buffer.byteLength) { + break; + } + } + + // Check that we read the expected number of bytes + if (totalBytesRead != fileinfo.size) { + throw new Error("Unexpected number of bytes read"); + } + + // Seek back to the beginning of the file + await file.seek(0, SeekMode.Start); +} diff --git a/js/initcontext.go b/js/initcontext.go index 52e10cc9809..eeb4c3325d8 100644 --- a/js/initcontext.go +++ b/js/initcontext.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/url" - "path/filepath" "github.com/dop251/goja" "github.com/sirupsen/logrus" @@ -20,18 +19,7 @@ const cantBeUsedOutsideInitContextMsg = `the "%s" function is only available in // contents of a file. If the second argument is "b" it returns an ArrayBuffer // instance, otherwise a string representation. func openImpl(rt *goja.Runtime, fs fsext.Fs, basePWD *url.URL, filename string, args ...string) (goja.Value, error) { - // Here IsAbs should be enough but unfortunately it doesn't handle absolute paths starting from - // the current drive on windows like `\users\noname\...`. Also it makes it more easy to test and - // will probably be need for archive execution under windows if always consider '/...' as an - // absolute path. - if filename[0] != '/' && filename[0] != '\\' && !filepath.IsAbs(filename) { - filename = filepath.Join(basePWD.Path, filename) - } - filename = filepath.Clean(filename) - - if filename[0:1] != fsext.FilePathSeparator { - filename = fsext.FilePathSeparator + filename - } + filename = fsext.Abs(basePWD.Path, filename) data, err := readFile(fs, filename) if err != nil { diff --git a/js/jsmodules.go b/js/jsmodules.go index f5ec36a0d81..b19a1e74aeb 100644 --- a/js/jsmodules.go +++ b/js/jsmodules.go @@ -8,6 +8,7 @@ import ( "go.k6.io/k6/js/modules/k6/data" "go.k6.io/k6/js/modules/k6/encoding" "go.k6.io/k6/js/modules/k6/execution" + "go.k6.io/k6/js/modules/k6/experimental/fs" "go.k6.io/k6/js/modules/k6/experimental/tracing" "go.k6.io/k6/js/modules/k6/grpc" "go.k6.io/k6/js/modules/k6/html" @@ -38,6 +39,7 @@ func getInternalJSModules() map[string]interface{} { "k6/experimental/timers": timers.New(), "k6/experimental/tracing": tracing.New(), "k6/experimental/browser": browser.New(), + "k6/experimental/fs": fs.New(), "k6/net/grpc": grpc.New(), "k6/html": html.New(), "k6/http": http.New(), diff --git a/js/modules/k6/experimental/fs/cache.go b/js/modules/k6/experimental/fs/cache.go new file mode 100644 index 00000000000..2602d2821d2 --- /dev/null +++ b/js/modules/k6/experimental/fs/cache.go @@ -0,0 +1,86 @@ +package fs + +import ( + "fmt" + "io" + "path/filepath" + "sync" + + "github.com/spf13/afero" +) + +// cache is a cache of opened files. +type cache struct { + // openedFiles holds a safe for concurrent use map, holding the content + // of the files that were opened by the user. + // + // Keys are expected to be strings holding the openedFiles' path. + // Values are expected to be byte slices holding the content of the opened file. + // + // That way, we can cache the file's content and avoid opening too many + // file descriptor, and re-reading its content every time the file is opened. + // + // Importantly, this also means that if the + // file is modified from outside of k6, the changes will not be reflected in the file's data. + openedFiles sync.Map +} + +// open retrieves the content of a given file from the specified filesystem (fromFs) and +// stores it in the registry's internal `openedFiles` map. +// +// The function cleans the provided filename using filepath.Clean before using it. +// +// If the file was previously "opened" (and thus cached) by the registry, it +// returns the cached content. Otherwise, it reads the file from the +// filesystem, caches its content, and then returns it. +// +// The function is designed to minimize redundant file reads by leveraging an internal cache (openedFiles). +// In case the cached value is not a byte slice (which should never occur in regular use), it +// panics with a descriptive error. +// +// Parameters: +// - filename: The name of the file to be retrieved. This should be a relative or absolute path. +// - fromFs: The filesystem (from the afero package) from which the file should be read if not already cached. +// +// Returns: +// - A byte slice containing the content of the specified file. +// - An error if there's any issue opening or reading the file. If the file content is +// successfully cached and returned once, subsequent calls will not produce +// file-related errors for the same file, as the cached value will be used. +func (fr *cache) open(filename string, fromFs afero.Fs) (data []byte, err error) { + filename = filepath.Clean(filename) + + if f, ok := fr.openedFiles.Load(filename); ok { + data, ok = f.([]byte) + if !ok { + panic(fmt.Errorf("registry's file %s is not stored as a byte slice", filename)) + } + + return data, nil + } + + // The underlying afero.Fs.Open method will cache the file content during this + // operation. Which will lead to effectively holding the content of the file in memory twice. + // However, as per #1079, we plan to eventually reduce our dependency on afero, and + // expect this issue to be resolved at that point. + // TODO: re-evaluate opening from the FS this once #1079 is resolved. + f, err := fromFs.Open(filename) + if err != nil { + return nil, err + } + defer func() { + cerr := f.Close() + if cerr != nil { + err = fmt.Errorf("failed to close file %s: %w", filename, cerr) + } + }() + + data, err = io.ReadAll(f) + if err != nil { + return nil, err + } + + fr.openedFiles.Store(filename, data) + + return data, nil +} diff --git a/js/modules/k6/experimental/fs/cache_test.go b/js/modules/k6/experimental/fs/cache_test.go new file mode 100644 index 00000000000..b8dd7218ec6 --- /dev/null +++ b/js/modules/k6/experimental/fs/cache_test.go @@ -0,0 +1,70 @@ +package fs + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestFileCacheOpen(t *testing.T) { + t.Parallel() + + t.Run("open succeeds", func(t *testing.T) { + t.Parallel() + + cache := &cache{} + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, "bonjour.txt", []byte("Bonjour, le monde"), 0o644) + }) + + _, gotBeforeOk := cache.openedFiles.Load("bonjour.txt") + gotData, gotErr := cache.open("bonjour.txt", fs) + _, gotAfterOk := cache.openedFiles.Load("bonjour.txt") + + assert.False(t, gotBeforeOk) + assert.NoError(t, gotErr) + assert.Equal(t, []byte("Bonjour, le monde"), gotData) + assert.True(t, gotAfterOk) + }) + + t.Run("double open succeeds", func(t *testing.T) { + t.Parallel() + + cache := &cache{} + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, "bonjour.txt", []byte("Bonjour, le monde"), 0o644) + }) + + firstData, firstErr := cache.open("bonjour.txt", fs) + _, gotFirstOk := cache.openedFiles.Load("bonjour.txt") + secondData, secondErr := cache.open("bonjour.txt", fs) + _, gotSecondOk := cache.openedFiles.Load("bonjour.txt") + + assert.True(t, gotFirstOk) + assert.NoError(t, firstErr) + assert.Equal(t, []byte("Bonjour, le monde"), firstData) + assert.True(t, gotSecondOk) + assert.NoError(t, secondErr) + assert.True(t, sameUnderlyingArray(firstData, secondData)) + assert.Equal(t, []byte("Bonjour, le monde"), secondData) + }) +} + +// sameUnderlyingArray returns true if the underlying array of lhs and rhs are the same. +// +// This is done by checking that the two slices have a capacity greater than 0 and that +// the last element of the underlying array is the same for both slices. +// +// Once a slice is created, its starting address can move forward, but can never move +// behond its starting address + its capacity, which is a fixed value for any Go slice. +// +// Hence, if the last element of the underlying array is the same for both slices, it +// means that the underlying array is the same. +// +// See [explanation] for more details. +// +// [explanation]: https://groups.google.com/g/golang-nuts/c/ks1jvoyMYuc?pli=1 +func sameUnderlyingArray(lhs, rhs []byte) bool { + return cap(lhs) > 0 && cap(rhs) > 0 && &lhs[0:cap(lhs)][cap(lhs)-1] == &rhs[0:cap(rhs)][cap(rhs)-1] +} diff --git a/js/modules/k6/experimental/fs/errors.go b/js/modules/k6/experimental/fs/errors.go new file mode 100644 index 00000000000..95fcb9e0167 --- /dev/null +++ b/js/modules/k6/experimental/fs/errors.go @@ -0,0 +1,71 @@ +package fs + +// newFsError creates a new Error object of the provided kind and with the +// provided message. +func newFsError(k errorKind, message string) *fsError { + return &fsError{ + Name: k.String(), + Message: message, + kind: k, + } +} + +// errorKind indicates the kind of file system error that has occurred. +// +// Its string representation is generated by the `enumer` tool. The +// `enumer` tool is run by the `go generate` command. See the `go generate` +// command documentation. +// The tool itself is not tracked as part of the k6 go.mod file, and +// therefore must be installed manually using `go install github.com/dmarkham/enumer`. +// +//go:generate enumer -type=errorKind -output errors_gen.go +type errorKind uint8 + +const ( + // NotFoundError is emitted when a file is not found. + NotFoundError errorKind = iota + 1 + + // InvalidResourceError is emitted when a resource is invalid: for + // instance when attempting to open a directory, which is not supported. + InvalidResourceError + + // ForbiddenError is emitted when an operation is forbidden. + ForbiddenError + + // TypeError is emitted when an incorrect type has been used. + TypeError + + // EOFError is emitted when the end of a file has been reached. + EOFError +) + +// fsError represents a custom error object emitted by the fs module. +// +// It is used to provide a more detailed error message to the user, and +// provide a concrete error type that can be used to differentiate between +// different types of errors. +// +// Exposing error types to the user in a way that's compatible with some +// JavaScript error handling constructs such as `instanceof` is still non-trivial +// in Go. See the [dedicated goja issue] with have opened for more details. +// +// [dedicated goja issue]: https://github.com/dop251/goja/issues/529 +type fsError struct { + // Name contains the name of the error as formalized by the [ErrorKind] + // type. + Name string `json:"name"` + + // Message contains the error message as presented to the user. + Message string `json:"message"` + + // kind contains the kind of error that has occurred. + kind errorKind +} + +// Ensure that the Error type implements the Go `error` interface. +var _ error = (*fsError)(nil) + +// Error implements the Go `error` interface. +func (e *fsError) Error() string { + return e.Name + ": " + e.Message +} diff --git a/js/modules/k6/experimental/fs/errors_gen.go b/js/modules/k6/experimental/fs/errors_gen.go new file mode 100644 index 00000000000..feb369058af --- /dev/null +++ b/js/modules/k6/experimental/fs/errors_gen.go @@ -0,0 +1,91 @@ +// Code generated by "enumer -type=errorKind -output errors_gen.go"; DO NOT EDIT. + +package fs + +import ( + "fmt" + "strings" +) + +const _errorKindName = "NotFoundErrorInvalidResourceErrorForbiddenErrorTypeErrorEOFError" + +var _errorKindIndex = [...]uint8{0, 13, 33, 47, 56, 64} + +const _errorKindLowerName = "notfounderrorinvalidresourceerrorforbiddenerrortypeerroreoferror" + +func (i errorKind) String() string { + i -= 1 + if i >= errorKind(len(_errorKindIndex)-1) { + return fmt.Sprintf("errorKind(%d)", i+1) + } + return _errorKindName[_errorKindIndex[i]:_errorKindIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _errorKindNoOp() { + var x [1]struct{} + _ = x[NotFoundError-(1)] + _ = x[InvalidResourceError-(2)] + _ = x[ForbiddenError-(3)] + _ = x[TypeError-(4)] + _ = x[EOFError-(5)] +} + +var _errorKindValues = []errorKind{NotFoundError, InvalidResourceError, ForbiddenError, TypeError, EOFError} + +var _errorKindNameToValueMap = map[string]errorKind{ + _errorKindName[0:13]: NotFoundError, + _errorKindLowerName[0:13]: NotFoundError, + _errorKindName[13:33]: InvalidResourceError, + _errorKindLowerName[13:33]: InvalidResourceError, + _errorKindName[33:47]: ForbiddenError, + _errorKindLowerName[33:47]: ForbiddenError, + _errorKindName[47:56]: TypeError, + _errorKindLowerName[47:56]: TypeError, + _errorKindName[56:64]: EOFError, + _errorKindLowerName[56:64]: EOFError, +} + +var _errorKindNames = []string{ + _errorKindName[0:13], + _errorKindName[13:33], + _errorKindName[33:47], + _errorKindName[47:56], + _errorKindName[56:64], +} + +// errorKindString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func errorKindString(s string) (errorKind, error) { + if val, ok := _errorKindNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _errorKindNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to errorKind values", s) +} + +// errorKindValues returns all values of the enum +func errorKindValues() []errorKind { + return _errorKindValues +} + +// errorKindStrings returns a slice of all String values of the enum +func errorKindStrings() []string { + strs := make([]string, len(_errorKindNames)) + copy(strs, _errorKindNames) + return strs +} + +// IsAerrorKind returns "true" if the value is listed in the enum definition. "false" otherwise +func (i errorKind) IsAerrorKind() bool { + for _, v := range _errorKindValues { + if i == v { + return true + } + } + return false +} diff --git a/js/modules/k6/experimental/fs/file.go b/js/modules/k6/experimental/fs/file.go new file mode 100644 index 00000000000..35bce6c242d --- /dev/null +++ b/js/modules/k6/experimental/fs/file.go @@ -0,0 +1,127 @@ +package fs + +import ( + "io" + "path/filepath" +) + +// file is an abstraction for interacting with files. +type file struct { + path string + + // data holds a pointer to the file's data + data []byte + + // offset holds the current offset in the file + offset int +} + +// Stat returns a FileInfo describing the named file. +func (f *file) stat() *FileInfo { + filename := filepath.Base(f.path) + return &FileInfo{Name: filename, Size: len(f.data)} +} + +// FileInfo holds information about a file. +type FileInfo struct { + // Name holds the base name of the file. + Name string `json:"name"` + + // Size holds the size of the file in bytes. + Size int `json:"size"` +} + +// Read reads up to len(into) bytes into the provided byte slice. +// +// It returns the number of bytes read (0 <= n <= len(into)) and any error +// encountered. +// +// If the end of the file has been reached, it returns EOFError. +func (f *file) Read(into []byte) (n int, err error) { + start := f.offset + if start == len(f.data) { + return 0, newFsError(EOFError, "EOF") + } + + end := f.offset + len(into) + if end > len(f.data) { + end = len(f.data) + // We align with the [io.Reader.Read] method's behavior + // and return EOFError when we reach the end of the + // file, regardless of how much data we were able to + // read. + err = newFsError(EOFError, "EOF") + } + + n = copy(into, f.data[start:end]) + + f.offset += n + + return n, err +} + +// Ensure that `file` implements the io.Reader interface. +var _ io.Reader = (*file)(nil) + +// Seek sets the offset for the next operation on the file, under the mode given by `whence`. +// +// `offset` indicates the number of bytes to move the offset. Based on +// the `whence` parameter, the offset is set relative to the start, +// current offset or end of the file. +// +// When using SeekModeStart, the offset must be positive. +// Negative offsets are allowed when using `SeekModeCurrent` or `SeekModeEnd`. +func (f *file) Seek(offset int, whence SeekMode) (int, error) { + newOffset := f.offset + + switch whence { + case SeekModeStart: + if offset < 0 { + return 0, newFsError(TypeError, "offset cannot be negative when using SeekModeStart") + } + + newOffset = offset + case SeekModeCurrent: + newOffset += offset + case SeekModeEnd: + if offset > 0 { + return 0, newFsError(TypeError, "offset cannot be positive when using SeekModeEnd") + } + + newOffset = len(f.data) + offset + default: + return 0, newFsError(TypeError, "invalid seek mode") + } + + if newOffset < 0 { + return 0, newFsError(TypeError, "seeking before start of file") + } + + if newOffset > len(f.data) { + return 0, newFsError(TypeError, "seeking beyond end of file") + } + + // Note that the implementation assumes one `file` instance per file/vu. + // If that assumption was invalidated, we would need to atomically update + // the offset instead. + f.offset = newOffset + + return newOffset, nil +} + +// SeekMode is used to specify the seek mode when seeking in a file. +type SeekMode = int + +const ( + // SeekModeStart sets the offset relative to the start of the file. + SeekModeStart SeekMode = iota + 1 + + // SeekModeCurrent seeks relative to the current offset. + SeekModeCurrent + + // SeekModeEnd seeks relative to the end of the file. + // + // When using this mode the seek operation will move backwards from + // the end of the file. + SeekModeEnd +) diff --git a/js/modules/k6/experimental/fs/file_test.go b/js/modules/k6/experimental/fs/file_test.go new file mode 100644 index 00000000000..e960e590e50 --- /dev/null +++ b/js/modules/k6/experimental/fs/file_test.go @@ -0,0 +1,257 @@ +package fs + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFileImpl(t *testing.T) { + t.Parallel() + + t.Run("read", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + into []byte + fileData []byte + offset int + wantInto []byte + wantN int + wantErr errorKind + }{ + { + name: "reading the entire file into a buffer fitting the whole file should succeed", + into: make([]byte, 5), + fileData: []byte("hello"), + offset: 0, + wantInto: []byte("hello"), + wantN: 5, + wantErr: 0, // No error expected + }, + { + name: "reading a file larger than the provided buffer should succeed", + into: make([]byte, 3), + fileData: []byte("hello"), + offset: 0, + wantInto: []byte("hel"), + wantN: 3, + wantErr: 0, // No error expected + }, + { + name: "reading a file larger than the provided buffer at an offset should succeed", + into: make([]byte, 3), + fileData: []byte("hello"), + offset: 2, + wantInto: []byte("llo"), + wantN: 3, + wantErr: 0, // No error expected + }, + { + name: "reading file data into a zero sized buffer should succeed", + into: []byte{}, + fileData: []byte("hello"), + offset: 0, + wantInto: []byte{}, + wantN: 0, + wantErr: 0, // No error expected + }, + { + name: "reading past the end of the file should fill the buffer and fail with EOF", + into: make([]byte, 10), + fileData: []byte("hello"), + offset: 0, + wantInto: []byte{'h', 'e', 'l', 'l', 'o', 0, 0, 0, 0, 0}, + wantN: 5, + wantErr: EOFError, + }, + { + name: "reading into a prefilled buffer overrides its content", + into: []byte("world!"), + fileData: []byte("hello"), + offset: 0, + wantInto: []byte("hello!"), + wantN: 5, + wantErr: EOFError, + }, + { + name: "reading an empty file should fail with EOF", + into: make([]byte, 10), + fileData: []byte{}, + offset: 0, + wantInto: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + wantN: 0, + wantErr: EOFError, + }, + { + name: "reading from the end of a file should fail with EOF", + into: make([]byte, 10), + // Note that the offset is larger than the file size + fileData: []byte("hello"), + offset: 5, + wantInto: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + wantN: 0, + wantErr: EOFError, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + f := &file{ + path: "", + data: tc.fileData, + offset: tc.offset, + } + + gotN, err := f.Read(tc.into) + + // Cast the error to your custom error type to access its kind + var gotErr errorKind + if err != nil { + var fsErr *fsError + ok := errors.As(err, &fsErr) + if !ok { + t.Fatalf("unexpected error type: got %T, want %T", err, &fsError{}) + } + gotErr = fsErr.kind + } + + if gotN != tc.wantN || gotErr != tc.wantErr { + t.Errorf("Read() = %d, %v, want %d, %v", gotN, gotErr, tc.wantN, tc.wantErr) + } + + if !bytes.Equal(tc.into, tc.wantInto) { + t.Errorf("Read() into = %v, want %v", tc.into, tc.wantInto) + } + }) + } + }) + + t.Run("seek", func(t *testing.T) { + t.Parallel() + + type args struct { + offset int + whence SeekMode + } + + // The test file is 100 bytes long + tests := []struct { + name string + fileOffset int + args args + wantOffset int + wantError bool + }{ + { + name: "seek using SeekModeStart within file bounds should succeed", + fileOffset: 0, + args: args{50, SeekModeStart}, + wantOffset: 50, + wantError: false, + }, + { + name: "seek using SeekModeStart beyond file boundaries should fail", + fileOffset: 0, + args: args{150, SeekModeStart}, + wantOffset: 0, + wantError: true, + }, + { + name: "seek using SeekModeStart and a negative offset should fail", + fileOffset: 0, + args: args{-50, SeekModeStart}, + wantOffset: 0, + wantError: true, + }, + { + name: "seek using SeekModeCurrent within file bounds at offset 0 should succeed", + fileOffset: 0, + args: args{10, SeekModeCurrent}, + wantOffset: 10, + wantError: false, + }, + { + name: "seek using SeekModeCurrent within file bounds at non-zero offset should succeed", + fileOffset: 20, + args: args{10, SeekModeCurrent}, + wantOffset: 30, + wantError: false, + }, + { + name: "seek using SeekModeCurrent beyond file boundaries should fail", + fileOffset: 20, + args: args{100, SeekModeCurrent}, + wantOffset: 20, + wantError: true, + }, + { + name: "seek using SeekModeCurrent and a negative offset should succeed", + fileOffset: 20, + args: args{-10, SeekModeCurrent}, + wantOffset: 10, + wantError: false, + }, + { + name: "seek using SeekModeCurrent and an out of bound negative offset should fail", + fileOffset: 20, + args: args{-40, SeekModeCurrent}, + wantOffset: 20, + wantError: true, + }, + { + name: "seek using SeekModeEnd within file bounds should succeed", + fileOffset: 20, + args: args{-20, SeekModeEnd}, + wantOffset: 80, + wantError: false, + }, + { + name: "seek using SeekModeEnd beyond file start should fail", + fileOffset: 20, + args: args{-110, SeekModeEnd}, + wantOffset: 20, + wantError: true, // File is 100 bytes long + }, + { + name: "seek using SeekModeEnd and a positive offset should fail", + fileOffset: 20, + args: args{10, SeekModeEnd}, + wantOffset: 20, + wantError: true, + }, + { + name: "seek with invalid whence should fail", + fileOffset: 0, + args: args{10, SeekMode(42)}, + wantOffset: 0, + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f := &file{data: make([]byte, 100), offset: tt.fileOffset} + + got, err := f.Seek(tt.args.offset, tt.args.whence) + if tt.wantError { + assert.Error(t, err, tt.name) + } else { + assert.NoError(t, err, tt.name) + assert.Equal(t, tt.wantOffset, got, tt.name) + } + }) + } + }) +} diff --git a/js/modules/k6/experimental/fs/module.go b/js/modules/k6/experimental/fs/module.go new file mode 100644 index 00000000000..0f0a098bf20 --- /dev/null +++ b/js/modules/k6/experimental/fs/module.go @@ -0,0 +1,361 @@ +// Package fs provides a k6 module that allows users to interact with files from the +// local filesystem. +package fs + +import ( + "errors" + "fmt" + + "github.com/dop251/goja" + "go.k6.io/k6/js/common" + "go.k6.io/k6/js/modules" + "go.k6.io/k6/js/promises" + "go.k6.io/k6/lib/fsext" +) + +type ( + // RootModule is the global module instance that will create instances of our + // module for each VU. + RootModule struct { + cache *cache + } + + // ModuleInstance represents an instance of the fs module for a single VU. + ModuleInstance struct { + vu modules.VU + cache *cache + } +) + +var ( + _ modules.Module = &RootModule{} + _ modules.Instance = &ModuleInstance{} +) + +// New returns a pointer to a new [RootModule] instance. +func New() *RootModule { + return &RootModule{ + cache: &cache{}, + } +} + +// NewModuleInstance implements the modules.Module interface and returns a new +// instance of our module for the given VU. +func (rm *RootModule) NewModuleInstance(vu modules.VU) modules.Instance { + return &ModuleInstance{vu: vu, cache: rm.cache} +} + +// Exports implements the modules.Module interface and returns the exports of +// our module. +func (mi *ModuleInstance) Exports() modules.Exports { + return modules.Exports{ + Named: map[string]any{ + "open": mi.Open, + "readAll": mi.ReadAll, + "SeekMode": map[string]any{ + "Start": SeekModeStart, + "Current": SeekModeCurrent, + "End": SeekModeEnd, + }, + }, + } +} + +// Open opens a file and returns a promise that will resolve to a [File] instance +func (mi *ModuleInstance) Open(path goja.Value) *goja.Promise { + promise, resolve, reject := promises.New(mi.vu) + + // Files can only be opened in the init context. + if mi.vu.State() != nil { + reject(newFsError(ForbiddenError, "open() failed; reason: opening a file in the VU context is forbidden")) + return promise + } + + if common.IsNullish(path) { + reject(newFsError(TypeError, "open() failed; reason: path cannot be null or undefined")) + return promise + } + + // Obtain the underlying path string from the JS value. + pathStr := path.String() + if pathStr == "" { + reject(newFsError(TypeError, "open() failed; reason: path cannot be empty")) + return promise + } + + go func() { + file, err := mi.openImpl(pathStr) + if err != nil { + reject(err) + return + } + + resolve(file) + }() + + return promise +} + +// ReadAll reads the provide file object's content until EOF (`null`) and resolves to +// the content as an `ArrayBuffer`. +// +// Note that this method will read the file's content from the current offset, and will +// move the offset to the end of the file. +func (mi *ModuleInstance) ReadAll(file goja.Value) *goja.Promise { + promise, resolve, reject := promises.New(mi.vu) + + if common.IsNullish(file) { + reject(newFsError(TypeError, "readAll() failed; reason: the file argument cannot be null or undefined")) + return promise + } + + var fileInstance File + if err := mi.vu.Runtime().ExportTo(file, &fileInstance); err != nil { + reject(newFsError(TypeError, "readAll() failed; reason: the file argument cannot be interpreted as a File")) + return promise + } + + go func() { + bytesLeft := len(fileInstance.file.data) - fileInstance.file.offset + data := make([]byte, bytesLeft) + + n, err := fileInstance.file.Read(data) + if err != nil { + errMsg := "readAll() failed; reason: %w" + + var fsErr *fsError + isFsErr := errors.As(err, &fsErr) + if !isFsErr { + reject(fmt.Errorf(errMsg, err)) + return + } + + // If we reached the end of the file, we resolve to null. + if fsErr.kind == EOFError { + resolve(goja.Null()) + } else { + reject(fmt.Errorf(errMsg, err)) + } + + return + } + + if n != bytesLeft { + reject(newFsError( + TypeError, + fmt.Sprintf("readAll() failed; reason: read %d bytes, expected %d", + n, + len(fileInstance.file.data))), + ) + return + } + + resolve(mi.vu.Runtime().NewArrayBuffer(data)) + }() + + return promise +} + +// File represents a file and exposes methods to interact with it. +// +// It is a wrapper around the [file] struct, which is meant to be directly +// exposed to the JS runtime. +type File struct { + // Path holds the name of the file, as presented to [Open]. + Path string `json:"path"` + + // file contains the actual implementation for the file system. + *file + + // vu holds a reference to the VU this file is associated with. + // + // We need this to be able to access the VU's runtime, and produce + // promises that are handled by the VU's runtime. + vu modules.VU + + // registry holds a pointer to the file registry this file is associated + // with. That way we are able to close the file when it's not needed + // anymore. + registry *cache +} + +// Stat returns a promise that will resolve to a [FileInfo] instance describing +// the file. +func (f *File) Stat() *goja.Promise { + promise, resolve, _ := promises.New(f.vu) + + go func() { + resolve(f.file.stat()) + }() + + return promise +} + +// Read the file's content, and writes it into the provided Uint8Array. +// +// Resolves to either the number of bytes read during the operation +// or EOF (null) if there was nothing more to read. +// +// It is possible for a read to successfully return with 0 bytes. +// This does not indicate EOF. +func (f *File) Read(into goja.Value) *goja.Promise { + promise, resolve, reject := promises.New(f.vu) + + if common.IsNullish(into) { + reject(newFsError(TypeError, "read() failed; reason: into cannot be null or undefined")) + return promise + } + + // We expect the into argument to be a `Uint8Array` instance + intoObj := into.ToObject(f.vu.Runtime()) + uint8ArrayConstructor := f.vu.Runtime().Get("Uint8Array") + if isUint8Array := intoObj.Get("constructor").SameAs(uint8ArrayConstructor); !isUint8Array { + reject(newFsError(TypeError, "read() failed; reason: into argument must be a Uint8Array")) + return promise + } + + // Obtain the underlying ArrayBuffer from the Uint8Array + ab, ok := intoObj.Get("buffer").Export().(goja.ArrayBuffer) + if !ok { + reject(newFsError(TypeError, "read() failed; reason: into argument cannot be interpreted as ArrayBuffer")) + return promise + } + + // Obtain the underlying byte slice from the ArrayBuffer. + // Note that this is not a copy, and will be modified by the Read operation + // in place. + buffer := ab.Bytes() + + go func() { + n, err := f.file.Read(buffer) + if err == nil { + resolve(n) + return + } + + // The [file.Read] method will return an EOFError as soon as it reached + // the end of the file. + // + // However, following deno's behavior, we express + // EOF to users by returning null, when and only when there aren't any + // more bytes to read. + // + // Thus, although the [file.Read] method will return an EOFError, and + // an n > 0, we make sure to take the EOFError returned into consideration + // only when n == 0. + var fsErr *fsError + isFsErr := errors.As(err, &fsErr) + if isFsErr { + if fsErr.kind == EOFError && n == 0 { + resolve(nil) + } else { + resolve(n) + } + } else { + reject(err) + } + }() + + return promise +} + +// Seek seeks to the given `offset` in the file, under the given `whence` mode. +// +// The returned promise resolves to the new `offset` (position) within the file, which +// is expressed in bytes from the selected start, current, or end position depending +// the provided `whence`. +func (f *File) Seek(offset goja.Value, whence goja.Value) *goja.Promise { + promise, resolve, reject := promises.New(f.vu) + + if common.IsNullish(offset) { + reject(newFsError(TypeError, "seek() failed; reason: the offset argument cannot be null or undefined")) + return promise + } + + var intOffset int64 + if err := f.vu.Runtime().ExportTo(offset, &intOffset); err != nil { + reject(newFsError(TypeError, "seek() failed; reason: the offset argument cannot be interpreted as integer")) + return promise + } + + if common.IsNullish(whence) { + reject(newFsError(TypeError, "seek() failed; reason: the whence argument cannot be null or undefined")) + return promise + } + + var intWhence int64 + if err := f.vu.Runtime().ExportTo(whence, &intWhence); err != nil { + reject(newFsError(TypeError, "seek() failed; reason: the whence argument cannot be interpreted as integer")) + return promise + } + + seekMode := SeekMode(intWhence) + switch seekMode { + case SeekModeStart, SeekModeCurrent, SeekModeEnd: + // Valid modes, do nothing. + default: + reject(newFsError(TypeError, "seek() failed; reason: the whence argument must be a SeekMode")) + return promise + } + + go func() { + newOffset, err := f.file.Seek(int(intOffset), seekMode) + if err != nil { + reject(err) + return + } + + resolve(newOffset) + }() + + return promise +} + +func (mi *ModuleInstance) openImpl(path string) (*File, error) { + initEnv := mi.vu.InitEnv() + + // We resolve the path relative to the entrypoint script, as opposed to + // the current working directory (the k6 command is called from). + // + // This is done on purpose, although it diverges in some respect with + // how files are handled in different k6 contexts, so that we cater to + // and intuitive user experience. + // + // See #2781 and #2674. + path = fsext.Abs(initEnv.CWD.Path, path) + + fs, ok := initEnv.FileSystems["file"] + if !ok { + common.Throw(mi.vu.Runtime(), errors.New("open() failed; reason: unable to access the file system")) + } + + if exists, err := fsext.Exists(fs, path); err != nil { + return nil, fmt.Errorf("open() failed, unable to verify if %q exists; reason: %w", path, err) + } else if !exists { + return nil, newFsError(NotFoundError, fmt.Sprintf("no such file or directory %q", path)) + } + + if isDir, err := fsext.IsDir(fs, path); err != nil { + return nil, fmt.Errorf("open() failed, unable to verify if %q is a directory; reason: %w", path, err) + } else if isDir { + return nil, newFsError( + InvalidResourceError, + fmt.Sprintf("cannot open %q: opening a directory is not supported", path), + ) + } + + data, err := mi.cache.open(path, fs) + if err != nil { + return nil, err + } + + return &File{ + Path: path, + file: &file{ + path: path, + data: data, + }, + vu: mi.vu, + registry: mi.cache, + }, nil +} diff --git a/js/modules/k6/experimental/fs/module_test.go b/js/modules/k6/experimental/fs/module_test.go new file mode 100644 index 00000000000..d59f59f186c --- /dev/null +++ b/js/modules/k6/experimental/fs/module_test.go @@ -0,0 +1,676 @@ +package fs + +import ( + "fmt" + "net/url" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.k6.io/k6/js/compiler" + "go.k6.io/k6/js/modulestest" + "go.k6.io/k6/lib" + "go.k6.io/k6/lib/fsext" + "go.k6.io/k6/metrics" +) + +func TestOpen(t *testing.T) { + t.Parallel() + + t.Run("opening existing file should succeed", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + openPath string + wantPath string + }{ + { + name: "open absolute path", + openPath: fsext.FilePathSeparator + "bonjour.txt", + wantPath: fsext.FilePathSeparator + "bonjour.txt", + }, + { + name: "open relative path", + openPath: filepath.Join(".", fsext.FilePathSeparator, "bonjour.txt"), + wantPath: fsext.FilePathSeparator + "bonjour.txt", + }, + { + name: "open path with ..", + openPath: fsext.FilePathSeparator + "dir" + fsext.FilePathSeparator + ".." + fsext.FilePathSeparator + "bonjour.txt", + wantPath: fsext.FilePathSeparator + "bonjour.txt", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + fs := newTestFs(t, func(fs afero.Fs) error { + fileErr := afero.WriteFile(fs, tt.wantPath, []byte("Bonjour, le monde"), 0o644) + if fileErr != nil { + return fileErr + } + + return fs.Mkdir(fsext.FilePathSeparator+"dir", 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + runtime.VU.InitEnvField.CWD = &url.URL{Scheme: "file", Path: fsext.FilePathSeparator} + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + let file; + try { + file = await fs.open(%q) + } catch (err) { + throw "unexpected error: " + err + } + + if (file.path !== %q) { + throw 'unexpected file path ' + file.path + '; expected %q'; + } + `, tt.openPath, tt.wantPath, tt.wantPath))) + + assert.NoError(t, err) + }) + } + }) + + t.Run("opening file in VU context should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + runtime.MoveToVUContext(&lib.State{ + Tags: lib.NewVUStateTags(metrics.NewRegistry().RootTagSet().With("tag-vu", "mytag")), + }) + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(` + try { + const file = await fs.open('bonjour.txt') + throw 'unexpected promise resolution with result: ' + file; + } catch (err) { + if (err.name !== 'ForbiddenError') { + throw 'unexpected error: ' + err + } + } + + `)) + + assert.NoError(t, err) + }) + + t.Run("calling open without providing a path should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(` + let file; + + try { + file = await fs.open() + throw 'unexpected promise resolution with result: ' + file; + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err + } + } + + try { + file = await fs.open(null) + throw 'unexpected promise resolution with result: ' + file; + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err + } + } + + try { + file = await fs.open(undefined) + throw 'unexpected promise resolution with result: ' + file; + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err + } + } + `)) + + assert.NoError(t, err) + }) + + t.Run("opening directory should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testDirPath := fsext.FilePathSeparator + "dir" + fs := newTestFs(t, func(fs afero.Fs) error { + return fs.Mkdir(testDirPath, 0o644) + }) + + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + try { + const file = await fs.open(%q) + throw 'unexpected promise resolution with result: ' + res + } catch (err) { + if (err.name !== 'InvalidResourceError') { + throw 'unexpected error: ' + err + } + } + `, testDirPath))) + + assert.NoError(t, err) + }) + + t.Run("opening non existing file should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(` + try { + const file = await fs.open('doesnotexist.txt') + throw 'unexpected promise resolution with result: ' + res + } catch (err) { + if (err.name !== 'NotFoundError') { + throw 'unexpected error: ' + err + } + } + `)) + + assert.NoError(t, err) + }) +} + +func TestReadAll(t *testing.T) { + t.Parallel() + + t.Run("reading all bytes from a file should succeed", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q) + const fileContent = await fs.readAll(file) + + if (fileContent.byteLength !== 5) { + throw 'unexpected file content length ' + fileContent.length + '; expected 5'; + } + + // transform the ArrayBuffer into a string + const uint8Array = new Uint8Array(fileContent); + const str = String.fromCharCode.apply(null, uint8Array) + + if (str !== 'hello') { + throw 'unexpected file content ' + str + '; expected hello'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("reading all bytes from the middle of a file should succeed", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q) + + // Seek to the middle of the file and read all bytes left. + await file.seek(2, fs.SeekMode.Start); + const fileContent = await fs.readAll(file) + + if (fileContent.byteLength !== 3) { + throw 'unexpected file content length ' + fileContent.length + '; expected 3'; + } + + // The file content should be 'llo'. + const uint8Array = new Uint8Array(fileContent); + const str = String.fromCharCode.apply(null, uint8Array) + + if (str !== 'llo') { + throw 'unexpected file content ' + str + '; expected llo'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("reading all bytes from EOF should return null and succeed", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q) + + // Reading the whole file to move the offset to EOF. + await fs.readAll(file) + + // Reading from EOF should return null. + const fileContent = await fs.readAll(file) + if (fileContent !== null) { + throw 'expected readAll to return null, got ' + fileContent + ' instead'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) +} + +func TestFile(t *testing.T) { + t.Parallel() + + t.Run("stat method should succeed", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("Bonjour, le monde"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q) + const info = await file.stat() + + if (info.name !== 'bonjour.txt') { + throw 'unexpected file name ' + info.name + '; expected \'bonjour.txt\''; + } + + if (info.size !== 17) { + throw 'unexpected file size ' + info.size + '; expected 17'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("read in multiple iterations", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q); + + let fileContent = new Uint8Array(5); + + let bytesRead; + let buffer = new Uint8Array(3); + + bytesRead = await file.read(buffer) + if (bytesRead !== 3) { + throw 'expected read to return 3, got ' + bytesRead + ' instead'; + } + + fileContent.set(buffer, 0); + + bytesRead = await file.read(buffer) + if (bytesRead !== 2) { + throw 'expected read to return 2, got ' + bytesRead + ' instead'; + } + + fileContent.set(buffer.subarray(0, bytesRead), 3); + + bytesRead = await file.read(buffer) + if (bytesRead !== null) { + throw 'expected read to return null, got ' + bytesRead + ' instead'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("read called when end of file reached should return null and succeed", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q); + let buffer = new Uint8Array(5); + + // Reading the whole file should return 5. + let bytesRead = await file.read(buffer); + if (bytesRead !== 5) { + throw 'expected read to return 5, got ' + bytesRead + ' instead'; + } + + // Reading from the end of the file should return null. + bytesRead = await file.read(buffer); + if (bytesRead !== null) { + throw 'expected read to return null got ' + bytesRead + ' instead'; + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("read called with invalid argument should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("Bonjour, le monde"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q); + let bytesRead; + + // No argument should fail with TypeError. + try { + bytesRead = await file.read() + } catch(err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err; + } + } + + // null buffer argument should fail with TypeError. + try { + bytesRead = await file.read(null) + } catch(err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err; + } + } + + // undefined buffer argument should fail with TypeError. + try { + bytesRead = await file.read(undefined) + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err; + } + } + + // Invalid typed array argument should fail with TypeError. + try { + bytesRead = await file.read(new Int32Array(5)) + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err; + } + } + + // ArrayBuffer argument should fail with TypeError. + try { + bytesRead = await file.read(new ArrayBuffer(5)) + } catch (err) { + if (err.name !== 'TypeError') { + throw 'unexpected error: ' + err; + } + } + `, testFilePath))) + + assert.NoError(t, err) + }) + + t.Run("seek with invalid arguments should fail", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + testFilePath := fsext.FilePathSeparator + "bonjour.txt" + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, testFilePath, []byte("hello"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + _, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(` + const file = await fs.open(%q) + + let newOffset + + // null offset should fail with TypeError. + try { + newOffset = await file.seek(null) + throw "file.seek(null) promise unexpectedly resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek(null) rejected with unexpected error: " + err + } + } + + // undefined offset should fail with TypeError. + try { + newOffset = await file.seek(undefined) + throw "file.seek(undefined) promise unexpectedly promise resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek(undefined) rejected with unexpected error: " + err + } + } + + // Invalid type offset should fail with TypeError. + try { + newOffset = await file.seek('abc') + throw "file.seek('abc') promise unexpectedly resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek('1') rejected with unexpected error: " + err + } + } + + // Negative offset should fail with TypeError. + try { + newOffset = await file.seek(-1) + throw "file.seek(-1) promise unexpectedly resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek(-1) rejected with unexpected error: " + err + } + } + + // Invalid type whence should fail with TypeError. + try { + newOffset = await file.seek(1, 'abc') + throw "file.seek(1, 'abc') promise unexpectedly resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek(1, 'abc') rejected with unexpected error: " + err + } + } + + // Invalid whence should fail with TypeError. + try { + newOffset = await file.seek(1, -1) + throw "file.seek(1, -1) promise unexpectedly resolved with result: " + newOffset + } catch (err) { + if (err.name !== 'TypeError') { + throw "file.seek(1, -1) rejected with unexpected error: " + err + } + } + `, testFilePath))) + + assert.NoError(t, err) + }) +} + +func TestOpenImpl(t *testing.T) { + t.Parallel() + + t.Run("should panic if the file system is not available", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + delete(runtime.VU.InitEnvField.FileSystems, "file") + + mi := &ModuleInstance{ + vu: runtime.VU, + cache: &cache{}, + } + + assert.Panics(t, func() { + //nolint:errcheck,gosec + mi.openImpl("bonjour.txt") + }) + }) + + t.Run("should return an error if the file does not exist", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + mi := &ModuleInstance{ + vu: runtime.VU, + cache: &cache{}, + } + + _, err = mi.openImpl("bonjour.txt") + assert.Error(t, err) + var fsError *fsError + assert.ErrorAs(t, err, &fsError) + assert.Equal(t, NotFoundError, fsError.kind) + }) + + t.Run("should return an error if the path is a directory", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + fs := newTestFs(t, func(fs afero.Fs) error { + return fs.Mkdir("/dir", 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + + mi := &ModuleInstance{ + vu: runtime.VU, + cache: &cache{}, + } + + _, err = mi.openImpl("/dir") + assert.Error(t, err) + var fsError *fsError + assert.ErrorAs(t, err, &fsError) + assert.Equal(t, InvalidResourceError, fsError.kind) + }) + + t.Run("path is resolved relative to the entrypoint script", func(t *testing.T) { + t.Parallel() + + runtime, err := newConfiguredRuntime(t) + require.NoError(t, err) + + fs := newTestFs(t, func(fs afero.Fs) error { + return afero.WriteFile(fs, "/bonjour.txt", []byte("Bonjour, le monde"), 0o644) + }) + runtime.VU.InitEnvField.FileSystems["file"] = fs + runtime.VU.InitEnvField.CWD = &url.URL{Scheme: "file", Path: "/dir"} + + mi := &ModuleInstance{ + vu: runtime.VU, + cache: &cache{}, + } + + _, err = mi.openImpl("../bonjour.txt") + assert.NoError(t, err) + }) +} + +const initGlobals = ` + globalThis.fs = require("k6/experimental/fs"); +` + +func newConfiguredRuntime(t testing.TB) (*modulestest.Runtime, error) { + runtime := modulestest.NewRuntime(t) + + err := runtime.SetupModuleSystem(map[string]interface{}{"k6/experimental/fs": New()}, nil, compiler.New(runtime.VU.InitEnv().Logger)) + if err != nil { + return nil, err + } + + // Set up the VU environment with an in-memory filesystem and a CWD of "/". + runtime.VU.InitEnvField.FileSystems = map[string]fsext.Fs{ + "file": fsext.NewMemMapFs(), + } + runtime.VU.InitEnvField.CWD = &url.URL{Scheme: "file"} + + // Ensure the `fs` module is available in the VU's runtime. + _, err = runtime.VU.Runtime().RunString(initGlobals) + + return runtime, err +} + +// newTestFs is a helper function that creates a new in-memory file system and calls the provided +// function with it. The provided function is expected to use the file system to create files and +// directories. +func newTestFs(t *testing.T, fn func(fs afero.Fs) error) afero.Fs { + t.Helper() + + fs := afero.NewMemMapFs() + + err := fn(fs) + if err != nil { + t.Fatal(err) + } + + return fs +} + +// wrapInAsyncLambda is a helper function that wraps the provided input in an async lambda. This +// makes the use of `await` statements in the input possible. +func wrapInAsyncLambda(input string) string { + // This makes it possible to use `await` freely on the "top" level + return "(async () => {\n " + input + "\n })()" +} diff --git a/lib/fsext/filepath.go b/lib/fsext/filepath.go index 322eac2d74a..230f7c506c0 100644 --- a/lib/fsext/filepath.go +++ b/lib/fsext/filepath.go @@ -15,3 +15,31 @@ import ( func JoinFilePath(b, p string) string { return filepath.Join(b, filepath.Clean("/"+p)) } + +// Abs returns an absolute representation of path. +// +// this implementation allows k6 to handle absolute paths starting from +// the current drive on windows like `\users\noname\...`. It makes it easier +// to test and is needed for archive execution under windows (it always consider '/...' as an +// absolute path). +// +// If the path is not absolute it will be joined with root +// to turn it into an absolute path. The root path is assumed +// to be a directory. +// +// Because k6 does not interact with the OS itself, but with +// its own virtual file system, it needs to be able to resolve +// the root path of the file system. In most cases this would be +// the bundle or init environment's working directory. +func Abs(root, path string) string { + if path[0] != '/' && path[0] != '\\' && !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + path = filepath.Clean(path) + + if path[0:1] != FilePathSeparator { + path = FilePathSeparator + path + } + + return path +} diff --git a/lib/fsext/filepath_unix_test.go b/lib/fsext/filepath_unix_test.go index b6d6027d833..ff2083d17d1 100644 --- a/lib/fsext/filepath_unix_test.go +++ b/lib/fsext/filepath_unix_test.go @@ -72,3 +72,59 @@ func TestJoinFilePath(t *testing.T) { }) } } + +func TestAbs(t *testing.T) { + t.Parallel() + + type args struct { + root string + path string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "absolute path", + args: args{ + root: "/", + path: "/test", + }, + want: "/test", + }, + { + name: "relative path", + args: args{ + root: "/", + path: "test", + }, + want: "/test", + }, + { + name: "relative path with leading dot", + args: args{ + root: "/", + path: "./test", + }, + want: "/test", + }, + { + name: "relative path with leading double dot", + args: args{ + root: "/", + path: "../test", + }, + want: "/test", + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, fsext.Abs(tt.args.root, tt.args.path)) + }) + } +} diff --git a/lib/fsext/filepath_windows_test.go b/lib/fsext/filepath_windows_test.go index be7697300c7..4d0c36887c3 100644 --- a/lib/fsext/filepath_windows_test.go +++ b/lib/fsext/filepath_windows_test.go @@ -64,3 +64,59 @@ func TestJoinFilePath(t *testing.T) { }) } } + +func TestAbs(t *testing.T) { + t.Parallel() + + type args struct { + root string + path string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "absolute path", + args: args{ + root: "\\", + path: "\\test", + }, + want: "\\test", + }, + { + name: "relative path", + args: args{ + root: "\\", + path: "test", + }, + want: "\\test", + }, + { + name: "relative path with leading dot", + args: args{ + root: "\\", + path: ".\\test", + }, + want: "\\test", + }, + { + name: "relative path with leading double dot", + args: args{ + root: "\\", + path: "..\\test", + }, + want: "\\test", + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, fsext.Abs(tt.args.root, tt.args.path)) + }) + } +}