diff --git a/js/bundle.go b/js/bundle.go index 69a8f8ec800..7c50a064ecc 100644 --- a/js/bundle.go +++ b/js/bundle.go @@ -302,9 +302,10 @@ func (b *Bundle) instantiate(logger logrus.FieldLogger, rt *goja.Runtime, init * // TODO: get rid of the unused ctxPtr, use a real external context (so we // can interrupt), build the common.InitEnvironment earlier and reuse it initenv := &common.InitEnvironment{ - Logger: logger, - FileSystems: init.filesystems, - CWD: init.pwd, + SharedObjects: init.sharedObjects, + Logger: logger, + FileSystems: init.filesystems, + CWD: init.pwd, } ctx := common.WithInitEnv(context.Background(), initenv) *init.ctxPtr = common.WithRuntime(ctx, rt) diff --git a/js/common/initenv.go b/js/common/initenv.go index be0a1b116ef..b5ba4037bad 100644 --- a/js/common/initenv.go +++ b/js/common/initenv.go @@ -23,6 +23,7 @@ package common import ( "net/url" "path/filepath" + "sync" "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -37,6 +38,7 @@ type InitEnvironment struct { // TODO: add RuntimeOptions and other properties, goja sources, etc. // ideally, we should leave this as the only data structure necessary for // executing the init context for all JS modules + SharedObjects *SharedObjects } // GetAbsFilePath should be used to access the FileSystems, since afero has a @@ -60,3 +62,34 @@ func (ie *InitEnvironment) GetAbsFilePath(filename string) string { } return filename } + +// SharedObjects is a collection of general store for objects to be shared. It is mostly a wrapper +// around map[string]interface with a lock and stuff. +// The reason behind not just using sync.Map is that it still needs a lock when we want to only call +// the function constructor if there is no such key at which point you already need a lock so ... +type SharedObjects struct { + data map[string]interface{} + l sync.Mutex +} + +// NewSharedObjects returns a new SharedObjects ready to use +func NewSharedObjects() *SharedObjects { + return &SharedObjects{ + data: make(map[string]interface{}), + } +} + +// GetOrCreateShare returns a shared value with the given name or sets it's value whatever +// createCallback returns and returns it. +func (so *SharedObjects) GetOrCreateShare(name string, createCallback func() interface{}) interface{} { + so.l.Lock() + defer so.l.Unlock() + + value, ok := so.data[name] + if !ok { + value = createCallback() + so.data[name] = value + } + + return value +} diff --git a/js/console_test.go b/js/console_test.go index 92b06002809..a6e4421666d 100644 --- a/js/console_test.go +++ b/js/console_test.go @@ -76,6 +76,7 @@ func getSimpleRunner(tb testing.TB, filename, data string, opts ...interface{}) var ( fs = afero.NewMemMapFs() rtOpts = lib.RuntimeOptions{CompatibilityMode: null.NewString("base", true)} + logger = testutils.NewLogger(tb) ) for _, o := range opts { switch opt := o.(type) { @@ -83,10 +84,12 @@ func getSimpleRunner(tb testing.TB, filename, data string, opts ...interface{}) fs = opt case lib.RuntimeOptions: rtOpts = opt + case *logrus.Logger: + logger = opt } } return New( - testutils.NewLogger(tb), + logger, &loader.SourceData{ URL: &url.URL{Path: filename, Scheme: "file"}, Data: []byte(data), diff --git a/js/initcontext.go b/js/initcontext.go index c8f9a1a1163..661f142a6c3 100644 --- a/js/initcontext.go +++ b/js/initcontext.go @@ -69,6 +69,8 @@ type InitContext struct { compatibilityMode lib.CompatibilityMode logger logrus.FieldLogger + + sharedObjects *common.SharedObjects } // NewInitContext creates a new initcontext with the provided arguments @@ -85,6 +87,7 @@ func NewInitContext( programs: make(map[string]programWithSource), compatibilityMode: compatMode, logger: logger, + sharedObjects: common.NewSharedObjects(), } } @@ -110,6 +113,7 @@ func newBoundInitContext(base *InitContext, ctxPtr *context.Context, rt *goja.Ru programs: programs, compatibilityMode: base.compatibilityMode, logger: base.logger, + sharedObjects: base.sharedObjects, } } @@ -162,7 +166,6 @@ func (i *InitContext) requireFile(name string) (goja.Value, error) { if pgm.pgm == nil { // Load the sources; the loader takes care of remote loading, etc. - // TODO: don't use the Global logger data, err := loader.Load(i.logger, i.filesystems, fileURL, name) if err != nil { return goja.Undefined(), err diff --git a/js/modules.go b/js/modules.go index 28d780b0940..ff6c5d0d691 100644 --- a/js/modules.go +++ b/js/modules.go @@ -25,6 +25,7 @@ import ( _ "github.com/loadimpact/k6/js/modules/k6" _ "github.com/loadimpact/k6/js/modules/k6/crypto" _ "github.com/loadimpact/k6/js/modules/k6/crypto/x509" + _ "github.com/loadimpact/k6/js/modules/k6/data" _ "github.com/loadimpact/k6/js/modules/k6/encoding" _ "github.com/loadimpact/k6/js/modules/k6/grpc" _ "github.com/loadimpact/k6/js/modules/k6/http" diff --git a/js/modules/k6/data/data.go b/js/modules/k6/data/data.go new file mode 100644 index 00000000000..2e43ab2bf45 --- /dev/null +++ b/js/modules/k6/data/data.go @@ -0,0 +1,95 @@ +/* + * + * k6 - a next-generation load testing tool + * Copyright (C) 2020 Load Impact + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "context" + + "github.com/dop251/goja" + "github.com/loadimpact/k6/js/common" + "github.com/loadimpact/k6/js/internal/modules" + "github.com/loadimpact/k6/lib" + "github.com/pkg/errors" +) + +type data struct{} + +func init() { + modules.Register("k6/data", new(data)) +} + +const sharedArrayNamePrefix = "k6/data/SharedArray." + +// XSharedArray is a constructor returning a shareable read-only array +// indentified by the name and having their contents be whatever the call returns +func (d *data) XSharedArray(ctx context.Context, name string, call goja.Callable) (goja.Value, error) { + if lib.GetState(ctx) != nil { + return nil, errors.New("new SharedArray must be called in the init context") + } + + initEnv := common.GetInitEnv(ctx) + if initEnv == nil { + return nil, errors.New("missing init environment") + } + if len(name) == 0 { + return nil, errors.New("empty name provided to SharedArray's constructor") + } + + name = sharedArrayNamePrefix + name + value := initEnv.SharedObjects.GetOrCreateShare(name, func() interface{} { + return getShareArrayFromCall(common.GetRuntime(ctx), call) + }) + array, ok := value.(sharedArray) + if !ok { // TODO more info in the error? + return nil, errors.New("wrong type of shared object") + } + + return array.wrap(&ctx, common.GetRuntime(ctx)), nil +} + +func getShareArrayFromCall(rt *goja.Runtime, call goja.Callable) sharedArray { + gojaValue, err := call(goja.Undefined()) + if err != nil { + common.Throw(rt, err) + } + obj := gojaValue.ToObject(rt) + if obj.ClassName() != "Array" { + common.Throw(rt, errors.New("only arrays can be made into SharedArray")) // TODO better error + } + arr := make([]string, obj.Get("length").ToInteger()) + + // We specifically use JSON.stringify here as we need to use JSON.parse on the way out + // it also has the benefit of needing only one loop and being more JS then using golang's json + cal, err := rt.RunString(`(function(input, output) { + for (var i = 0; i < input.length; i++) { + output[i] = JSON.stringify(input[i]) + } + })`) + if err != nil { + common.Throw(rt, err) + } + newCall, _ := goja.AssertFunction(cal) + _, err = newCall(goja.Undefined(), gojaValue, rt.ToValue(arr)) + if err != nil { + common.Throw(rt, err) + } + return sharedArray{arr: arr} +} diff --git a/js/modules/k6/data/share.go b/js/modules/k6/data/share.go new file mode 100644 index 00000000000..de0b25bd732 --- /dev/null +++ b/js/modules/k6/data/share.go @@ -0,0 +1,135 @@ +/* + * + * k6 - a next-generation load testing tool + * Copyright (C) 2020 Load Impact + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package data + +import ( + "context" + + "github.com/dop251/goja" + "github.com/loadimpact/k6/js/common" +) + +// TODO fix it not working really well with setupData or just make it more broken +// TODO fix it working with console.log +type sharedArray struct { + arr []string +} + +func (s sharedArray) wrap(ctxPtr *context.Context, rt *goja.Runtime) goja.Value { + cal, err := rt.RunString(arrayWrapperCode) + if err != nil { + common.Throw(rt, err) + } + call, _ := goja.AssertFunction(cal) + wrapped, err := call(goja.Undefined(), rt.ToValue(common.Bind(rt, s, ctxPtr))) + if err != nil { + common.Throw(rt, err) + } + + return wrapped +} + +func (s sharedArray) Get(index int) (interface{}, error) { + if index < 0 || index >= len(s.arr) { + return goja.Undefined(), nil + } + + // we specifically use JSON.parse to get the json to an object inside as otherwise we won't be + // able to freeze it as goja doesn't let us unless it is a pure goja object and this is the + // easiest way to get one. + return s.arr[index], nil +} + +func (s sharedArray) Length() int { + return len(s.arr) +} + +/* This implementation is commented as with it - it is harder to deepFreeze it with this implementation. +type sharedArrayIterator struct { + a *sharedArray + index int +} + +func (sai *sharedArrayIterator) Next() (interface{}, error) { + if sai.index == len(sai.a.arr)-1 { + return map[string]bool{"done": true}, nil + } + sai.index++ + var tmp interface{} + if err := json.Unmarshal(sai.a.arr[sai.index], &tmp); err != nil { + return goja.Undefined(), err + } + return map[string]interface{}{"value": tmp}, nil +} + +func (s sharedArray) Iterator() *sharedArrayIterator { + return &sharedArrayIterator{a: &s, index: -1} +} +*/ + +const arrayWrapperCode = `(function(val) { + function deepFreeze(o) { + Object.freeze(o); + if (o === undefined) { + return o; + } + + Object.getOwnPropertyNames(o).forEach(function (prop) { + if (o[prop] !== null + && (typeof o[prop] === "object" || typeof o[prop] === "function") + && !Object.isFrozen(o[prop])) { + deepFreeze(o[prop]); + } + }); + + return o; + }; + + var arrayHandler = { + get: function(target, property, receiver) { + switch (property){ + case "length": + return target.length(); + case Symbol.iterator: + return function(){ + var index = 0; + return { + "next": function() { + if (index >= target.length()) { + return {done: true} + } + var result = {value: deepFreeze(JSON.parse(target.get(index)))}; + index++; + return result; + } + } + } + } + var i = parseInt(property); + if (isNaN(i)) { + return undefined; + } + + return deepFreeze(JSON.parse(target.get(i))); + } + }; + return new Proxy(val, arrayHandler); +})` diff --git a/js/share_test.go b/js/share_test.go new file mode 100644 index 00000000000..760f88dd1a1 --- /dev/null +++ b/js/share_test.go @@ -0,0 +1,182 @@ +/* + * + * k6 - a next-generation load testing tool + * Copyright (C) 2020 Load Impact + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package js + +import ( + "context" + "io/ioutil" + "testing" + + "github.com/loadimpact/k6/lib" + "github.com/loadimpact/k6/lib/testutils" + "github.com/loadimpact/k6/stats" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitContextNewSharedArray(t *testing.T) { + data := `'use strict'; +var SharedArray = require("k6/data").SharedArray; +function generateArray() { + console.log("once"); + var n = 50; + var arr = new Array(n); + for (var i = 0 ; i