Skip to content

Commit

Permalink
js: Use the new ESM support in goja through k6
Browse files Browse the repository at this point in the history
This is still very hackish and tries to preserve every observable
behavior possible.

This also specifically skips adding support for:
1. dynamic import
2. top level await
3. anything around the internal go modules

Which will be added later. Also let us concentrate on making this change
as not breaking as possible.
  • Loading branch information
mstoykov committed Jun 21, 2024
1 parent 9150a95 commit 2ae2678
Show file tree
Hide file tree
Showing 22 changed files with 771 additions and 923 deletions.
22 changes: 11 additions & 11 deletions cmd/tests/cmd_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,8 +944,8 @@ func TestAbortedByScriptSetupErrorWithDependency(t *testing.T) {
if runtime.GOOS == "windows" {
rootPath += "c:/"
}
assert.Contains(t, stdout, `level=error msg="Error: baz\n\tat baz (`+rootPath+`test/bar.js:6:9(3))\n\tat `+
rootPath+`test/bar.js:3:3(3)\n\tat setup (`+rootPath+`test/test.js:5:3(9))\n" hint="script exception"`)
assert.Contains(t, stdout, `level=error msg="Error: baz\n\tat baz (`+rootPath+`test/bar.js:6:10(3))\n\tat default (`+
rootPath+`test/bar.js:3:7(3))\n\tat setup (`+rootPath+`test/test.js:5:7(8))\n" hint="script exception"`)
assert.Contains(t, stdout, `level=debug msg="Sending test finished" output=cloud ref=123 run_status=7 tainted=false`)
assert.Contains(t, stdout, "bogus summary")
}
Expand Down Expand Up @@ -2105,10 +2105,10 @@ func TestEventSystemError(t *testing.T) {
"got event Init with data '<nil>'",
"got event TestStart with data '<nil>'",
"got event IterStart with data '{Iteration:0 VUID:1 ScenarioName:default Error:<nil>}'",
"got event IterEnd with data '{Iteration:0 VUID:1 ScenarioName:default Error:test aborted: oops! at file:///-:11:16(6)}'",
"got event IterEnd with data '{Iteration:0 VUID:1 ScenarioName:default Error:test aborted: oops! at default (file:///-:11:16(5))}'",
"got event TestEnd with data '<nil>'",
"got event Exit with data '&{Error:test aborted: oops! at file:///-:11:16(6)}'",
"test aborted: oops! at file:///-:11:16(6)",
"got event Exit with data '&{Error:test aborted: oops! at default (file:///-:11:16(5))}'",
"test aborted: oops! at default (file:///-:11:16(5))",
},
expExitCode: exitcodes.ScriptAborted,
},
Expand All @@ -2117,8 +2117,8 @@ func TestEventSystemError(t *testing.T) {
script: "undefinedVar",
expLog: []string{
"got event Exit with data '&{Error:could not initialize '-': could not load JS test " +
"'file:///-': ReferenceError: undefinedVar is not defined\n\tat file:///-:2:0(12)\n}'",
"ReferenceError: undefinedVar is not defined\n\tat file:///-:2:0(12)\n",
"'file:///-': ReferenceError: undefinedVar is not defined\n\tat file:///-:2:1(8)\n}'",
"ReferenceError: undefinedVar is not defined\n\tat file:///-:2:1(8)\n",
},
expExitCode: exitcodes.ScriptException,
},
Expand All @@ -2137,11 +2137,11 @@ func TestEventSystemError(t *testing.T) {
"got event Init with data '<nil>'",
"got event TestStart with data '<nil>'",
"got event IterStart with data '{Iteration:0 VUID:1 ScenarioName:default Error:<nil>}'",
"got event IterEnd with data '{Iteration:0 VUID:1 ScenarioName:default Error:Error: oops!\n\tat file:///-:9:11(3)\n}'",
"Error: oops!\n\tat file:///-:9:11(3)\n",
"got event IterEnd with data '{Iteration:0 VUID:1 ScenarioName:default Error:Error: oops!\n\tat default (file:///-:9:12(3))\n}'",
"Error: oops!\n\tat default (file:///-:9:12(3))\n",
"got event IterStart with data '{Iteration:1 VUID:1 ScenarioName:default Error:<nil>}'",
"got event IterEnd with data '{Iteration:1 VUID:1 ScenarioName:default Error:Error: oops!\n\tat file:///-:9:11(3)\n}'",
"Error: oops!\n\tat file:///-:9:11(3)\n",
"got event IterEnd with data '{Iteration:1 VUID:1 ScenarioName:default Error:Error: oops!\n\tat default (file:///-:9:12(3))\n}'",
"Error: oops!\n\tat default (file:///-:9:12(3))\n",
"got event TestEnd with data '<nil>'",
"got event Exit with data '&{Error:<nil>}'",
},
Expand Down
2 changes: 1 addition & 1 deletion cmd/tests/eventloop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func TestEventLoopDoesntCrossIterations(t *testing.T) {
eventLoopTest(t, script, func(logLines []string) {
require.Equal(t, []string{
"setTimeout 1 was stopped because the VU iteration was interrupted",
"just error\n\tat file:///-:13:4(15)\n", "1",
"just error\n\tat default (file:///-:13:5(14))\n", "1",
}, logLines)
})
}
Expand Down
143 changes: 80 additions & 63 deletions js/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ type BundleInstance struct {
// TODO: maybe just have a reference to the Bundle? or save and pass rtOpts?
env map[string]string

mainModuleExports *sobek.Object
moduleVUImpl *moduleVUImpl
mainModule sobek.ModuleRecord
mainModuleInstance sobek.ModuleInstance
moduleVUImpl *moduleVUImpl
}

