From 913b280c9fb57a643842ea824f35299e568283f3 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov <312246+mstoykov@users.noreply.github.com> Date: Thu, 1 Aug 2024 12:09:18 +0300 Subject: [PATCH] Implement import.meta.resolve() (#3873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement import.meta.resolve() This implements and tests `import.meta.resolve()` a way to get the path to a module the same way ESM and/or `require` will do. This doesn't read or load the file in question from the file. This also showed that we currently always parse code as ECMAScript modules. Even when the parsed/compiled file was to be wrapped as CommonJS. This likely had no other side effects as all other ESM specific syntax is also either not implemented in k6 or forces CommonJS wrapping disabled. Closes #3856 * Update js/modules/require_impl.go Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> * panic on error --------- Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> --- js/bundle.go | 13 ++ js/compiler/compiler.go | 16 +- js/modules/require_impl.go | 11 + js/path_resolution_test.go | 209 ++++++++++++++++++ ...ing_test_errors-experimental_enhanced.json | 6 +- js/tc39/breaking_test_errors-extended.json | 6 +- 6 files changed, 253 insertions(+), 8 deletions(-) diff --git a/js/bundle.go b/js/bundle.go index 820893927ed..028e2d01076 100644 --- a/js/bundle.go +++ b/js/bundle.go @@ -451,6 +451,19 @@ func (b *Bundle) setInitGlobals(rt *sobek.Runtime, vu *moduleVUImpl, modSys *mod } warnAboutModuleMixing("module") warnAboutModuleMixing("exports") + + rt.SetFinalImportMeta(func(o *sobek.Object, mr sobek.ModuleRecord) { + err := o.Set("resolve", func(specifier string) (string, error) { + u, err := modSys.Resolve(mr, specifier) + if err != nil { + return "", err + } + return u.String(), nil + }) + if err != nil { + panic("error while creating `import.meta.resolve`: " + err.Error()) + } + }) } func generateFileLoad(b *Bundle) modules.FileLoader { diff --git a/js/compiler/compiler.go b/js/compiler/compiler.go index 3e3286a4ef1..6673904fd29 100644 --- a/js/compiler/compiler.go +++ b/js/compiler/compiler.go @@ -70,18 +70,26 @@ func (ps *parsingState) parseImpl(src, filename string, commonJSWrap bool) (*ast code = ps.wrap(code, filename) ps.commonJSWrapped = true } - opts := parser.WithDisableSourceMaps + var opts []parser.Option if ps.loader != nil { - opts = parser.WithSourceMapLoader(ps.sourceMapLoader) + opts = append(opts, parser.WithSourceMapLoader(ps.sourceMapLoader)) + } else { + opts = append(opts, parser.WithDisableSourceMaps) + } + + if !commonJSWrap { + opts = append(opts, parser.IsModule) } - prg, err := parser.ParseFile(nil, filename, code, 0, opts, parser.IsModule) + + prg, err := parser.ParseFile(nil, filename, code, 0, opts...) if ps.couldntLoadSourceMap { ps.couldntLoadSourceMap = false // reset // we probably don't want to abort scripts which have source maps but they can't be found, // this also will be a breaking change, so if we couldn't we retry with it disabled ps.compiler.logger.WithError(ps.srcMapError).Warnf("Couldn't load source map for %s", filename) - prg, err = parser.ParseFile(nil, filename, code, 0, parser.WithDisableSourceMaps, parser.IsModule) + ps.loader = nil + return ps.parseImpl(src, filename, commonJSWrap) } if err == nil { diff --git a/js/modules/require_impl.go b/js/modules/require_impl.go index 5cab7a4ba81..e3c9eb3f271 100644 --- a/js/modules/require_impl.go +++ b/js/modules/require_impl.go @@ -87,6 +87,17 @@ func (ms *ModuleSystem) getModuleInstanceFromGoModule(wm *goModule) (wmi *goModu return gmi, nil } +// Resolve returns what the provided specifier will get resolved to if it was to be imported +// To be used by other parts to get the path +func (ms *ModuleSystem) Resolve(mr sobek.ModuleRecord, specifier string) (*url.URL, error) { + if specifier == "" { + return nil, errors.New("require() can't be used with an empty specifier") + } + + baseModuleURL := ms.resolver.reversePath(mr) + return ms.resolver.resolveSpecifier(baseModuleURL, specifier) +} + // CurrentlyRequiredModule returns the module that is currently being required. // It is mostly used for old and somewhat buggy behaviour of the `open` call func (ms *ModuleSystem) CurrentlyRequiredModule() (*url.URL, error) { diff --git a/js/path_resolution_test.go b/js/path_resolution_test.go index 6fa224a5149..919b59dd6cc 100644 --- a/js/path_resolution_test.go +++ b/js/path_resolution_test.go @@ -356,3 +356,212 @@ func writeToFs(fs fsext.Fs, in map[string]any) error { } return nil } + +func TestImportMetaResolve(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + fsMap map[string]any + expectedLogs []string + expectedError string + }{ + "simple": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/A/A/A/script.js": ` + let data = require(import.meta.resolve("../../../B/data.js")); + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + "intermediate": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + module.exports = require(import.meta.resolve("../../B/data.js")); + `, + "/A/A/A/A/script.js": ` + let data = require(import.meta.resolve("./../../../C/B/script.js")) + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + expectedError: `import not supported in script`, + }, + "intermediate fixed": { + fsMap: map[string]any{ + "/A/B/data.js": "export default 'export content'", + "/A/C/B/script.js": ` + export default require("../../B/data.js").default; + `, + "/A/A/A/A/script.js": ` + let data = require("./../../../C/B/script.js").default; + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + "complex": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + export default () => require(import.meta.resolve("./../../B/data.js")); + `, + "/A/B/B/script.js": ` + export default require(import.meta.resolve("./../../C/B/script.js")).default(); + `, + "/A/A/A/A/script.js": ` + let data = require(import.meta.resolve("./../../../B/B/script.js")).default; + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + "complex wrong": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + export default () => require(import.meta.resolve("./../data.js")); + `, + "/A/B/B/script.js": ` + export default require(import.meta.resolve("./../../C/B/script.js")).default(); + `, + "/A/A/A/A/script.js": ` + let data = require(import.meta.resolve("./../../../B/B/script.js")).default; + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + expectedError: `A/C/data.js" couldn't be found`, + }, + "complex wrong fixed": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + export default (specifier) => require(specifier); + `, + "/A/B/B/script.js": ` + export default require(import.meta.resolve("./../../C/B/script.js")).default( + import.meta.resolve("./../data.js") + ); + `, + "/A/A/A/A/script.js": ` + let data = require(import.meta.resolve("./../../../B/B/script.js")).default; + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + "ESM and require": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + export default function () { + // Here the path is relative to this module not the calling one + return require(import.meta.resolve("./../../B/data.js")); + } + `, + "/A/B/B/script.js": ` + import s from "./../../C/B/script.js" + export default require(import.meta.resolve("./../../C/B/script.js")).default(); + `, + "/A/A/A/A/script.js": ` + import data from "./../../../B/B/script.js" + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + "ESM and require wrong": { + fsMap: map[string]any{ + "/A/B/data.js": "module.exports='export content'", + "/A/C/B/script.js": ` + export default function () { + return require(import.meta.resolve("./../data.js")); + } + `, + "/A/B/B/script.js": ` + import s from "./../../C/B/script.js" + export default require(import.meta.resolve("./../../C/B/script.js")).default(); + `, + "/A/A/A/A/script.js": ` + import data from "./../../../B/B/script.js" + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + expectedError: `The moduleSpecifier "file:///A/C/data.js" couldn't be found on local disk.`, + }, + "full ESM": { + fsMap: map[string]any{ + "/A/B/data.js": "export default 'export content'", + "/A/C/B/script.js": ` + export default function (specifier) { + return require(specifier).default; + } + `, + "/A/B/B/script.js": ` + import s from "./../../C/B/script.js" + let l = s(import.meta.resolve("../data.js")); // this will resolve with this module as root + export default l; + `, + "/A/A/A/A/script.js": ` + import data from "./../../../B/B/script.js" + if (data != "export content") { + throw new Error("wrong content " + data); + } + export default function() {} + `, + }, + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + fs := fsext.NewMemMapFs() + err := writeToFs(fs, testCase.fsMap) + fs = fsext.NewCacheOnReadFs(fs, fsext.NewMemMapFs(), 0) + require.NoError(t, err) + logger, hook := testutils.NewLoggerWithHook(t, logrus.WarnLevel) + b, err := getSimpleBundle(t, "/main.js", `export { default } from "/A/A/A/A/script.js"`, fs, logger) + + if testCase.expectedError != "" { + require.ErrorContains(t, err, testCase.expectedError) + return + } + require.NoError(t, err) + + _, err = b.Instantiate(context.Background(), 0) + require.NoError(t, err) + logs := hook.Drain() + + if len(testCase.expectedLogs) == 0 { + require.Empty(t, logs) + return + } + require.Equal(t, len(logs), len(testCase.expectedLogs)) + + for i, log := range logs { + require.Contains(t, log.Message, testCase.expectedLogs[i], "log line %d", i) + } + }) + } +} diff --git a/js/tc39/breaking_test_errors-experimental_enhanced.json b/js/tc39/breaking_test_errors-experimental_enhanced.json index 80c0c043558..88528f6ad21 100644 --- a/js/tc39/breaking_test_errors-experimental_enhanced.json +++ b/js/tc39/breaking_test_errors-experimental_enhanced.json @@ -52,8 +52,10 @@ "test/language/expressions/class/static-init-await-reference.js-strict:true": "test/language/expressions/class/static-init-await-reference.js: test/language/expressions/class/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", "test/language/expressions/function/static-init-await-reference.js-strict:true": "test/language/expressions/function/static-init-await-reference.js: test/language/expressions/function/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", "test/language/expressions/generators/static-init-await-reference.js-strict:true": "test/language/expressions/generators/static-init-await-reference.js: test/language/expressions/generators/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", - "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js-strict:true": "panic while running test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: interface conversion: *sobek.Program is not sobek.ModuleRecord: missing method Evaluate", - "test/language/expressions/import.meta/same-object-returned.js-strict:true": "panic while running test/language/expressions/import.meta/same-object-returned.js: interface conversion: *sobek.Program is not sobek.ModuleRecord: missing method Evaluate", + "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js-strict:true": "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: file:///TestTC39/test262/test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: Line 28:25 import not supported in script (and 19 more errors)", + "test/language/expressions/import.meta/same-object-returned.js-strict:true": "test/language/expressions/import.meta/same-object-returned.js: file:///TestTC39/test262/test/language/expressions/import.meta/same-object-returned.js: Line 28:9 import not supported in script (and 3 more errors)", + "test/language/expressions/import.meta/syntax/goal-module-nested-function.js-strict:true": "test/language/expressions/import.meta/syntax/goal-module-nested-function.js: file:///TestTC39/test262/test/language/expressions/import.meta/syntax/goal-module-nested-function.js: Line 16:3 import not supported in script (and 4 more errors)", + "test/language/expressions/import.meta/syntax/goal-module.js-strict:true": "test/language/expressions/import.meta/syntax/goal-module.js: file:///TestTC39/test262/test/language/expressions/import.meta/syntax/goal-module.js: Line 15:1 import not supported in script (and 3 more errors)", "test/language/expressions/import.meta/syntax/goal-script.js-strict:true": "test/language/expressions/import.meta/syntax/goal-script.js: error is not an object (Test262: This statement should not be evaluated.)", "test/language/expressions/in/private-field-rhs-await-absent.js-strict:true": "test/language/expressions/in/private-field-rhs-await-absent.js: test/language/expressions/in/private-field-rhs-await-absent.js: Line 24:10 Unexpected token await", "test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js-strict:true": "test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js: test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js: Line 29:4 Unexpected token ILLEGAL (and 6 more errors)", diff --git a/js/tc39/breaking_test_errors-extended.json b/js/tc39/breaking_test_errors-extended.json index 80c0c043558..88528f6ad21 100644 --- a/js/tc39/breaking_test_errors-extended.json +++ b/js/tc39/breaking_test_errors-extended.json @@ -52,8 +52,10 @@ "test/language/expressions/class/static-init-await-reference.js-strict:true": "test/language/expressions/class/static-init-await-reference.js: test/language/expressions/class/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", "test/language/expressions/function/static-init-await-reference.js-strict:true": "test/language/expressions/function/static-init-await-reference.js: test/language/expressions/function/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", "test/language/expressions/generators/static-init-await-reference.js-strict:true": "test/language/expressions/generators/static-init-await-reference.js: test/language/expressions/generators/static-init-await-reference.js: Line 15:5 Unexpected token await (and 1 more errors)", - "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js-strict:true": "panic while running test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: interface conversion: *sobek.Program is not sobek.ModuleRecord: missing method Evaluate", - "test/language/expressions/import.meta/same-object-returned.js-strict:true": "panic while running test/language/expressions/import.meta/same-object-returned.js: interface conversion: *sobek.Program is not sobek.ModuleRecord: missing method Evaluate", + "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js-strict:true": "test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: file:///TestTC39/test262/test/language/expressions/import.meta/import-meta-is-an-ordinary-object.js: Line 28:25 import not supported in script (and 19 more errors)", + "test/language/expressions/import.meta/same-object-returned.js-strict:true": "test/language/expressions/import.meta/same-object-returned.js: file:///TestTC39/test262/test/language/expressions/import.meta/same-object-returned.js: Line 28:9 import not supported in script (and 3 more errors)", + "test/language/expressions/import.meta/syntax/goal-module-nested-function.js-strict:true": "test/language/expressions/import.meta/syntax/goal-module-nested-function.js: file:///TestTC39/test262/test/language/expressions/import.meta/syntax/goal-module-nested-function.js: Line 16:3 import not supported in script (and 4 more errors)", + "test/language/expressions/import.meta/syntax/goal-module.js-strict:true": "test/language/expressions/import.meta/syntax/goal-module.js: file:///TestTC39/test262/test/language/expressions/import.meta/syntax/goal-module.js: Line 15:1 import not supported in script (and 3 more errors)", "test/language/expressions/import.meta/syntax/goal-script.js-strict:true": "test/language/expressions/import.meta/syntax/goal-script.js: error is not an object (Test262: This statement should not be evaluated.)", "test/language/expressions/in/private-field-rhs-await-absent.js-strict:true": "test/language/expressions/in/private-field-rhs-await-absent.js: test/language/expressions/in/private-field-rhs-await-absent.js: Line 24:10 Unexpected token await", "test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js-strict:true": "test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js: test/language/expressions/object/cpn-obj-lit-computed-property-name-from-integer-separators.js: Line 29:4 Unexpected token ILLEGAL (and 6 more errors)",