From 91c3c0de59fe8146fa23321d55df8e070cbb7c3a Mon Sep 17 00:00:00 2001 From: oleiade Date: Thu, 26 Oct 2023 13:47:39 +0200 Subject: [PATCH] Add a fs.readAll operation --- js/modules/k6/experimental/fs/module.go | 145 +++++++++++++------ js/modules/k6/experimental/fs/module_test.go | 96 ++++++++++++ 2 files changed, 199 insertions(+), 42 deletions(-) diff --git a/js/modules/k6/experimental/fs/module.go b/js/modules/k6/experimental/fs/module.go index 4f44ccbe3f6..0f0a098bf20 100644 --- a/js/modules/k6/experimental/fs/module.go +++ b/js/modules/k6/experimental/fs/module.go @@ -50,7 +50,8 @@ func (rm *RootModule) NewModuleInstance(vu modules.VU) modules.Instance { func (mi *ModuleInstance) Exports() modules.Exports { return modules.Exports{ Named: map[string]any{ - "open": mi.Open, + "open": mi.Open, + "readAll": mi.ReadAll, "SeekMode": map[string]any{ "Start": SeekModeStart, "Current": SeekModeCurrent, @@ -95,53 +96,64 @@ func (mi *ModuleInstance) Open(path goja.Value) *goja.Promise { 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) +// 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) - fs, ok := initEnv.FileSystems["file"] - if !ok { - common.Throw(mi.vu.Runtime(), errors.New("open() failed; reason: unable to access the file system")) + if common.IsNullish(file) { + reject(newFsError(TypeError, "readAll() failed; reason: the file argument cannot be null or undefined")) + return promise } - 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)) + 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 } - 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), - ) - } + go func() { + bytesLeft := len(fileInstance.file.data) - fileInstance.file.offset + data := make([]byte, bytesLeft) - data, err := mi.cache.open(path, fs) - if err != nil { - return nil, err - } + n, err := fileInstance.file.Read(data) + if err != nil { + errMsg := "readAll() failed; reason: %w" - return &File{ - Path: path, - file: file{ - path: path, - data: data, - }, - vu: mi.vu, - registry: mi.cache, - }, nil + 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. @@ -153,7 +165,7 @@ type File struct { Path string `json:"path"` // file contains the actual implementation for the file system. - file + *file // vu holds a reference to the VU this file is associated with. // @@ -298,3 +310,52 @@ func (f *File) Seek(offset goja.Value, whence goja.Value) *goja.Promise { 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 index 535ca8afa44..d59f59f186c 100644 --- a/js/modules/k6/experimental/fs/module_test.go +++ b/js/modules/k6/experimental/fs/module_test.go @@ -195,6 +195,102 @@ func TestOpen(t *testing.T) { }) } +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()