func (bi *BundleInstance) getCallableExport(name string) sobek.Callable {
Expand All @@ -59,7 +60,7 @@ func (bi *BundleInstance) getCallableExport(name string) sobek.Callable {
}

func (bi *BundleInstance) getExported(name string) sobek.Value {
return bi.mainModuleExports.Get(name)
return bi.mainModuleInstance.GetBindingValue(name)
}

// NewBundle creates a new bundle from a source file and a filesystem.
Expand Down Expand Up @@ -94,7 +95,7 @@ func newBundle(
}

c := bundle.newCompiler(piState.Logger)
bundle.ModuleResolver = modules.NewModuleResolver(getJSModules(), generateFileLoad(bundle), c)
bundle.ModuleResolver = modules.NewModuleResolver(getJSModules(), generateFileLoad(bundle), c, src.URL.JoinPath(".."))

// Instantiate the bundle into a new VM using a bound init context. This uses a context with a
// runtime, but no state, to allow module-provided types to function within the init context.
Expand All @@ -108,13 +109,13 @@ func newBundle(
},
}
vuImpl.eventLoop = eventloop.New(vuImpl)
exports, err := bundle.instantiate(vuImpl, 0)
bi, err := bundle.instantiate(vuImpl, 0)
if err != nil {
return nil, err
}
bundle.ModuleResolver.Lock()

err = bundle.populateExports(updateOptions, exports)
err = bundle.populateExports(updateOptions, bi)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -171,35 +172,50 @@ func (b *Bundle) makeArchive() *lib.Archive {
}

