diff --git a/README.md b/README.md index 9f3ee62a..aa80fb9a 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,19 @@ $ deno run --allow-read run.mjs - The WasmGC reference interpreter can be used to validate and convert between the binary and text form: - https://github.com/WebAssembly/gc/tree/main/interpreter - Use docker image for it https://github.com/tanishiking/wasmgc-docker + +### Testing + +Requires NodeJS >= 22 (for enough support of WasmGC). + +```sh +$ NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/nightly nvm install v22 +``` + +- `tests/test` will + - Run `testSuite/run` to compile the Scala code under `test-suite` to WebAssembly + - Run the WebAssembly binary using NodeJS +- Each Scala program in `test-suite` should have a function that has no arguments and return a Boolean value. The test passes if the function returns `true`. +- When you add a test, + - Add a file under `test-suite` + - Add a test case to `cli/src/main/scala/TestSuites.scala` (`methodName` should be a exported function name). \ No newline at end of file diff --git a/build.sbt b/build.sbt index 0a65265d..237bdbe7 100644 --- a/build.sbt +++ b/build.sbt @@ -9,9 +9,15 @@ lazy val cli = project scalaJSUseMainModuleInitializer := true, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule), - } + }, + libraryDependencies ++= Seq( + "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1" + ), + ) + .dependsOn( + wasm, + // tests // for TestSuites constant ) - .dependsOn(wasm) lazy val wasm = project .in(file("wasm")) @@ -21,7 +27,6 @@ lazy val wasm = project version := "0.1.0-SNAPSHOT", scalaVersion := scalaV, libraryDependencies ++= Seq( - "org.scalameta" %% "munit" % "0.7.29" % Test, "org.scala-js" %%% "scalajs-linker" % "1.15.0" ), scalaJSUseMainModuleInitializer := true, @@ -41,14 +46,50 @@ lazy val sample = project import org.scalajs.jsenv.nodejs.NodeJSEnv val cp = Attributed .data((Compile / fullClasspath).value) - // .filter { path => - // val pathStr = path.toString() - // println(pathStr) - // pathStr.contains("sample/target") - // } .mkString(";") - val env = Map("SCALAJS_CLASSPATH" -> cp, "SCALAJS_MODE" -> "sample") + val env = Map( + "SCALAJS_CLASSPATH" -> cp, + "SCALAJS_MODE" -> "sample", + ) + new NodeJSEnv(NodeJSEnv.Config().withEnv(env).withArgs(List("--enable-source-maps"))) + }, + Compile / jsEnvInput := (`cli` / Compile / jsEnvInput).value + ) + +lazy val testSuite = project + .in(file("test-suite")) + .enablePlugins(ScalaJSPlugin) + .settings( + scalaVersion := scalaV, + scalaJSUseMainModuleInitializer := true, + Compile / jsEnv := { + import org.scalajs.jsenv.nodejs.NodeJSEnv + val cp = Attributed + .data((Compile / fullClasspath).value) + .mkString(";") + val env = Map( + "SCALAJS_CLASSPATH" -> cp, + "SCALAJS_MODE" -> "testsuite", + ) new NodeJSEnv(NodeJSEnv.Config().withEnv(env).withArgs(List("--enable-source-maps"))) }, Compile / jsEnvInput := (`cli` / Compile / jsEnvInput).value ) + +lazy val tests = project + .in(file("tests")) + .enablePlugins(ScalaJSPlugin) + .settings( + scalaVersion := scalaV, + libraryDependencies ++= Seq( + "org.scalameta" %%% "munit" % "0.7.29" % Test, + "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1" % Test + ), + scalaJSLinkerConfig ~= { + _.withModuleKind(ModuleKind.CommonJSModule), + }, + test := Def.sequential( + (testSuite / Compile / run).toTask(""), + (Test / test) + ).value + ).dependsOn(cli) diff --git a/cli/src/main/scala/Main.scala b/cli/src/main/scala/Main.scala index fc714241..708a41b6 100644 --- a/cli/src/main/scala/Main.scala +++ b/cli/src/main/scala/Main.scala @@ -2,35 +2,68 @@ package cli import scala.scalajs.js -import scala.concurrent.ExecutionContext import wasm.Compiler +import org.scalajs.linker.interface.ModuleInitializer +import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ + +import scala.concurrent.Future + object Main { - private implicit val ec: ExecutionContext = ExecutionContext.global def main(args: Array[String]): Unit = { val modeEnvVar = js.Dynamic.global.process.env.SCALAJS_MODE - val cpEnvVar = js.Dynamic.global.process.env.SCALAJS_CLASSPATH + val classpath = (cpEnvVar: Any) match { case cpEnvVar: String if cpEnvVar != "" => cpEnvVar.split(';').toList case _ => throw new IllegalArgumentException("The classpath was not provided.") } - println(classpath) - - val result = for { - irFiles <- new CliReader(classpath).irFiles - _ <- Compiler.compileIRFiles(irFiles) - } yield { - println("Module successfully initialized") - () + + val mode = (modeEnvVar: Any) match { + case modeEnvVar if modeEnvVar == "testsuite" => "testsuite" + case _ => "compile" } + + val result = + if (mode == "testsuite") { + val className = TestSuites.suites.map(_.className) + val moduleInitializers = className + .map { clazz => + ModuleInitializer.mainMethod(clazz, "main") + } + .zip(className) + + for { + irFiles <- new CliReader(classpath).irFiles + _ <- Future.sequence { + moduleInitializers.map { case (moduleInitializer, className) => + Compiler.compileIRFiles( + irFiles, + List(moduleInitializer), + s"$className" + ) + } + } + } yield { + println("Module successfully initialized") + () + } + } else { + for { + irFiles <- new CliReader(classpath).irFiles + _ <- Compiler.compileIRFiles(irFiles, Nil, s"output") + } yield { + println("Module successfully initialized") + () + } + } + result.recover { case th: Throwable => System.err.println("Module initialization failed:") th.printStackTrace() js.Dynamic.global.process.exit(1) } - } } diff --git a/cli/src/main/scala/TestSuites.scala b/cli/src/main/scala/TestSuites.scala new file mode 100644 index 00000000..d737e910 --- /dev/null +++ b/cli/src/main/scala/TestSuites.scala @@ -0,0 +1,9 @@ +package cli + +object TestSuites { + case class TestSuite(className: String, methodName: String) + val suites = List( + TestSuite("testsuite.core.simple.Simple", "simple"), + TestSuite("testsuite.core.add.Add", "add") + ) +} diff --git a/test-suite/src/main/scala/testsuite/core/Add.scala b/test-suite/src/main/scala/testsuite/core/Add.scala new file mode 100644 index 00000000..57fcc238 --- /dev/null +++ b/test-suite/src/main/scala/testsuite/core/Add.scala @@ -0,0 +1,11 @@ +package testsuite.core.add + +import scala.scalajs.js.annotation._ + +object Add { + def main(): Unit = { val _ = test() } + @JSExportTopLevel("add") + def test(): Boolean = { + 1 + 1 == 2 + } +} diff --git a/test-suite/src/main/scala/testsuite/core/Simple.scala b/test-suite/src/main/scala/testsuite/core/Simple.scala new file mode 100644 index 00000000..5c46855e --- /dev/null +++ b/test-suite/src/main/scala/testsuite/core/Simple.scala @@ -0,0 +1,11 @@ +package testsuite.core.simple + +import scala.scalajs.js.annotation._ + +object Simple { + def main(): Unit = { val _ = test() } + @JSExportTopLevel("simple") + def test(): Boolean = { + true + } +} diff --git a/tests/src/test/scala/tests/CoreTests.scala b/tests/src/test/scala/tests/CoreTests.scala new file mode 100644 index 00000000..ec443c67 --- /dev/null +++ b/tests/src/test/scala/tests/CoreTests.scala @@ -0,0 +1,29 @@ +package tests + +import scala.scalajs.js +import scala.scalajs.js.annotation._ +import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ + +class CoreTests extends munit.FunSuite { + cli.TestSuites.suites.map { suite => + test(suite.className) { + val file = s"./target/${suite.className}.wasm" + val wasmBuffer = FS.readFileSync(file) + val wasmModule = + js.Dynamic.global.WebAssembly.instantiate(wasmBuffer).asInstanceOf[js.Promise[js.Dynamic]] + wasmModule.toFuture.map { module => + val testFunction = + module.instance.exports + .selectDynamic(suite.methodName) + .asInstanceOf[js.Function0[Int]] + assert(testFunction() == 1) + } + } + } + +} + +private object FS { + @js.native @JSImport("fs") + def readFileSync(file: String): js.typedarray.Uint8Array = js.native +} diff --git a/wasm/src/main/scala/Compiler.scala b/wasm/src/main/scala/Compiler.scala index a84cee11..4793a8c8 100644 --- a/wasm/src/main/scala/Compiler.scala +++ b/wasm/src/main/scala/Compiler.scala @@ -8,7 +8,7 @@ import org.scalajs.ir.Trees._ import org.scalajs.ir.Types._ import org.scalajs.linker.frontend.LinkerFrontendImpl -import org.scalajs.linker.interface.IRFile +import org.scalajs.linker.interface.{IRFile, ModuleInitializer} import org.scalajs.linker.standard.{LinkedClass, SymbolRequirement} import org.scalajs.logging.{Level, ScalaConsoleLogger} @@ -20,22 +20,32 @@ import scala.scalajs.js.annotation._ import scala.scalajs.js.typedarray._ object Compiler { - def compileIRFiles(irFiles: Seq[IRFile])(implicit ec: ExecutionContext): Future[Unit] = { + def compileIRFiles( + irFiles: Seq[IRFile], + moduleInitializers: List[ModuleInitializer], + outputName: String + )(implicit ec: ExecutionContext): Future[Unit] = { val module = new WasmModule val builder = new WasmBuilder() implicit val context: WasmContext = new WasmContext(module) - println("compiling") + println("compiling... ") - val config = LinkerFrontendImpl.Config() + val config = LinkerFrontendImpl + .Config() .withOptimizer(false) val linkerFrontend = LinkerFrontendImpl(config) val symbolRequirements = SymbolRequirement.factory("none").none() - val logger = new ScalaConsoleLogger(Level.Error) + val logger = new ScalaConsoleLogger(Level.Info) for { patchedIRFiles <- LibraryPatches.patchIRFiles(irFiles) - moduleSet <- linkerFrontend.link(patchedIRFiles, Nil, symbolRequirements, logger) + moduleSet <- linkerFrontend.link( + patchedIRFiles, + moduleInitializers, + symbolRequirements, + logger + ) } yield { val onlyModule = moduleSet.modules.head @@ -46,17 +56,18 @@ object Compiler { filteredClasses.sortBy(_.className).foreach(showLinkedClass(_)) Preprocessor.preprocess(filteredClasses)(context) + println("preprocessed") filteredClasses.foreach { clazz => builder.transformClassDef(clazz) } onlyModule.topLevelExports.foreach { tle => builder.transformTopLevelExport(tle) } - val writer = new converters.WasmTextWriter() - println(writer.write(module)) + val textOutput = new converters.WasmTextWriter().write(module) + FS.writeFileSync(s"./target/$outputName.wat", textOutput.getBytes().toTypedArray) val binaryOutput = new converters.WasmBinaryWriter(module).write() - FS.writeFileSync("./target/output.wasm", binaryOutput.toTypedArray) + FS.writeFileSync(s"./target/$outputName.wasm", binaryOutput.toTypedArray) } } @@ -112,7 +123,9 @@ object Compiler { ::: clazz.jsConstructorDef.toList ::: clazz.exportedMembers ::: clazz.jsNativeMembers, - "{", "", "}" + "{", + "", + "}" ) } } diff --git a/wasm/src/main/scala/ir2wasm/TypeTransformer.scala b/wasm/src/main/scala/ir2wasm/TypeTransformer.scala index a94724c4..b2d448f7 100644 --- a/wasm/src/main/scala/ir2wasm/TypeTransformer.scala +++ b/wasm/src/main/scala/ir2wasm/TypeTransformer.scala @@ -35,8 +35,9 @@ object TypeTransformer { t: IRTypes.Type )(implicit ctx: ReadOnlyWasmContext): List[Types.WasmType] = t match { - case IRTypes.NoType => Nil - case _ => List(transformType(t)) + case IRTypes.NoType => Nil + case IRTypes.ClassType(className) if className == IRNames.BoxedUnitClass => Nil + case _ => List(transformType(t)) } def transformType(t: IRTypes.Type)(implicit ctx: ReadOnlyWasmContext): Types.WasmType = t match { diff --git a/wasm/src/main/scala/ir2wasm/WasmExpressionBuilder.scala b/wasm/src/main/scala/ir2wasm/WasmExpressionBuilder.scala index 8f7dd640..dd621366 100644 --- a/wasm/src/main/scala/ir2wasm/WasmExpressionBuilder.scala +++ b/wasm/src/main/scala/ir2wasm/WasmExpressionBuilder.scala @@ -77,7 +77,6 @@ class WasmExpressionBuilder(ctx: FunctionTypeWriterWasmContext, fctx: WasmFuncti println(tree) ??? - // case undef: IRTrees.Undefined => ??? // case unary: IRTrees.JSUnaryOp => ??? // case select: IRTrees.JSPrivateSelect => ??? // case nul: IRTrees.Null => ??? @@ -276,19 +275,21 @@ class WasmExpressionBuilder(ctx: FunctionTypeWriterWasmContext, fctx: WasmFuncti case IRTrees.FloatLiteral(v) => WasmInstr.F32_CONST(F32(v)) :: Nil case IRTrees.DoubleLiteral(v) => WasmInstr.F64_CONST(F64(v)) :: Nil - case v: IRTrees.Undefined => WasmInstr.GLOBAL_GET(GlobalIdx(WasmGlobalName.WasmUndefName)) :: Nil - case v: IRTrees.Null => ??? + case v: IRTrees.Undefined => + WasmInstr.GLOBAL_GET(GlobalIdx(WasmGlobalName.WasmUndefName)) :: Nil + case v: IRTrees.Null => ??? case v: IRTrees.StringLiteral => // TODO We should allocate literal strings once and for all as globals val str = v.value str.toList.map(c => WasmInstr.I32_CONST(I32(c.toInt))) ::: List( - WasmInstr.ARRAY_NEW_FIXED(TypeIdx(WasmTypeName.WasmArrayTypeName.stringData), I32(str.length())), + WasmInstr + .ARRAY_NEW_FIXED(TypeIdx(WasmTypeName.WasmArrayTypeName.stringData), I32(str.length())), WasmInstr.STRUCT_NEW(TypeIdx(WasmTypeName.WasmStructTypeName.string)) ) - case v: IRTrees.ClassOf => ??? + case v: IRTrees.ClassOf => ??? } private def transformSelect(sel: IRTrees.Select): List[WasmInstr] = { @@ -544,14 +545,20 @@ class WasmExpressionBuilder(ctx: FunctionTypeWriterWasmContext, fctx: WasmFuncti } private def transformVarDef(r: IRTrees.VarDef): List[WasmInstr] = { - val local = WasmLocal( - WasmLocalName.fromIR(r.name.name), - TypeTransformer.transformType(r.vtpe)(ctx), - isParameter = false - ) - fctx.locals.define(local) + r.vtpe match { + // val _: Unit = rhs + case ClassType(className) if className == IRNames.BoxedUnitClass => + transformTree(r.rhs) :+ DROP + case _ => + val local = WasmLocal( + WasmLocalName.fromIR(r.name.name), + TypeTransformer.transformType(r.vtpe)(ctx), + isParameter = false + ) + fctx.locals.define(local) - transformTree(r.rhs) :+ LOCAL_SET(LocalIdx(local.name)) + transformTree(r.rhs) :+ LOCAL_SET(LocalIdx(local.name)) + } } private def transformIf(t: IRTrees.If): List[WasmInstr] = { @@ -577,7 +584,7 @@ class WasmExpressionBuilder(ctx: FunctionTypeWriterWasmContext, fctx: WasmFuncti // end // unreachable List( - LOOP(noResultType, Some(label)), + LOOP(noResultType, Some(label)) ) ++ transformTree(t.body) ++ List( BR(label), @@ -595,10 +602,10 @@ class WasmExpressionBuilder(ctx: FunctionTypeWriterWasmContext, fctx: WasmFuncti // end List( - LOOP(noResultType, Some(label)), + LOOP(noResultType, Some(label)) ) ++ transformTree(t.cond) ++ List( - IF(noResultType), + IF(noResultType) ) ++ transformTree(t.body) ++ List( diff --git a/wasm/src/test/scala/MySuite.scala b/wasm/src/test/scala/MySuite.scala deleted file mode 100644 index 621784d1..00000000 --- a/wasm/src/test/scala/MySuite.scala +++ /dev/null @@ -1,9 +0,0 @@ -// For more information on writing tests, see -// https://scalameta.org/munit/docs/getting-started.html -class MySuite extends munit.FunSuite { - test("example test that succeeds") { - val obtained = 42 - val expected = 42 - assertEquals(obtained, expected) - } -}