Skip to content

Commit

Permalink
Support ECMAScript modules (export/import statements, modules definit…
Browse files Browse the repository at this point in the history
…ion) (#1054)
  • Loading branch information
christianrondeau committed Mar 3, 2022
1 parent e31a0a7 commit fcc7c8d
Show file tree
Hide file tree
Showing 42 changed files with 1,147 additions and 181 deletions.
5 changes: 3 additions & 2 deletions Jint.Tests.Test262/Language/ModuleTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Jint.Runtime;
using Jint.Runtime.Modules;
using System;
using System.IO;
using System.Reflection;
using Xunit;
using Xunit.Sdk;

Expand Down Expand Up @@ -43,8 +45,7 @@ private static void RunModuleTest(SourceFile sourceFile)

var options = new Options();
options.Host.Factory = _ => new ModuleTestHost();
options.Modules.Enabled = true;
options.WithModuleLoader(new DefaultModuleLoader(null));
options.EnableModules(Path.Combine(BasePath, "test"));

var engine = new Engine(options);

Expand Down
2 changes: 1 addition & 1 deletion Jint.Tests.Test262/Test262Test.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public abstract class Test262Test
{
private static readonly Dictionary<string, Script> Sources;

private static readonly string BasePath;
protected static readonly string BasePath;

private static readonly TimeZoneInfo _pacificTimeZone;

Expand Down
6 changes: 1 addition & 5 deletions Jint.Tests/Jint.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
<AssemblyOriginatorKeyFile>..\Jint\Jint.snk</AssemblyOriginatorKeyFile>
<SignAssembly>true</SignAssembly>
<IsPackable>false</IsPackable>
<!--
Unity currently supports only C# 8 so ensure we can use the features Jint introduces
https://docs.unity3d.com/Manual/CSharpCompiler.html
-->
<LangVersion>8</LangVersion>
<LangVersion>latest</LangVersion>
<NoWarn>612</NoWarn>
</PropertyGroup>
<ItemGroup>
Expand Down
249 changes: 249 additions & 0 deletions Jint.Tests/Runtime/ModuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
#if(NET6_0_OR_GREATER)
using System.IO;
using System.Reflection;
#endif
using System;
using Jint.Native;
using Jint.Runtime;
using Xunit;

namespace Jint.Tests.Runtime;

public class ModuleTests
{
private readonly Engine _engine;

public ModuleTests()
{
_engine = new Engine();
}

[Fact]
public void ShouldExportNamed()
{
_engine.AddModule("my-module", @"export const value = 'exported value';");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("value").AsString());
}

[Fact]
public void ShouldExportNamedListRenamed()
{
_engine.AddModule("my-module", @"const value1 = 1; const value2 = 2; export { value1 as renamed1, value2 as renamed2 }");
var ns = _engine.ImportModule("my-module");

Assert.Equal(1, ns.Get("renamed1").AsInteger());
Assert.Equal(2, ns.Get("renamed2").AsInteger());
}

[Fact]
public void ShouldExportDefault()
{
_engine.AddModule("my-module", @"export default 'exported value';");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("default").AsString());
}

[Fact]
public void ShouldExportAll()
{
_engine.AddModule("module1", @"export const value = 'exported value';");
_engine.AddModule("module2", @"export * from 'module1';");
var ns = _engine.ImportModule("module2");

Assert.Equal("exported value", ns.Get("value").AsString());
}

[Fact]
public void ShouldImportNamed()
{
_engine.AddModule("imported-module", @"export const value = 'exported value';");
_engine.AddModule("my-module", @"import { value } from 'imported-module'; export const exported = value;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("exported").AsString());
}

[Fact]
public void ShouldImportRenamed()
{
_engine.AddModule("imported-module", @"export const value = 'exported value';");
_engine.AddModule("my-module", @"import { value as renamed } from 'imported-module'; export const exported = renamed;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("exported").AsString());
}

[Fact]
public void ShouldImportDefault()
{
_engine.AddModule("imported-module", @"export default 'exported value';");
_engine.AddModule("my-module", @"import imported from 'imported-module'; export const exported = imported;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("exported").AsString());
}

[Fact]
public void ShouldImportAll()
{
_engine.AddModule("imported-module", @"export const value = 'exported value';");
_engine.AddModule("my-module", @"import * as imported from 'imported-module'; export const exported = imported.value;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("exported value", ns.Get("exported").AsString());
}

[Fact]
public void ShouldPropagateThrowStatementOnCSharpImport()
{
_engine.AddModule("my-module", @"throw new Error('imported successfully');");

var exc = Assert.Throws<JavaScriptException>(() => _engine.ImportModule("my-module"));
Assert.Equal("imported successfully", exc.Message);
Assert.Equal("my-module", exc.Location.Source);
}

[Fact]
public void ShouldPropagateThrowStatementThroughJavaScriptImport()
{
_engine.AddModule("imported-module", @"throw new Error('imported successfully');");
_engine.AddModule("my-module", @"import 'imported-module';");

var exc = Assert.Throws<JavaScriptException>(() => _engine.ImportModule("my-module"));
Assert.Equal("imported successfully", exc.Message);
Assert.Equal("imported-module", exc.Location.Source);
}

[Fact]
public void ShouldAddModuleFromJsValue()
{
_engine.AddModule("my-module", builder => builder.ExportValue("value", JsString.Create("hello world")));
var ns = _engine.ImportModule("my-module");

Assert.Equal("hello world", ns.Get("value").AsString());
}

[Fact]
public void ShouldAddModuleFromClrInstance()
{
_engine.AddModule("imported-module", builder => builder.ExportObject("value", new ImportedClass { Value = "instance value" }));
_engine.AddModule("my-module", @"import { value } from 'imported-module'; export const exported = value.value;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("instance value", ns.Get("exported").AsString());
}

[Fact]
public void ShouldAllowInvokeUserDefinedClass()
{
_engine.AddModule("user", "export class UserDefined { constructor(v) { this._v = v; } hello(c) { return `hello ${this._v}${c}`; } }");
var ctor = _engine.ImportModule("user").Get("UserDefined");
var instance = _engine.Construct(ctor, JsString.Create("world"));
var result = instance.GetMethod("hello").Call(instance, JsString.Create("!"));

Assert.Equal("hello world!", result);
}

[Fact]
public void ShouldAddModuleFromClrType()
{
_engine.AddModule("imported-module", builder => builder.ExportType<ImportedClass>());
_engine.AddModule("my-module", @"import { ImportedClass } from 'imported-module'; export const exported = new ImportedClass().value;");
var ns = _engine.ImportModule("my-module");

Assert.Equal("hello world", ns.Get("exported").AsString());
}

private class ImportedClass
{
public string Value { get; set; } = "hello world";
}

[Fact]
public void ShouldAllowExportMultipleImports()
{
_engine.AddModule("@mine/import1", builder => builder.ExportValue("value1", JsNumber.Create(1)));
_engine.AddModule("@mine/import2", builder => builder.ExportValue("value2", JsNumber.Create(2)));
_engine.AddModule("@mine", "export * from '@mine/import1'; export * from '@mine/import2'");
_engine.AddModule("app", @"import { value1, value2 } from '@mine'; export const result = `${value1} ${value2}`");
var ns = _engine.ImportModule("app");

Assert.Equal("1 2", ns.Get("result").AsString());
}

/* ECMAScript 2020 "export * as ns from"
[Fact]
public void ShouldAllowNamedStarExport()
{
_engine.AddModule("imported-module", builder => builder.ExportValue("value1", 5));
_engine.AddModule("my-module", "export * as ns from 'imported-module';");
var ns = _engine.ImportModule("my-module");
Assert.Equal(5, ns.Get("ns").Get("value1").AsNumber());
}
*/

[Fact]
public void ShouldAllowChaining()
{
_engine.AddModule("dependent-module", "export const dependency = 1;");
_engine.AddModule("my-module", builder => builder
.AddSource("import { dependency } from 'dependent-module';")
.AddSource("export const output = dependency + 1;")
.ExportValue("num", JsNumber.Create(-1))
);
var ns = _engine.ImportModule("my-module");

Assert.Equal(2, ns.Get("output").AsInteger());
Assert.Equal(-1, ns.Get("num").AsInteger());
}

[Fact]
public void ShouldAllowLoadingMoreThanOnce()
{
var called = 0;
_engine.AddModule("imported-module", builder => builder.ExportFunction("count", args => called++));
_engine.AddModule("my-module", @"import { count } from 'imported-module'; count();");
_engine.ImportModule("my-module");
_engine.ImportModule("my-module");

Assert.Equal(called, 1);
}

#if(NET6_0_OR_GREATER)

[Fact]
public void CanLoadModuleImportsFromFiles()
{
var engine = new Engine(options => options.EnableModules(GetBasePath()));
engine.AddModule("my-module", "import { User } from './modules/user.js'; export const user = new User('John', 'Doe');");
var ns = engine.ImportModule("my-module");

Assert.Equal("John Doe", ns["user"].Get("name").AsString());
}

[Fact]
public void CanImportFromFile()
{
var engine = new Engine(options => options.EnableModules(GetBasePath()));
var ns = engine.ImportModule("./modules/format-name.js");
var result = engine.Invoke(ns.Get("formatName"), "John", "Doe").AsString();

Assert.Equal("John Doe", result);
}

private static string GetBasePath()
{
var assemblyPath = new Uri(typeof(ModuleTests).GetTypeInfo().Assembly.Location).LocalPath;
var assemblyDirectory = new FileInfo(assemblyPath).Directory;
return Path.Combine(
assemblyDirectory?.Parent?.Parent?.Parent?.FullName ?? throw new NullReferenceException("Could not find tests base path"),
"Runtime",
"Scripts");
}

#endif
}
51 changes: 51 additions & 0 deletions Jint.Tests/Runtime/Modules/DefaultModuleResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Jint.Runtime.Modules;
using Xunit;

namespace Jint.Tests.Runtime.Modules;

public class DefaultModuleLoaderTests
{
[Theory]
[InlineData("./other.js", @"file:///project/folder/other.js")]
[InlineData("../model/other.js", @"file:///project/model/other.js")]
[InlineData("/project/model/other.js", @"file:///project/model/other.js")]
[InlineData("file:///project/model/other.js", @"file:///project/model/other.js")]
public void ShouldResolveRelativePaths(string specifier, string expectedUri)
{
var resolver = new DefaultModuleLoader("file:///project");

var resolved = resolver.Resolve("file:///project/folder/script.js", specifier);

Assert.Equal(specifier, resolved.Specifier);
Assert.Equal(expectedUri, resolved.Key);
Assert.Equal(expectedUri, resolved.Uri?.AbsoluteUri);
Assert.Equal(SpecifierType.RelativeOrAbsolute, resolved.Type);
}

[Theory]
[InlineData("./../../other.js")]
[InlineData("../../model/other.js")]
[InlineData("/model/other.js")]
[InlineData("file:///etc/secret.js")]
public void ShouldRejectPathsOutsideOfBasePath(string specifier)
{
var resolver = new DefaultModuleLoader("file:///project");

var exc = Assert.Throws<ModuleResolutionException>(() => resolver.Resolve("file:///project/folder/script.js", specifier));
Assert.StartsWith(exc.ResolverAlgorithmError, "Unauthorized Module Path");
Assert.StartsWith(exc.Specifier, specifier);
}

[Fact]
public void ShouldResolveBareSpecifiers()
{
var resolver = new DefaultModuleLoader("/");

var resolved = resolver.Resolve(null, "my-module");

Assert.Equal("my-module", resolved.Specifier);
Assert.Equal("my-module", resolved.Key);
Assert.Equal(null, resolved.Uri?.AbsoluteUri);
Assert.Equal(SpecifierType.Bare, resolved.Type);
}
}
3 changes: 3 additions & 0 deletions Jint.Tests/Runtime/Scripts/modules/format-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function formatName(firstName, lastName) {
return `${firstName} ${lastName}`;
}
14 changes: 14 additions & 0 deletions Jint.Tests/Runtime/Scripts/modules/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { formatName as nameFormatter } from './format-name.js';

class User {
constructor(firstName, lastName) {
this._firstName = firstName;
this._lastName = lastName;
}

get name() {
return nameFormatter(this._firstName, this._lastName);
}
}

export { User };

0 comments on commit fcc7c8d

Please sign in to comment.