// populateExports validates and extracts exported objects
func (b *Bundle) populateExports(updateOptions bool, exports *sobek.Object) error {
for _, k := range exports.Keys() {
v := exports.Get(k)
if _, ok := sobek.AssertFunction(v); ok && k != consts.Options {
b.callableExports[k] = struct{}{}
continue
}
switch k {
case consts.Options:
if !updateOptions {
func (b *Bundle) populateExports(updateOptions bool, bi *BundleInstance) error {
var err error
ch := make(chan struct{})
bi.mainModule.GetExportedNames(func(names []string) {
defer close(ch)
for _, k := range names {
v := bi.getExported(k)
if _, ok := sobek.AssertFunction(v); ok && k != consts.Options {
b.callableExports[k] = struct{}{}
continue
}
data, err := json.Marshal(v.Export())
if err != nil {
return fmt.Errorf("error parsing script options: %w", err)
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&b.Options); err != nil {
if uerr := json.Unmarshal(data, &b.Options); uerr != nil {
return uerr
switch k {
case consts.Options:
if !updateOptions {
continue
}
var data []byte
data, err = json.Marshal(v.Export())
if err != nil {
err = fmt.Errorf("error parsing script options: %w", err)
return
}
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err = dec.Decode(&b.Options); err != nil {
if uerr := json.Unmarshal(data, &b.Options); uerr != nil {
err = uerr
return
}
b.preInitState.Logger.WithError(err).Warn("There were unknown fields in the options exported in the script")
err = nil
}
b.preInitState.Logger.WithError(err).Warn("There were unknown fields in the options exported in the script")
case consts.SetupFn:
err = errors.New("exported 'setup' must be a function")
return
case consts.TeardownFn:
err = errors.New("exported 'teardown' must be a function")
return
}
case consts.SetupFn:
return errors.New("exported 'setup' must be a function")
case consts.TeardownFn:
return errors.New("exported 'teardown' must be a function")
}
})
<-ch
if err != nil {
return err
}

if len(b.callableExports) == 0 {
Expand All @@ -222,40 +238,34 @@ func (b *Bundle) Instantiate(ctx context.Context, vuID uint64) (*BundleInstance,
},
}
vuImpl.eventLoop = eventloop.New(vuImpl)
exports, err := b.instantiate(vuImpl, vuID)
bi, err := b.instantiate(vuImpl, vuID)
if err != nil {
return nil, err
}

bi := &BundleInstance{
Runtime: vuImpl.runtime,
env: b.preInitState.RuntimeOptions.Env,
moduleVUImpl: vuImpl,
mainModuleExports: exports,
if err = bi.manipulateOptions(b.Options); err != nil {
return nil, err
}

return bi, nil
}

func (bi *BundleInstance) manipulateOptions(options lib.Options) error {
// Grab any exported functions that could be executed. These were
// already pre-validated in cmd.validateScenarioConfig(), just get them here.
jsOptions := exports.Get(consts.Options)
jsOptions := bi.getExported(consts.Options)
var jsOptionsObj *sobek.Object
if common.IsNullish(jsOptions) {
jsOptionsObj = vuImpl.runtime.NewObject()
err := exports.Set(consts.Options, jsOptionsObj)
if err != nil {
return nil, fmt.Errorf("couldn't set exported options with merged values: %w", err)
}
} else {
jsOptionsObj = jsOptions.ToObject(vuImpl.runtime)
return nil
}

jsOptionsObj = jsOptions.ToObject(bi.Runtime)
var instErr error
b.Options.ForEachSpecified("json", func(key string, val interface{}) {
options.ForEachSpecified("json", func(key string, val interface{}) {
if err := jsOptionsObj.Set(key, val); err != nil {
instErr = err
}
})

return bi, instErr
return instErr
}

func (b *Bundle) newCompiler(logger logrus.FieldLogger) *compiler.Compiler {
Expand All @@ -268,7 +278,7 @@ func (b *Bundle) newCompiler(logger logrus.FieldLogger) *compiler.Compiler {
return c
}

func (b *Bundle) instantiate(vuImpl *moduleVUImpl, vuID uint64) (*sobek.Object, error) {
func (b *Bundle) instantiate(vuImpl *moduleVUImpl, vuID uint64) (*BundleInstance, error) {
rt := vuImpl.runtime
err := b.setupJSRuntime(rt, int64(vuID), b.preInitState.Logger)
if err != nil {
Expand Down Expand Up @@ -301,10 +311,25 @@ func (b *Bundle) instantiate(vuImpl *moduleVUImpl, vuID uint64) (*sobek.Object,
close(initDone)
}()

var exportsV sobek.Value
err = vuImpl.eventLoop.Start(func() error {
bi := &BundleInstance{
Runtime: vuImpl.runtime,
env: b.preInitState.RuntimeOptions.Env,
moduleVUImpl: vuImpl,
}
callback := func() error { // this exists so that sobek catches uncatchable panics such as Interrupt
var err error
exportsV, err = modSys.RunSourceData(b.sourceData)
bi.mainModule, err = modSys.RunSourceData(b.sourceData)
if err != nil {
return err
}
bi.mainModuleInstance = rt.GetModuleInstance(bi.mainModule)
return nil
}

call, _ := sobek.AssertFunction(vuImpl.runtime.ToValue(callback))

err = vuImpl.eventLoop.Start(func() error {
_, err := call(nil)
return err
})

Expand All @@ -317,14 +342,6 @@ func (b *Bundle) instantiate(vuImpl *moduleVUImpl, vuID uint64) (*sobek.Object,
}
return nil, err
}
if common.IsNullish(exportsV) {
return nil, errors.New("exports must not be set to null or undefined")
}
exports := exportsV.ToObject(vuImpl.runtime)

if exports == nil {
return nil, errors.New("exports must be an object")
}

// If we've already initialized the original VU init context, forbid
// any subsequent VUs to open new files
Expand All @@ -334,7 +351,7 @@ func (b *Bundle) instantiate(vuImpl *moduleVUImpl, vuID uint64) (*sobek.Object,

rt.SetRandSource(common.NewRandSource())

return exports, nil
return bi, nil
}

func (b *Bundle) setupJSRuntime(rt *sobek.Runtime, vuID int64, logger logrus.FieldLogger) error {
Expand Down Expand Up @@ -389,7 +406,7 @@ func (b *Bundle) setInitGlobals(rt *sobek.Runtime, vu *moduleVUImpl, modSys *mod

impl := requireImpl{
inInitContext: func() bool { return vu.state == nil },
internal: modules.NewLegacyRequireImpl(vu, modSys, *b.pwd),
internal: modules.NewLegacyRequireImpl(vu, modSys),
}

mustSet("require", impl.require)
Expand All @@ -405,7 +422,7 @@ func (b *Bundle) setInitGlobals(rt *sobek.Runtime, vu *moduleVUImpl, modSys *mod
}
// This uses the pwd from the requireImpl
pwd := impl.internal.CurrentlyRequiredModule()
return openImpl(rt, b.filesystems["file"], &pwd, filename, args...)
return openImpl(rt, b.filesystems["file"], pwd, filename, args...)
})
}

Expand Down
6 changes: 4 additions & 2 deletions js/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func TestNewBundle(t *testing.T) {
t.Parallel()
_, err := getSimpleBundle(t, "/script.js", "\x00")
require.NotNil(t, err)
require.Contains(t, err.Error(), "SyntaxError: file:///script.js: Unexpected character '\x00' (1:0)\n> 1 | \x00\n")
require.Contains(t, err.Error(), "file:///script.js: Line 1:1 Unexpected token ILLEGAL (and 1 more errors)")
})
t.Run("Error", func(t *testing.T) {
t.Parallel()
Expand All @@ -96,7 +96,7 @@ func TestNewBundle(t *testing.T) {
t.Run("InvalidExports", func(t *testing.T) {
t.Parallel()
_, err := getSimpleBundle(t, "/script.js", `module.exports = null`)
require.EqualError(t, err, "exports must be an object")
require.EqualError(t, err, "GoError: exports must be an object\n") // TODO: try to remove the GoError from herer
})
t.Run("DefaultUndefined", func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -533,12 +533,14 @@ func TestNewBundleFromArchive(t *testing.T) {
checkArchive(t, arc, baseCompatModeRtOpts, "")
})

/* TODO remove completely - this no longer makes sense
t.Run("es6_archive_with_wrong_compat_mode", func(t *testing.T) {
t.Parallel()
arc, err := getArchive(t, es6Code, baseCompatModeRtOpts)
require.Error(t, err)
require.Nil(t, arc)
})
*/

t.Run("messed_up_archive", func(t *testing.T) {
t.Parallel()
Expand Down
Loading

0 comments on commit 2ae2678

Please sign in to comment.