Repository: source-academy/js-slang
Pull request #1471 (2023-08-22 07:49:19)

Use Asynchronous Code for Loading Modules
This PR is mainly aimed at addressing #1348 . I have only been able to write an async implementation for the transpiler and stepper. Both the interpreter and ec-evaluator will need further work and discussion for such implementations.

This PR also incorporates the changes from #1470 .

martin-henz: > This PR is mainly aimed at addressing #1348 . I have only been able to write an async implementation for the transpiler and stepper. Both the interpreter and ec-evaluator will need further work and discussion for such implementations.
> 
> This PR also incorporates the changes from #1470 .

I think the interpreter is no longer in use, see https://github.com/source-academy/js-slang/issues/1348

So what would remain to be refactored is the module loading of ec-evaluator.
leeyi45: I'll do another once over myself, but if all's good I'll do the merge

Patch:
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..874bf8b8
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,2 @@
+[*]
+end_of_line = lf
\ No newline at end of file
diff --git a/.eslintrc.json b/.eslintrc.json
index 79e62881..7c2e486a 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -9,6 +9,7 @@
     "plugin:@typescript-eslint/recommended-requiring-type-checking",
     "prettier"
   ],
+  "ignorePatterns": ["src/**/__tests__/**", "src/**/__mocks__/**"],
   "parser": "@typescript-eslint/parser",
   "parserOptions": {
     "project": "tsconfig.json",
@@ -45,5 +46,12 @@
     "@typescript-eslint/unbound-method": "off",
     "prefer-rest-params": "off",
     "simple-import-sort/imports": "warn"
-  }
+  },
+  "overrides": [{
+    "files": ["src/**/__tests__/**", "src/**/__mocks__/**"],
+    "plugins": ["simple-import-sort"],
+    "rules": {
+      "simple-import-sort/imports": "warn"
+    }
+  }]
 }
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index 3ea972a6..86f590c0 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,3 @@
 /src/scm-slang/
-/src/py-slang/
\ No newline at end of file
+/src/py-slang/
+/src/**/__tests__/**/__snapshots__
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
index c63ef6c8..5e5702b6 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -4,5 +4,6 @@
   "printWidth": 100,
   "parser": "typescript",
   "trailingComma": "none",
-  "arrowParens": "avoid"
+  "arrowParens": "avoid",
+  "endOfLine": "lf"
 }
diff --git a/src/ec-evaluator/interpreter.ts b/src/ec-evaluator/interpreter.ts
index e10b0d7a..e1299973 100644
--- a/src/ec-evaluator/interpreter.ts
+++ b/src/ec-evaluator/interpreter.ts
@@ -7,7 +7,7 @@
 
 /* tslint:disable:max-classes-per-file */
 import * as es from 'estree'
-import { partition, uniqueId } from 'lodash'
+import { uniqueId } from 'lodash'
 
 import { IOptions } from '..'
 import { UNKNOWN_LOCATION } from '../constants'
@@ -15,10 +15,12 @@ import * as errors from '../errors/errors'
 import { RuntimeSourceError } from '../errors/runtimeSourceError'
 import Closure from '../interpreter/closure'
 import { UndefinedImportError } from '../modules/errors'
-import { loadModuleBundle, loadModuleTabs } from '../modules/moduleLoader'
-import { ModuleFunctions } from '../modules/moduleTypes'
+import { initModuleContext, loadModuleBundle } from '../modules/moduleLoader'
+import { ImportTransformOptions } from '../modules/moduleTypes'
 import { checkEditorBreakpoints } from '../stdlib/inspector'
 import { Context, ContiguousArrayElements, Result, Value } from '../types'
+import assert from '../utils/assert'
+import { filterImportDeclarations } from '../utils/ast/helpers'
 import * as ast from '../utils/astCreator'
 import { evaluateBinaryExpression, evaluateUnaryExpression } from '../utils/operators'
 import * as rttc from '../utils/rttc'
@@ -172,45 +174,31 @@ export function resumeEvaluate(context: Context) {
 function evaluateImports(
   program: es.Program,
   context: Context,
-  loadTabs: boolean,
-  checkImports: boolean
+  { loadTabs, checkImports }: ImportTransformOptions
 ) {
-  const [importNodes] = partition(program.body, ({ type }) => type === 'ImportDeclaration') as [
-    es.ImportDeclaration[],
-    es.Statement[]
-  ]
-  const moduleFunctions: Record<string, ModuleFunctions> = {}
-
   try {
-    for (const node of importNodes) {
-      const moduleName = node.source.value
-      if (typeof moduleName !== 'string') {
-        throw new Error(`ImportDeclarations should have string sources, got ${moduleName}`)
-      }
-
-      if (!(moduleName in moduleFunctions)) {
-        context.moduleContexts[moduleName] = {
-          state: null,
-          tabs: loadTabs ? loadModuleTabs(moduleName, node) : null
-        }
-        moduleFunctions[moduleName] = loadModuleBundle(moduleName, context, node)
-      }
-
-      const functions = moduleFunctions[moduleName]
-      const environment = currentEnvironment(context)
-      for (const spec of node.specifiers) {
-        if (spec.type !== 'ImportSpecifier') {
-          throw new Error(`Only ImportSpecifiers are supported, got: ${spec.type}`)
+    const [importNodeMap] = filterImportDeclarations(program)
+
+    const environment = currentEnvironment(context)
+    Object.entries(importNodeMap).forEach(([moduleName, nodes]) => {
+      initModuleContext(moduleName, context, loadTabs)
+      const functions = loadModuleBundle(moduleName, context, nodes[0])
+      for (const node of nodes) {
+        for (const spec of node.specifiers) {
+          assert(
+            spec.type === 'ImportSpecifier',
+            `Only ImportSpecifiers are supported, got: ${spec.type}`
+          )
+
+          if (checkImports && !(spec.imported.name in functions)) {
+            throw new UndefinedImportError(spec.imported.name, moduleName, spec)
+          }
+
+          declareIdentifier(context, spec.local.name, node, environment)
+          defineVariable(context, spec.local.name, functions[spec.imported.name], true, node)
         }
-
-        if (checkImports && !(spec.imported.name in functions)) {
-          throw new UndefinedImportError(spec.imported.name, moduleName, node)
-        }
-
-        declareIdentifier(context, spec.local.name, node, environment)
-        defineVariable(context, spec.local.name, functions[spec.imported.name], true, node)
       }
-    }
+    })
   } catch (error) {
     // console.log(error)
     handleRuntimeError(context, error)
@@ -335,7 +323,11 @@ const cmdEvaluators: { [type: string]: CmdEvaluator } = {
     if (hasDeclarations(command) || hasImportDeclarations(command)) {
       const environment = createBlockEnvironment(context, 'programEnvironment')
       pushEnvironment(context, environment)
-      evaluateImports(command as unknown as es.Program, context, true, true)
+      evaluateImports(command as unknown as es.Program, context, {
+        wrapSourceModules: true,
+        checkImports: true,
+        loadTabs: true
+      })
       declareFunctionsAndVariables(context, command, environment)
     }
 
diff --git a/src/index.ts b/src/index.ts
index e0f1e8b7..c26a13e7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -16,6 +16,7 @@ import {
   FuncDeclWithInferredTypeAnnotation,
   ModuleContext,
   NodeWithInferredType,
+  RecursivePartial,
   Result,
   SourceError,
   SVMProgram,
@@ -31,6 +32,7 @@ import { ECEResultPromise, resumeEvaluate } from './ec-evaluator/interpreter'
 import { CannotFindModuleError } from './errors/localImportErrors'
 import { validateFilePath } from './localImports/filePaths'
 import preprocessFileImports from './localImports/preprocessor'
+import type { ImportTransformOptions } from './modules/moduleTypes'
 import { getKeywords, getProgramNames, NameDeclaration } from './name-extractor'
 import { parse } from './parser/parser'
 import { decodeError, decodeValue } from './parser/scheme'
@@ -56,6 +58,8 @@ export interface IOptions {
   isPrelude: boolean
   throwInfiniteLoops: boolean
   envSteps: number
+
+  importOptions: ImportTransformOptions
 }
 
 // needed to work on browsers
@@ -303,7 +307,7 @@ export function getTypeInformation(
 export async function runInContext(
   code: string,
   context: Context,
-  options: Partial<IOptions> = {}
+  options: RecursivePartial<IOptions> = {}
 ): Promise<Result> {
   const defaultFilePath = '/default.js'
   const files: Partial<Record<string, string>> = {}
@@ -315,7 +319,7 @@ export async function runFilesInContext(
   files: Partial<Record<string, string>>,
   entrypointFilePath: string,
   context: Context,
-  options: Partial<IOptions> = {}
+  options: RecursivePartial<IOptions> = {}
 ): Promise<Result> {
   for (const filePath in files) {
     const filePathError = validateFilePath(filePath)
@@ -340,7 +344,13 @@ export async function runFilesInContext(
     if (program === null) {
       return resolvedErrorPromise
     }
-    return fullJSRunner(program, context, options)
+    const fullImportOptions = {
+      loadTabs: true,
+      checkImports: false,
+      wrapSourceModules: false,
+      ...options.importOptions
+    }
+    return fullJSRunner(program, context, fullImportOptions)
   }
 
   if (context.chapter === Chapter.HTML) {
diff --git a/src/infiniteLoops/instrument.ts b/src/infiniteLoops/instrument.ts
index c8c98d0e..6b3af974 100644
--- a/src/infiniteLoops/instrument.ts
+++ b/src/infiniteLoops/instrument.ts
@@ -574,25 +574,34 @@ function trackLocations(program: es.Program) {
   })
 }
 
-function handleImports(programs: es.Program[]): [string, string[]] {
-  const [prefixes, imports] = programs.reduce(
-    ([prefix, moduleNames], program) => {
-      const [prefixToAdd, importsToAdd, otherNodes] = transformImportDeclarations(
+async function handleImports(programs: es.Program[]): Promise<[string, string[]]> {
+  const transformed = await Promise.all(
+    programs.map(async program => {
+      const [prefixToAdd, importsToAdd, otherNodes] = await transformImportDeclarations(
         program,
         new Set<string>(),
-        false
+        {
+          wrapSourceModules: false,
+          checkImports: false,
+          loadTabs: false
+        }
       )
       program.body = (importsToAdd as es.Program['body']).concat(otherNodes)
-      prefix.push(prefixToAdd)
-
       const importedNames = importsToAdd.flatMap(node =>
         node.declarations.map(
           decl => ((decl.init as es.MemberExpression).object as es.Identifier).name
         )
       )
-      return [prefix, moduleNames.concat(importedNames)]
-    },
-    [[] as string[], [] as string[]]
+      return [prefixToAdd, importedNames] as [string, string[]]
+    })
+  )
+
+  const [prefixes, imports] = transformed.reduce(
+    ([prefixes, moduleNames], [prefix, importedNames]) => [
+      [...prefixes, prefix],
+      [...moduleNames, ...importedNames]
+    ],
+    [[], []] as [string[], string[]]
   )
 
   return [prefixes.join('\n'), [...new Set<string>(imports)]]
@@ -606,11 +615,11 @@ function handleImports(programs: es.Program[]): [string, string[]] {
  * @param builtins Names of builtin functions.
  * @returns code with instrumentations.
  */
-function instrument(
+async function instrument(
   previous: es.Program[],
   program: es.Program,
   builtins: Iterable<string>
-): string {
+): Promise<string> {
   const { builtinsId, functionsId, stateId } = globalIds
   const predefined = {}
   predefined[builtinsId] = builtinsId
@@ -618,7 +627,7 @@ function instrument(
   predefined[stateId] = stateId
   const innerProgram = { ...program }
 
-  const [prefix, moduleNames] = handleImports([program].concat(previous))
+  const [prefix, moduleNames] = await handleImports([program].concat(previous))
   for (const name of moduleNames) {
     predefined[name] = name
   }
diff --git a/src/infiniteLoops/runtime.ts b/src/infiniteLoops/runtime.ts
index a9b81b95..83783e25 100644
--- a/src/infiniteLoops/runtime.ts
+++ b/src/infiniteLoops/runtime.ts
@@ -305,7 +305,10 @@ functions[FunctionNames.evalU] = sym.evaluateHybridUnary
  * @param previousProgramsStack Any code previously entered in the REPL & parsed into AST.
  * @returns SourceError if an infinite loop was detected, undefined otherwise.
  */
-export function testForInfiniteLoop(program: es.Program, previousProgramsStack: es.Program[]) {
+export async function testForInfiniteLoop(
+  program: es.Program,
+  previousProgramsStack: es.Program[]
+) {
   const context = createContext(Chapter.SOURCE_4, Variant.DEFAULT, undefined, undefined)
   const prelude = parse(context.prelude as string, context) as es.Program
   context.prelude = null
@@ -313,7 +316,7 @@ export function testForInfiniteLoop(program: es.Program, previousProgramsStack:
   const newBuiltins = prepareBuiltins(context.nativeStorage.builtins)
   const { builtinsId, functionsId, stateId } = InfiniteLoopRuntimeObjectNames
 
-  const instrumentedCode = instrument(previous, program, newBuiltins.keys())
+  const instrumentedCode = await instrument(previous, program, newBuiltins.keys())
   const state = new st.State()
 
   const sandboxedRun = new Function(
@@ -327,7 +330,7 @@ export function testForInfiniteLoop(program: es.Program, previousProgramsStack:
   )
 
   try {
-    sandboxedRun(instrumentedCode, functions, state, newBuiltins, getRequireProvider(context))
+    await sandboxedRun(instrumentedCode, functions, state, newBuiltins, getRequireProvider(context))
   } catch (error) {
     if (error instanceof InfiniteLoopError) {
       if (state.lastLocation !== undefined) {
diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts
index b459f08a..f883d3a9 100644
--- a/src/interpreter/interpreter.ts
+++ b/src/interpreter/interpreter.ts
@@ -7,10 +7,11 @@ import { LazyBuiltIn } from '../createContext'
 import * as errors from '../errors/errors'
 import { RuntimeSourceError } from '../errors/runtimeSourceError'
 import { UndefinedImportError } from '../modules/errors'
-import { loadModuleBundle, loadModuleTabs } from '../modules/moduleLoader'
+import { initModuleContext, loadModuleBundle } from '../modules/moduleLoader'
 import { ModuleFunctions } from '../modules/moduleTypes'
 import { checkEditorBreakpoints } from '../stdlib/inspector'
 import { Context, ContiguousArrayElements, Environment, Frame, Value, Variant } from '../types'
+import assert from '../utils/assert'
 import * as create from '../utils/astCreator'
 import { conditionalExpression, literal, primitive } from '../utils/astCreator'
 import { evaluateBinaryExpression, evaluateUnaryExpression } from '../utils/operators'
@@ -734,27 +735,26 @@ export function* evaluateProgram(
       yield* visit(context, node)
 
       const moduleName = node.source.value
-      if (typeof moduleName !== 'string') {
-        throw new Error(`ImportDeclarations should have string sources, got ${moduleName}`)
-      }
+      assert(
+        typeof moduleName === 'string',
+        `ImportDeclarations should have string sources, got ${moduleName}`
+      )
 
       if (!(moduleName in moduleFunctions)) {
-        context.moduleContexts[moduleName] = {
-          state: null,
-          tabs: loadTabs ? loadModuleTabs(moduleName, node) : null
-        }
+        initModuleContext(moduleName, context, loadTabs)
         moduleFunctions[moduleName] = loadModuleBundle(moduleName, context, node)
       }
 
       const functions = moduleFunctions[moduleName]
 
       for (const spec of node.specifiers) {
-        if (spec.type !== 'ImportSpecifier') {
-          throw new Error(`Only Import Specifiers are supported, got ${spec.type}`)
-        }
+        assert(
+          spec.type === 'ImportSpecifier',
+          `Only Import Specifiers are supported, got ${spec.type}`
+        )
 
         if (checkImports && !(spec.imported.name in functions)) {
-          throw new UndefinedImportError(spec.imported.name, moduleName, node)
+          throw new UndefinedImportError(spec.imported.name, moduleName, spec)
         }
 
         declareIdentifier(context, spec.local.name, node)
diff --git a/src/localImports/directedGraph.ts b/src/localImports/directedGraph.ts
index c7ff436a..a6997a51 100644
--- a/src/localImports/directedGraph.ts
+++ b/src/localImports/directedGraph.ts
@@ -1,3 +1,5 @@
+import assert from '../utils/assert'
+
 /**
  * The result of attempting to find a topological ordering
  * of nodes on a DirectedGraph.
@@ -113,9 +115,10 @@ export class DirectedGraph {
     // all nodes have an in-degree of 0 after running Kahn's algorithm.
     // This in turn implies that Kahn's algorithm was able to find a
     // valid topological ordering & that the graph contains no cycles.
-    if (startingNodeInCycle === null) {
-      throw new Error('There are no cycles in this graph. This should never happen.')
-    }
+    assert(
+      startingNodeInCycle !== null,
+      'There are no cycles in this graph. This should never happen.'
+    )
 
     const cycle = [startingNodeInCycle]
     // Then, we keep picking arbitrary nodes with non-zero in-degrees until
@@ -132,9 +135,10 @@ export class DirectedGraph {
       // An in-degree of 0 implies that the node is not part of a cycle,
       // which is a contradiction since the current node was picked because
       // it is part of a cycle.
-      if (neighbours.size === 0) {
-        throw new Error(`Node '${currentNode}' has no incoming edges. This should never happen.`)
-      }
+      assert(
+        neighbours.size > 0,
+        `Node '${currentNode}' has no incoming edges. This should never happen.`
+      )
 
       let nextNodeInCycle: string | null = null
       for (const neighbour of neighbours) {
@@ -146,11 +150,10 @@ export class DirectedGraph {
       // By the invariant stated above, if the current node is part of a cycle,
       // then one of its neighbours must also be part of the same cycle. This
       // is because a cycle contains at least 2 nodes.
-      if (nextNodeInCycle === null) {
-        throw new Error(
-          `None of the neighbours of node '${currentNode}' are part of the same cycle. This should never happen.`
-        )
-      }
+      assert(
+        nextNodeInCycle !== null,
+        `None of the neighbours of node '${currentNode}' are part of the same cycle. This should never happen.`
+      )
 
       // If the next node we pick is already part of the cycle,
       // we drop all elements before the first instance of the
diff --git a/src/localImports/preprocessor.ts b/src/localImports/preprocessor.ts
index 40a1d7e9..ae4204a7 100644
--- a/src/localImports/preprocessor.ts
+++ b/src/localImports/preprocessor.ts
@@ -1,10 +1,12 @@
 import es from 'estree'
-import * as posixPath from 'path/posix'
+import { posix as posixPath } from 'path'
 
 import { CannotFindModuleError, CircularImportError } from '../errors/localImportErrors'
 import { parse } from '../parser/parser'
 import { AcornOptions } from '../parser/types'
 import { Context } from '../types'
+import assert from '../utils/assert'
+import { isImportDeclaration, isModuleDeclaration } from '../utils/ast/typeGuards'
 import { isIdentifier } from '../utils/rttc'
 import { createInvokedFunctionResultVariableDeclaration } from './constructors/contextSpecificConstructors'
 import { DirectedGraph } from './directedGraph'
@@ -23,7 +25,6 @@ import {
   getInvokedFunctionResultVariableNameToImportSpecifiersMap,
   transformProgramToFunctionDeclaration
 } from './transformers/transformProgramToFunctionDeclaration'
-import { isImportDeclaration, isModuleDeclaration } from './typeGuards'
 
 /**
  * Returns all absolute local module paths which should be imported.
@@ -218,11 +219,10 @@ const preprocessFileImports = (
 
     const functionDeclaration = transformProgramToFunctionDeclaration(program, filePath)
     const functionName = functionDeclaration.id?.name
-    if (functionName === undefined) {
-      throw new Error(
-        'A transformed function declaration is missing its name. This should never happen.'
-      )
-    }
+    assert(
+      functionName !== undefined,
+      'A transformed function declaration is missing its name. This should never happen.'
+    )
 
     functionDeclarations[functionName] = functionDeclaration
   }
@@ -242,11 +242,10 @@ const preprocessFileImports = (
 
     const functionDeclaration = functionDeclarations[functionName]
     const functionParams = functionDeclaration.params.filter(isIdentifier)
-    if (functionParams.length !== functionDeclaration.params.length) {
-      throw new Error(
-        'Function declaration contains non-Identifier AST nodes as params. This should never happen.'
-      )
-    }
+    assert(
+      functionParams.length === functionDeclaration.params.length,
+      'Function declaration contains non-Identifier AST nodes as params. This should never happen.'
+    )
 
     const invokedFunctionResultVariableDeclaration = createInvokedFunctionResultVariableDeclaration(
       functionName,
diff --git a/src/localImports/transformers/hoistAndMergeImports.ts b/src/localImports/transformers/hoistAndMergeImports.ts
index 96699587..85864e18 100644
--- a/src/localImports/transformers/hoistAndMergeImports.ts
+++ b/src/localImports/transformers/hoistAndMergeImports.ts
@@ -1,9 +1,9 @@
 import es from 'estree'
 import * as _ from 'lodash'
 
+import { isImportDeclaration } from '../../utils/ast/typeGuards'
 import { createImportDeclaration, createLiteral } from '../constructors/baseConstructors'
 import { cloneAndStripImportSpecifier } from '../constructors/contextSpecificConstructors'
-import { isImportDeclaration } from '../typeGuards'
 
 /**
  * Hoists import declarations to the top of the program & merges duplicate
diff --git a/src/localImports/transformers/removeExports.ts b/src/localImports/transformers/removeExports.ts
index 7e81e936..eb2962d8 100644
--- a/src/localImports/transformers/removeExports.ts
+++ b/src/localImports/transformers/removeExports.ts
@@ -1,7 +1,7 @@
 import es from 'estree'
 
+import { isDeclaration } from '../../utils/ast/typeGuards'
 import { ancestor } from '../../utils/walkers'
-import { isDeclaration } from '../typeGuards'
 
 /**
  * Removes all export-related nodes from the AST.
diff --git a/src/localImports/transformers/removeNonSourceModuleImports.ts b/src/localImports/transformers/removeNonSourceModuleImports.ts
index 0aa3f99e..61052de6 100644
--- a/src/localImports/transformers/removeNonSourceModuleImports.ts
+++ b/src/localImports/transformers/removeNonSourceModuleImports.ts
@@ -1,5 +1,6 @@
 import es from 'estree'
 
+import assert from '../../utils/assert'
 import { ancestor } from '../../utils/walkers'
 import { isFilePath } from '../filePaths'
 
@@ -94,9 +95,7 @@ export const removeNonSourceModuleImports = (program: es.Program): void => {
   // have any specifiers (thus being functionally useless).
   ancestor(program, {
     ImportDeclaration(node: es.ImportDeclaration, _state: es.Node[], ancestors: es.Node[]): void {
-      if (typeof node.source.value !== 'string') {
-        throw new Error('Module names must be strings.')
-      }
+      assert(typeof node.source.value === 'string', 'Module names must be strings.')
       // ImportDeclaration nodes without any specifiers are functionally useless and are thus removed.
       if (node.specifiers.length === 0) {
         removeImportDeclaration(node, ancestors)
diff --git a/src/localImports/transformers/transformProgramToFunctionDeclaration.ts b/src/localImports/transformers/transformProgramToFunctionDeclaration.ts
index 870d0aa9..4ff2f3d5 100644
--- a/src/localImports/transformers/transformProgramToFunctionDeclaration.ts
+++ b/src/localImports/transformers/transformProgramToFunctionDeclaration.ts
@@ -1,7 +1,14 @@
 import es from 'estree'
-import * as posixPath from 'path/posix'
+import { posix as posixPath } from 'path'
 
 import { defaultExportLookupName } from '../../stdlib/localImport.prelude'
+import assert from '../../utils/assert'
+import {
+  isDeclaration,
+  isDirective,
+  isModuleDeclaration,
+  isStatement
+} from '../../utils/ast/typeGuards'
 import {
   createFunctionDeclaration,
   createIdentifier,
@@ -17,7 +24,6 @@ import {
   transformFilePathToValidFunctionName,
   transformFunctionNameToInvokedFunctionResultVariableName
 } from '../filePaths'
-import { isDeclaration, isDirective, isModuleDeclaration, isStatement } from '../typeGuards'
 import { isSourceModule } from './removeNonSourceModuleImports'
 
 type ImportSpecifier = es.ImportSpecifier | es.ImportDefaultSpecifier | es.ImportNamespaceSpecifier
@@ -34,11 +40,11 @@ export const getInvokedFunctionResultVariableNameToImportSpecifiersMap = (
       return
     }
     const importSource = node.source.value
-    if (typeof importSource !== 'string') {
-      throw new Error(
-        'Encountered an ImportDeclaration node with a non-string source. This should never occur.'
-      )
-    }
+    assert(
+      typeof importSource === 'string',
+      'Encountered an ImportDeclaration node with a non-string source. This should never occur.'
+    )
+
     // Only handle import declarations for non-Source modules.
     if (isSourceModule(importSource)) {
       return
@@ -89,18 +95,18 @@ export const getInvokedFunctionResultVariableNameToImportSpecifiersMap = (
 const getIdentifier = (node: es.Declaration): es.Identifier | null => {
   switch (node.type) {
     case 'FunctionDeclaration':
-      if (node.id === null) {
-        throw new Error(
-          'Encountered a FunctionDeclaration node without an identifier. This should have been caught when parsing.'
-        )
-      }
+      assert(
+        node.id !== null,
+        'Encountered a FunctionDeclaration node without an identifier. This should have been caught when parsing.'
+      )
       return node.id
     case 'VariableDeclaration':
       const id = node.declarations[0].id
       // In Source, variable names are Identifiers.
-      if (id.type !== 'Identifier') {
-        throw new Error(`Expected variable name to be an Identifier, but was ${id.type} instead.`)
-      }
+      assert(
+        id.type === 'Identifier',
+        `Expected variable name to be an Identifier, but was ${id.type} instead.`
+      )
       return id
     case 'ClassDeclaration':
       throw new Error('Exporting of class is not supported.')
@@ -163,11 +169,10 @@ const getDefaultExportExpression = (
     if (node.type !== 'ExportDefaultDeclaration') {
       return
     }
-    if (defaultExport !== null) {
-      // This should never occur because multiple default exports should have
-      // been caught by the Acorn parser when parsing into an AST.
-      throw new Error('Encountered multiple default exports!')
-    }
+    // This should never occur because multiple default exports should have
+    // been caught by the Acorn parser when parsing into an AST.
+    assert(defaultExport === null, 'Encountered multiple default exports!')
+
     if (isDeclaration(node.declaration)) {
       const identifier = getIdentifier(node.declaration)
       if (identifier === null) {
diff --git a/src/modules/__mocks__/moduleLoader.ts b/src/modules/__mocks__/moduleLoader.ts
new file mode 100644
index 00000000..49498ac1
--- /dev/null
+++ b/src/modules/__mocks__/moduleLoader.ts
@@ -0,0 +1,29 @@
+import { Context } from '../../types'
+
+export function loadModuleBundle() {
+  return {
+    foo: () => 'foo',
+    bar: () => 'bar'
+  }
+}
+
+export function loadModuleTabs(_name: string) {
+  return []
+}
+export const memoizedGetModuleManifest = () => ({
+  one_module: { tabs: [] },
+  other_module: { tabs: [] },
+  another_module: { tabs: [] }
+})
+
+export async function initModuleContext(moduleName: string, context: Context, loadTabs: boolean) {
+  // Load the module's tabs
+  if (!(moduleName in context.moduleContexts)) {
+    context.moduleContexts[moduleName] = {
+      state: null,
+      tabs: loadTabs ? loadModuleTabs(moduleName) : null
+    }
+  } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) {
+    context.moduleContexts[moduleName].tabs = loadModuleTabs(moduleName)
+  }
+}
diff --git a/src/modules/__mocks__/moduleLoaderAsync.ts b/src/modules/__mocks__/moduleLoaderAsync.ts
new file mode 100644
index 00000000..c4fec664
--- /dev/null
+++ b/src/modules/__mocks__/moduleLoaderAsync.ts
@@ -0,0 +1,46 @@
+import { Context } from '../../types'
+
+export const memoizedGetModuleDocsAsync = jest.fn().mockResolvedValue({
+  foo: 'foo',
+  bar: 'bar'
+})
+
+export const memoizedGetModuleBundleAsync = jest.fn().mockResolvedValue(
+  `require => ({
+    foo: () => 'foo',
+    bar: () => 'bar',
+  })`
+)
+
+export const memoizedGetModuleManifestAsync = jest.fn().mockResolvedValue({
+  one_module: { tabs: [] },
+  other_module: { tabs: [] },
+  another_module: { tabs: [] }
+})
+
+export function loadModuleBundleAsync() {
+  return Promise.resolve({
+    foo: () => 'foo',
+    bar: () => 'bar'
+  })
+}
+
+export function loadModuleTabsAsync(_name: string) {
+  return Promise.resolve([])
+}
+
+export async function initModuleContextAsync(
+  moduleName: string,
+  context: Context,
+  loadTabs: boolean
+) {
+  // Load the module's tabs
+  if (!(moduleName in context.moduleContexts)) {
+    context.moduleContexts[moduleName] = {
+      state: null,
+      tabs: loadTabs ? await loadModuleTabsAsync(moduleName) : null
+    }
+  } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) {
+    context.moduleContexts[moduleName].tabs = await loadModuleTabsAsync(moduleName)
+  }
+}
diff --git a/src/modules/errors.ts b/src/modules/errors.ts
index fa3ebaef..4f69c193 100644
--- a/src/modules/errors.ts
+++ b/src/modules/errors.ts
@@ -1,4 +1,4 @@
-import type { ImportDeclaration } from 'estree'
+import type * as es from 'estree'
 
 import { RuntimeSourceError } from '../errors/runtimeSourceError'
 
@@ -6,7 +6,7 @@ export class UndefinedImportError extends RuntimeSourceError {
   constructor(
     public readonly symbol: string,
     public readonly moduleName: string,
-    node?: ImportDeclaration
+    node?: es.ImportSpecifier
   ) {
     super(node)
   }
@@ -19,3 +19,51 @@ export class UndefinedImportError extends RuntimeSourceError {
     return "Check your imports and make sure what you're trying to import exists!"
   }
 }
+
+export class ModuleConnectionError extends RuntimeSourceError {
+  private static message: string = `Unable to get modules.`
+  private static elaboration: string = `You should check your Internet connection, and ensure you have used the correct module path.`
+  constructor(node?: es.Node) {
+    super(node)
+  }
+
+  public explain() {
+    return ModuleConnectionError.message
+  }
+
+  public elaborate() {
+    return ModuleConnectionError.elaboration
+  }
+}
+
+export class ModuleNotFoundError extends RuntimeSourceError {
+  constructor(public moduleName: string, node?: es.Node) {
+    super(node)
+  }
+
+  public explain() {
+    return `Module "${this.moduleName}" not found.`
+  }
+
+  public elaborate() {
+    return `
+      You should check your import declarations, and ensure that all are valid modules.
+    `
+  }
+}
+
+export class ModuleInternalError extends RuntimeSourceError {
+  constructor(public moduleName: string, public error?: any, node?: es.Node) {
+    super(node)
+  }
+
+  public explain() {
+    return `Error(s) occured when executing the module "${this.moduleName}".`
+  }
+
+  public elaborate() {
+    return `
+      You may need to contact with the author for this module to fix this error.
+    `
+  }
+}
diff --git a/src/modules/moduleLoader.ts b/src/modules/moduleLoader.ts
index 2801ce81..c4445a32 100644
--- a/src/modules/moduleLoader.ts
+++ b/src/modules/moduleLoader.ts
@@ -9,8 +9,9 @@ import {
 } from '../errors/moduleErrors'
 import { Context } from '../types'
 import { wrapSourceModule } from '../utils/operators'
-import { ModuleBundle, ModuleDocumentation, ModuleFunctions, Modules } from './moduleTypes'
+import { ModuleBundle, ModuleDocumentation, ModuleFunctions, ModuleManifest } from './moduleTypes'
 import { getRequireProvider } from './requireProvider'
+import { evalRawTab } from './utils'
 
 // Supports both JSDom (Web Browser) environment and Node environment
 export const newHttpRequest = () =>
@@ -46,7 +47,7 @@ export function httpGet(url: string): string {
  * @return Modules
  */
 export const memoizedGetModuleManifest = memoize(getModuleManifest)
-function getModuleManifest(): Modules {
+function getModuleManifest(): ModuleManifest {
   const rawManifest = httpGet(`${MODULES_STATIC_URL}/modules.json`)
   return JSON.parse(rawManifest)
 }
@@ -107,7 +108,7 @@ export function loadModuleTabs(path: string, node?: es.Node) {
   return sideContentTabPaths.map(path => {
     const rawTabFile = memoizedGetModuleFile(path, 'tab')
     try {
-      return eval(rawTabFile)
+      return evalRawTab(rawTabFile)
     } catch (error) {
       // console.error('tab error:', error);
       throw new ModuleInternalError(path, error, node)
@@ -129,3 +130,19 @@ export function loadModuleDocs(path: string, node?: es.Node) {
     return null
   }
 }
+
+export function initModuleContext(
+  moduleName: string,
+  context: Context,
+  loadTabs: boolean,
+  node?: es.Node
+) {
+  if (!(moduleName in context.moduleContexts)) {
+    context.moduleContexts[moduleName] = {
+      state: null,
+      tabs: loadTabs ? loadModuleTabs(moduleName, node) : null
+    }
+  } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) {
+    context.moduleContexts[moduleName].tabs = loadModuleTabs(moduleName, node)
+  }
+}
diff --git a/src/modules/moduleLoaderAsync.ts b/src/modules/moduleLoaderAsync.ts
new file mode 100644
index 00000000..e0a6be11
--- /dev/null
+++ b/src/modules/moduleLoaderAsync.ts
@@ -0,0 +1,130 @@
+import type { Node } from 'estree'
+import { memoize } from 'lodash'
+
+import type { Context } from '..'
+import { PromiseTimeoutError, timeoutPromise } from '../utils/misc'
+import { wrapSourceModule } from '../utils/operators'
+import { ModuleConnectionError, ModuleInternalError, ModuleNotFoundError } from './errors'
+import { MODULES_STATIC_URL } from './moduleLoader'
+import type { ModuleBundle, ModuleDocumentation, ModuleManifest } from './moduleTypes'
+import { getRequireProvider } from './requireProvider'
+import { evalRawTab } from './utils'
+
+export function httpGetAsync(path: string, type: 'json'): Promise<object>
+export function httpGetAsync(path: string, type: 'text'): Promise<string>
+export async function httpGetAsync(path: string, type: 'json' | 'text') {
+  try {
+    const resp = await timeoutPromise(
+      fetch(path, {
+        method: 'GET'
+      }),
+      10000
+    )
+
+    if (resp.status !== 200 && resp.status !== 304) {
+      throw new ModuleConnectionError()
+    }
+
+    const promise = type === 'text' ? resp.text() : resp.json()
+    return timeoutPromise(promise, 10000)
+  } catch (error) {
+    if (error instanceof TypeError || error instanceof PromiseTimeoutError) {
+      throw new ModuleConnectionError()
+    }
+    if (!(error instanceof ModuleConnectionError)) throw new ModuleInternalError(path, error)
+    throw error
+  }
+}
+
+/**
+ * Send a HTTP GET request to the modules endpoint to retrieve the manifest
+ * @return Modules
+ */
+export const memoizedGetModuleManifestAsync = memoize(getModuleManifestAsync)
+function getModuleManifestAsync(): Promise<ModuleManifest> {
+  return httpGetAsync(`${MODULES_STATIC_URL}/modules.json`, 'json') as Promise<ModuleManifest>
+}
+
+async function checkModuleExists(moduleName: string, node?: Node) {
+  const modules = await memoizedGetModuleManifestAsync()
+  // Check if the module exists
+  if (!(moduleName in modules)) throw new ModuleNotFoundError(moduleName, node)
+
+  return modules[moduleName]
+}
+
+export const memoizedGetModuleBundleAsync = memoize(getModuleBundleAsync)
+async function getModuleBundleAsync(moduleName: string): Promise<string> {
+  return httpGetAsync(`${MODULES_STATIC_URL}/bundles/${moduleName}.js`, 'text')
+}
+
+export const memoizedGetModuleTabAsync = memoize(getModuleTabAsync)
+function getModuleTabAsync(tabName: string): Promise<string> {
+  return httpGetAsync(`${MODULES_STATIC_URL}/tabs/${tabName}.js`, 'text')
+}
+
+export const memoizedGetModuleDocsAsync = memoize(getModuleDocsAsync)
+async function getModuleDocsAsync(moduleName: string): Promise<ModuleDocumentation | null> {
+  try {
+    const result = await httpGetAsync(`${MODULES_STATIC_URL}/jsons/${moduleName}.json`, 'json')
+    return result as ModuleDocumentation
+  } catch (error) {
+    console.warn(`Failed to load documentation for ${moduleName}:`, error)
+    return null
+  }
+}
+
+export async function loadModuleTabsAsync(moduleName: string, node?: Node) {
+  const moduleInfo = await checkModuleExists(moduleName, node)
+
+  // Load the tabs for the current module
+  return Promise.all(
+    moduleInfo.tabs.map(async path => {
+      const rawTabFile = await memoizedGetModuleTabAsync(path)
+      try {
+        return evalRawTab(rawTabFile)
+      } catch (error) {
+        // console.error('tab error:', error);
+        throw new ModuleInternalError(path, error, node)
+      }
+    })
+  )
+}
+
+export async function loadModuleBundleAsync(
+  moduleName: string,
+  context: Context,
+  wrapModule: boolean,
+  node?: Node
+) {
+  // await checkModuleExists(moduleName, node)
+  const moduleText = await memoizedGetModuleBundleAsync(moduleName)
+  try {
+    const moduleBundle: ModuleBundle = eval(moduleText)
+
+    if (wrapModule) return wrapSourceModule(moduleName, moduleBundle, getRequireProvider(context))
+    return moduleBundle(getRequireProvider(context))
+  } catch (error) {
+    // console.error("bundle error: ", error)
+    throw new ModuleInternalError(moduleName, error, node)
+  }
+}
+
+/**
+ * Initialize module contexts and add UI tabs needed for modules to program context
+ */
+export async function initModuleContextAsync(
+  moduleName: string,
+  context: Context,
+  loadTabs: boolean
+) {
+  // Load the module's tabs
+  if (!(moduleName in context.moduleContexts)) {
+    context.moduleContexts[moduleName] = {
+      state: null,
+      tabs: loadTabs ? await loadModuleTabsAsync(moduleName) : null
+    }
+  } else if (context.moduleContexts[moduleName].tabs === null && loadTabs) {
+    context.moduleContexts[moduleName].tabs = await loadModuleTabsAsync(moduleName)
+  }
+}
diff --git a/src/modules/moduleTypes.ts b/src/modules/moduleTypes.ts
index 974f27de..8182f0a1 100644
--- a/src/modules/moduleTypes.ts
+++ b/src/modules/moduleTypes.ts
@@ -1,6 +1,6 @@
 import type { RequireProvider } from './requireProvider'
 
-export type Modules = {
+export type ModuleManifest = {
   [module: string]: {
     tabs: string[]
   }
@@ -13,3 +13,9 @@ export type ModuleFunctions = {
 }
 
 export type ModuleDocumentation = Record<string, string>
+
+export type ImportTransformOptions = {
+  wrapSourceModules: boolean
+  loadTabs: boolean
+  checkImports: boolean
+}
diff --git a/src/modules/utils.ts b/src/modules/utils.ts
new file mode 100644
index 00000000..2d53696e
--- /dev/null
+++ b/src/modules/utils.ts
@@ -0,0 +1,12 @@
+/**
+ * For tabs that are export default declarations, we need to remove
+ * the `export default` bit from the front before they can be loaded
+ * by js-slang
+ */
+export function evalRawTab(text: string) {
+  if (text.startsWith('export default')) {
+    text = text.substring(14)
+  }
+
+  return eval(text)
+}
diff --git a/src/repl/transpiler.ts b/src/repl/transpiler.ts
index db7d3906..4b198628 100644
--- a/src/repl/transpiler.ts
+++ b/src/repl/transpiler.ts
@@ -11,7 +11,7 @@ import { transpile } from '../transpiler/transpiler'
 import { Chapter, Variant } from '../types'
 import { validateAndAnnotate } from '../validator/validator'
 
-function transpileCode(
+async function transpileCode(
   chapter: Chapter = Chapter.SOURCE_1,
   variant: Variant = Variant.DEFAULT,
   code = '',
@@ -35,7 +35,8 @@ function transpileCode(
   if (pretranspile) {
     return generate(program)
   } else {
-    return transpile(program as Program, context).transpiled
+    const { transpiled } = await transpile(program as Program, context)
+    return transpiled
   }
 }
 
@@ -90,8 +91,9 @@ function main() {
   })
   process.stdin.on('end', () => {
     const code = Buffer.concat(chunks).toString('utf-8')
-    const transpiled = transpileCode(chapter, variant, code, pretranspile)
-    process.stdout.write(transpiled)
+    transpileCode(chapter, variant, code, pretranspile).then(transpiled =>
+      process.stdout.write(transpiled)
+    )
   })
 }
 
diff --git a/src/runner/fullJSRunner.ts b/src/runner/fullJSRunner.ts
index 97f8c3b7..1327b8c4 100644
--- a/src/runner/fullJSRunner.ts
+++ b/src/runner/fullJSRunner.ts
@@ -1,12 +1,13 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 import { generate } from 'astring'
-import * as es from 'estree'
+import type * as es from 'estree'
 import { RawSourceMap } from 'source-map'
 
-import { IOptions, Result } from '..'
+import type { Result } from '..'
 import { NATIVE_STORAGE_ID } from '../constants'
 import { RuntimeSourceError } from '../errors/runtimeSourceError'
 import { hoistAndMergeImports } from '../localImports/transformers/hoistAndMergeImports'
+import { ImportTransformOptions } from '../modules/moduleTypes'
 import { getRequireProvider, RequireProvider } from '../modules/requireProvider'
 import { parse } from '../parser/parser'
 import {
@@ -19,7 +20,7 @@ import type { Context, NativeStorage } from '../types'
 import * as create from '../utils/astCreator'
 import { getFunctionDeclarationNamesInProgram } from '../utils/uniqueIds'
 import { toSourceError } from './errors'
-import { appendModulesToContext, resolvedErrorPromise } from './utils'
+import { resolvedErrorPromise } from './utils'
 
 function fullJSEval(
   code: string,
@@ -54,7 +55,7 @@ function containsPrevEval(context: Context): boolean {
 export async function fullJSRunner(
   program: es.Program,
   context: Context,
-  options: Partial<IOptions> = {}
+  importOptions: ImportTransformOptions
 ): Promise<Result> {
   // prelude & builtins
   // only process builtins and preludes if it is a fresh eval context
@@ -68,7 +69,6 @@ export async function fullJSRunner(
 
   // modules
   hoistAndMergeImports(program)
-  appendModulesToContext(program, context)
 
   // evaluate and create a separate block for preludes and builtins
   const preEvalProgram: es.Program = create.program([
@@ -88,12 +88,12 @@ export async function fullJSRunner(
   let transpiled
   let sourceMapJson: RawSourceMap | undefined
   try {
-    ;({ transpiled, sourceMapJson } = transpile(program, context))
-    return Promise.resolve({
+    ;({ transpiled, sourceMapJson } = await transpile(program, context, importOptions))
+    return {
       status: 'finished',
       context,
       value: await fullJSEval(transpiled, requireProvider, context.nativeStorage)
-    })
+    }
   } catch (error) {
     context.errors.push(
       error instanceof RuntimeSourceError ? error : await toSourceError(error, sourceMapJson)
diff --git a/src/runner/htmlRunner.ts b/src/runner/htmlRunner.ts
index f52c688f..ba17dd06 100644
--- a/src/runner/htmlRunner.ts
+++ b/src/runner/htmlRunner.ts
@@ -1,5 +1,5 @@
 import { IOptions, Result } from '..'
-import { Context } from '../types'
+import { Context, RecursivePartial } from '../types'
 
 const HTML_ERROR_HANDLING_SCRIPT_TEMPLATE = `<script>
   window.onerror = (msg, url, lineNum) => {
@@ -16,7 +16,7 @@ export const htmlErrorHandlingScript = HTML_ERROR_HANDLING_SCRIPT_TEMPLATE.repla
 export async function htmlRunner(
   code: string,
   context: Context,
-  options: Partial<IOptions> = {}
+  options: RecursivePartial<IOptions> = {}
 ): Promise<Result> {
   return Promise.resolve({
     status: 'finished',
diff --git a/src/runner/sourceRunner.ts b/src/runner/sourceRunner.ts
index ff2abdbb..4a531047 100644
--- a/src/runner/sourceRunner.ts
+++ b/src/runner/sourceRunner.ts
@@ -1,8 +1,8 @@
-import * as es from 'estree'
+import type * as es from 'estree'
 import * as _ from 'lodash'
-import { RawSourceMap } from 'source-map'
+import type { RawSourceMap } from 'source-map'
 
-import { IOptions, Result } from '..'
+import type { IOptions, Result } from '..'
 import { JSSLANG_PROPERTIES, UNKNOWN_LOCATION } from '../constants'
 import { ECEResultPromise, evaluate as ECEvaluate } from '../ec-evaluator/interpreter'
 import { ExceptionError } from '../errors/errors'
@@ -28,7 +28,7 @@ import {
 } from '../stepper/stepper'
 import { sandboxedEval } from '../transpiler/evalContainer'
 import { transpile } from '../transpiler/transpiler'
-import { Context, Scheduler, SourceError, Variant } from '../types'
+import { Context, RecursivePartial, Scheduler, Variant } from '../types'
 import { forceIt } from '../utils/operators'
 import { validateAndAnnotate } from '../validator/validator'
 import { compileForConcurrent } from '../vm/svml-compiler'
@@ -36,9 +36,9 @@ import { runWithProgram } from '../vm/svml-machine'
 import { determineExecutionMethod, hasVerboseErrors } from '.'
 import { toSourceError } from './errors'
 import { fullJSRunner } from './fullJSRunner'
-import { appendModulesToContext, determineVariant, resolvedErrorPromise } from './utils'
+import { determineVariant, resolvedErrorPromise } from './utils'
 
-const DEFAULT_SOURCE_OPTIONS: IOptions = {
+const DEFAULT_SOURCE_OPTIONS: Readonly<IOptions> = {
   scheduler: 'async',
   steps: 1000,
   stepLimit: -1,
@@ -48,7 +48,12 @@ const DEFAULT_SOURCE_OPTIONS: IOptions = {
   useSubst: false,
   isPrelude: false,
   throwInfiniteLoops: true,
-  envSteps: -1
+  envSteps: -1,
+  importOptions: {
+    wrapSourceModules: true,
+    checkImports: true,
+    loadTabs: true
+  }
 }
 
 let previousCode: {
@@ -80,12 +85,12 @@ function runConcurrent(program: es.Program, context: Context, options: IOptions)
   }
 }
 
-function runSubstitution(
+async function runSubstitution(
   program: es.Program,
   context: Context,
   options: IOptions
 ): Promise<Result> {
-  const steps = getEvaluationSteps(program, context, options.stepLimit)
+  const steps = await getEvaluationSteps(program, context, options)
   if (context.errors.length > 0) {
     return resolvedErrorPromise
   }
@@ -100,11 +105,11 @@ function runSubstitution(
       function: callee(redex, context)
     })
   }
-  return Promise.resolve({
+  return {
     status: 'finished',
     context,
     value: redexedSteps
-  })
+  }
 }
 
 function runInterpreter(program: es.Program, context: Context, options: IOptions): Promise<Result> {
@@ -142,8 +147,6 @@ async function runNative(
   let transpiled
   let sourceMapJson: RawSourceMap | undefined
   try {
-    appendModulesToContext(transpiledProgram, context)
-
     switch (context.variant) {
       case Variant.GPU:
         transpileToGPU(transpiledProgram)
@@ -153,7 +156,11 @@ async function runNative(
         break
     }
 
-    ;({ transpiled, sourceMapJson } = transpile(transpiledProgram, context))
+    ;({ transpiled, sourceMapJson } = await transpile(
+      transpiledProgram,
+      context,
+      options.importOptions
+    ))
     let value = await sandboxedEval(transpiled, getRequireProvider(context), context.nativeStorage)
 
     if (context.variant === Variant.LAZY) {
@@ -164,15 +171,18 @@ async function runNative(
       isPreviousCodeTimeoutError = false
     }
 
-    return Promise.resolve({
+    return {
       status: 'finished',
       context,
       value
-    })
+    }
   } catch (error) {
     const isDefaultVariant = options.variant === undefined || options.variant === Variant.DEFAULT
     if (isDefaultVariant && isPotentialInfiniteLoop(error)) {
-      const detectedInfiniteLoop = testForInfiniteLoop(program, context.previousPrograms.slice(1))
+      const detectedInfiniteLoop = await testForInfiniteLoop(
+        program,
+        context.previousPrograms.slice(1)
+      )
       if (detectedInfiniteLoop !== undefined) {
         if (options.throwInfiniteLoops) {
           context.errors.push(detectedInfiniteLoop)
@@ -202,7 +212,7 @@ async function runNative(
       }
     }
 
-    const sourceError: SourceError = await toSourceError(error, sourceMapJson)
+    const sourceError = await toSourceError(error, sourceMapJson)
     context.errors.push(sourceError)
     return resolvedErrorPromise
   }
@@ -217,9 +227,11 @@ export async function sourceRunner(
   program: es.Program,
   context: Context,
   isVerboseErrorsEnabled: boolean,
-  options: Partial<IOptions> = {}
+  options: RecursivePartial<IOptions> = {}
 ): Promise<Result> {
-  const theOptions: IOptions = { ...DEFAULT_SOURCE_OPTIONS, ...options }
+  // It is necessary to make a copy of the DEFAULT_SOURCE_OPTIONS object because merge()
+  // will modify it rather than create a new object
+  const theOptions = _.merge({ ...DEFAULT_SOURCE_OPTIONS }, options)
   context.variant = determineVariant(context, options)
 
   validateAndAnnotate(program, context)
@@ -238,7 +250,7 @@ export async function sourceRunner(
   determineExecutionMethod(theOptions, context, program, isVerboseErrorsEnabled)
 
   if (context.executionMethod === 'native' && context.variant === Variant.NATIVE) {
-    return await fullJSRunner(program, context, theOptions)
+    return await fullJSRunner(program, context, theOptions.importOptions)
   }
 
   // All runners after this point evaluate the prelude.
@@ -279,7 +291,7 @@ export async function sourceFilesRunner(
   files: Partial<Record<string, string>>,
   entrypointFilePath: string,
   context: Context,
-  options: Partial<IOptions> = {}
+  options: RecursivePartial<IOptions> = {}
 ): Promise<Result> {
   const entrypointCode = files[entrypointFilePath]
   if (entrypointCode === undefined) {
diff --git a/src/runner/utils.ts b/src/runner/utils.ts
index 43b86440..9a7909e9 100644
--- a/src/runner/utils.ts
+++ b/src/runner/utils.ts
@@ -2,10 +2,9 @@
 import { DebuggerStatement, Literal, Program } from 'estree'
 
 import { IOptions, Result } from '..'
-import { loadModuleTabs } from '../modules/moduleLoader'
 import { parseAt } from '../parser/utils'
 import { areBreakpointsSet } from '../stdlib/inspector'
-import { Context, Variant } from '../types'
+import { Context, RecursivePartial, Variant } from '../types'
 import { simple } from '../utils/walkers'
 
 // Context Utils
@@ -22,7 +21,7 @@ import { simple } from '../utils/walkers'
  *
  * @returns The variant that the program is to be run in
  */
-export function determineVariant(context: Context, options: Partial<IOptions>): Variant {
+export function determineVariant(context: Context, options: RecursivePartial<IOptions>): Variant {
   if (options.variant) {
     return options.variant
   } else {
@@ -82,29 +81,6 @@ export function determineExecutionMethod(
   context.executionMethod = isNativeRunnable ? 'native' : 'ec-evaluator'
 }
 
-/**
- * Add UI tabs needed for modules to program context
- *
- * @param program AST of program to be ran
- * @param context The context of the program
- */
-export function appendModulesToContext(program: Program, context: Context): void {
-  for (const node of program.body) {
-    if (node.type !== 'ImportDeclaration') break
-    const moduleName = (node.source.value as string).trim()
-
-    // Load the module's tabs
-    if (!(moduleName in context.moduleContexts)) {
-      context.moduleContexts[moduleName] = {
-        state: null,
-        tabs: loadModuleTabs(moduleName)
-      }
-    } else if (context.moduleContexts[moduleName].tabs === null) {
-      context.moduleContexts[moduleName].tabs = loadModuleTabs(moduleName)
-    }
-  }
-}
-
 // AST Utils
 
 export function hasVerboseErrors(theCode: string): boolean {
diff --git a/src/stepper/stepper.ts b/src/stepper/stepper.ts
index c14d706e..aad1229b 100644
--- a/src/stepper/stepper.ts
+++ b/src/stepper/stepper.ts
@@ -1,10 +1,11 @@
 import { generate } from 'astring'
-import * as es from 'estree'
+import type * as es from 'estree'
 
+import { type IOptions } from '..'
 import * as errors from '../errors/errors'
 import { UndefinedImportError } from '../modules/errors'
-import { loadModuleBundle, loadModuleTabs } from '../modules/moduleLoader'
-import { ModuleFunctions } from '../modules/moduleTypes'
+import { initModuleContextAsync, loadModuleBundleAsync } from '../modules/moduleLoaderAsync'
+import type { ImportTransformOptions } from '../modules/moduleTypes'
 import { parse } from '../parser/parser'
 import {
   BlockExpression,
@@ -14,6 +15,8 @@ import {
   FunctionDeclarationExpression,
   substituterNodes
 } from '../types'
+import assert from '../utils/assert'
+import { filterImportDeclarations } from '../utils/ast/helpers'
 import * as ast from '../utils/astCreator'
 import {
   dummyBlockExpression,
@@ -3162,51 +3165,45 @@ function removeDebuggerStatements(program: es.Program): es.Program {
   return program
 }
 
-function evaluateImports(
+async function evaluateImports(
   program: es.Program,
   context: Context,
-  loadTabs: boolean,
-  checkImports: boolean
+  { loadTabs, checkImports, wrapSourceModules }: ImportTransformOptions
 ) {
-  const importNodes = program.body.filter(
-    ({ type }) => type === 'ImportDeclaration'
-  ) as es.ImportDeclaration[]
-  program.body = program.body.filter(({ type }) => !(type === 'ImportDeclaration'))
-  const moduleFunctions: Record<string, ModuleFunctions> = {}
+  const [importNodeMap, otherNodes] = filterImportDeclarations(program)
 
   try {
-    for (const node of importNodes) {
-      const moduleName = node.source.value
-      if (typeof moduleName !== 'string') {
-        throw new Error(`ImportDeclarations should have string sources, got ${moduleName}`)
-      }
-
-      if (!(moduleName in moduleFunctions)) {
-        context.moduleContexts[moduleName] = {
-          state: null,
-          tabs: loadTabs ? loadModuleTabs(moduleName, node) : null
-        }
-        moduleFunctions[moduleName] = loadModuleBundle(moduleName, context, node)
-      }
-
-      const functions = moduleFunctions[moduleName]
-      const environment = currentEnvironment(context)
-      for (const spec of node.specifiers) {
-        if (spec.type !== 'ImportSpecifier') {
-          throw new Error(`Only ImportSpecifiers are supported, got: ${spec.type}`)
-        }
+    const environment = currentEnvironment(context)
+    await Promise.all(
+      Object.entries(importNodeMap).map(async ([moduleName, nodes]) => {
+        await initModuleContextAsync(moduleName, context, loadTabs)
+        const functions = await loadModuleBundleAsync(
+          moduleName,
+          context,
+          wrapSourceModules,
+          nodes[0]
+        )
+        for (const node of nodes) {
+          for (const spec of node.specifiers) {
+            assert(
+              spec.type === 'ImportSpecifier',
+              `Only ImportSpecifiers are supported, got: ${spec.type}`
+            )
 
-        if (checkImports && !(spec.imported.name in functions)) {
-          throw new UndefinedImportError(spec.imported.name, moduleName, node)
+            if (checkImports && !(spec.imported.name in functions)) {
+              throw new UndefinedImportError(spec.imported.name, moduleName, spec)
+            }
+            declareIdentifier(context, spec.local.name, node, environment)
+            defineVariable(context, spec.local.name, functions[spec.imported.name], true, node)
+          }
         }
-        declareIdentifier(context, spec.local.name, node, environment)
-        defineVariable(context, spec.local.name, functions[spec.imported.name], true, node)
-      }
-    }
+      })
+    )
   } catch (error) {
     // console.log(error)
     handleRuntimeError(context, error)
   }
+  program.body = otherNodes
 }
 
 const globalIdNames = [
@@ -3335,16 +3332,16 @@ function checkForUndefinedVariables(program: es.Program, context: Context) {
 }
 
 // the context here is for builtins
-export function getEvaluationSteps(
+export async function getEvaluationSteps(
   program: es.Program,
   context: Context,
-  stepLimit: number | undefined
-): [es.Program, string[][], string][] {
+  { importOptions, stepLimit }: Pick<IOptions, 'importOptions' | 'stepLimit'>
+): Promise<[es.Program, string[][], string][]> {
   const steps: [es.Program, string[][], string][] = []
   try {
     checkForUndefinedVariables(program, context)
     const limit = stepLimit === undefined ? 1000 : stepLimit % 2 === 0 ? stepLimit : stepLimit + 1
-    evaluateImports(program, context, true, true)
+    await evaluateImports(program, context, importOptions)
     // starts with substituting predefined constants
     let start = substPredefinedConstants(program)
     // and predefined fns
diff --git a/src/transpiler/transpiler.ts b/src/transpiler/transpiler.ts
index 9cb8b079..e14b9acc 100644
--- a/src/transpiler/transpiler.ts
+++ b/src/transpiler/transpiler.ts
@@ -6,10 +6,25 @@ import { RawSourceMap, SourceMapGenerator } from 'source-map'
 
 import { NATIVE_STORAGE_ID, REQUIRE_PROVIDER_ID, UNKNOWN_LOCATION } from '../constants'
 import { UndefinedVariable } from '../errors/errors'
-import { UndefinedImportError } from '../modules/errors'
-import { memoizedGetModuleFile, memoizedloadModuleDocs } from '../modules/moduleLoader'
-import { ModuleDocumentation } from '../modules/moduleTypes'
-import { AllowedDeclarations, Chapter, Context, NativeStorage, Variant } from '../types'
+import { ModuleNotFoundError } from '../errors/moduleErrors'
+import { ModuleInternalError, UndefinedImportError } from '../modules/errors'
+import {
+  initModuleContextAsync,
+  memoizedGetModuleBundleAsync,
+  memoizedGetModuleDocsAsync,
+  memoizedGetModuleManifestAsync
+} from '../modules/moduleLoaderAsync'
+import type { ImportTransformOptions } from '../modules/moduleTypes'
+import {
+  AllowedDeclarations,
+  Chapter,
+  Context,
+  NativeStorage,
+  RecursivePartial,
+  Variant
+} from '../types'
+import assert from '../utils/assert'
+import { isImportDeclaration } from '../utils/ast/typeGuards'
 import * as create from '../utils/astCreator'
 import {
   getFunctionDeclarationNamesInProgram,
@@ -41,88 +56,103 @@ const globalIdNames = [
 
 export type NativeIds = Record<typeof globalIdNames[number], es.Identifier>
 
-export function transformImportDeclarations(
+export async function transformImportDeclarations(
   program: es.Program,
   usedIdentifiers: Set<string>,
-  checkImports: boolean,
+  { wrapSourceModules, checkImports, loadTabs }: ImportTransformOptions,
+  context?: Context,
   nativeId?: es.Identifier,
   useThis: boolean = false
-): [string, es.VariableDeclaration[], es.Program['body']] {
-  const [importNodes, otherNodes] = partition(
-    program.body,
-    node => node.type === 'ImportDeclaration'
-  )
+): Promise<[string, es.VariableDeclaration[], es.Program['body']]> {
+  const [importNodes, otherNodes] = partition(program.body, isImportDeclaration)
 
   if (importNodes.length === 0) return ['', [], otherNodes]
+  const importNodeMap = importNodes.reduce((res, node) => {
+    const moduleName = node.source.value
+    assert(
+      typeof moduleName === 'string',
+      `Expected ImportDeclaration to have a source of type string, got ${moduleName}`
+    )
 
-  const moduleInfos = importNodes.reduce(
-    (res, node: es.ImportDeclaration) => {
-      const moduleName = node.source.value
-      if (typeof moduleName !== 'string') {
-        throw new Error(
-          `Expected ImportDeclaration to have a source of type string, got ${moduleName}`
-        )
-      }
+    if (!(moduleName in res)) {
+      res[moduleName] = []
+    }
 
-      if (!(moduleName in res)) {
-        res[moduleName] = {
-          text: memoizedGetModuleFile(moduleName, 'bundle'),
-          nodes: [],
-          docs: checkImports ? memoizedloadModuleDocs(moduleName, node) : null
-        }
-      }
+    res[moduleName].push(node)
 
-      res[moduleName].nodes.push(node)
-      node.specifiers.forEach(spec => usedIdentifiers.add(spec.local.name))
-      return res
-    },
-    {} as Record<
-      string,
-      {
-        nodes: es.ImportDeclaration[]
-        text: string
-        docs: ModuleDocumentation | null
+    node.specifiers.forEach(({ local: { name } }) => usedIdentifiers.add(name))
+    return res
+  }, {} as Record<string, es.ImportDeclaration[]>)
+
+  const manifest = await memoizedGetModuleManifestAsync()
+
+  const loadedModules = await Promise.all(
+    Object.entries(importNodeMap).map(async ([moduleName, nodes]) => {
+      if (!(moduleName in manifest)) {
+        throw new ModuleNotFoundError(moduleName, nodes[0])
       }
-    >
-  )
 
-  const prefix: string[] = []
-  const declNodes = Object.entries(moduleInfos).flatMap(([moduleName, { nodes, text, docs }]) => {
-    const namespaced = getUniqueId(usedIdentifiers, '__MODULE__')
-    prefix.push(`// ${moduleName} module`)
+      const [text, docs] = await Promise.all([
+        memoizedGetModuleBundleAsync(moduleName),
+        memoizedGetModuleDocsAsync(moduleName),
+        context ? initModuleContextAsync(moduleName, context, loadTabs) : Promise.resolve()
+      ])
 
-    const modifiedText = nativeId
-      ? `${NATIVE_STORAGE_ID}.operators.get("wrapSourceModule")("${moduleName}", ${text}, ${REQUIRE_PROVIDER_ID})`
-      : `(${text})(${REQUIRE_PROVIDER_ID})`
-    prefix.push(`const ${namespaced} = ${modifiedText}\n`)
+      const namespaced = getUniqueId(usedIdentifiers, '__MODULE__')
 
-    return nodes.flatMap(node =>
-      node.specifiers.map(specifier => {
-        if (specifier.type !== 'ImportSpecifier') {
-          throw new Error(`Expected import specifier, found: ${specifier.type}`)
-        }
+      if (checkImports && !docs) {
+        throw new ModuleInternalError(
+          moduleName,
+          new Error('checkImports was true, but failed to load docs'),
+          nodes[0]
+        )
+      }
+
+      const declNodes = nodes.flatMap(({ specifiers }) =>
+        specifiers.map(spec => {
+          assert(spec.type === 'ImportSpecifier', `Expected ImportSpecifier, got ${spec.type}`)
 
-        if (checkImports) {
-          if (!docs) {
-            console.warn(`Failed to load docs for ${moduleName}, skipping typechecking`)
-          } else if (!(specifier.imported.name in docs)) {
-            throw new UndefinedImportError(specifier.imported.name, moduleName, node)
+          if (checkImports && !(spec.imported.name in docs!)) {
+            throw new UndefinedImportError(spec.imported.name, moduleName, spec)
           }
-        }
 
-        // Convert each import specifier to its corresponding local variable declaration
-        return create.constantDeclaration(
-          specifier.local.name,
-          create.memberExpression(
-            create.identifier(`${useThis ? 'this.' : ''}${namespaced}`),
-            specifier.imported.name
+          // Convert each import specifier to its corresponding local variable declaration
+          return create.constantDeclaration(
+            spec.local.name,
+            create.memberExpression(
+              create.identifier(`${useThis ? 'this.' : ''}${namespaced}`),
+              spec.imported.name
+            )
           )
-        )
-      })
-    )
-  })
+        })
+      )
+
+      return [moduleName, { text, nodes: declNodes, namespaced }] as [
+        string,
+        {
+          text: string
+          nodes: es.VariableDeclaration[]
+          namespaced: string
+        }
+      ]
+    })
+  )
 
-  return [prefix.join('\n'), declNodes, otherNodes]
+  const [prefixes, declNodes] = loadedModules.reduce(
+    ([prefix, decls], [moduleName, { text, nodes, namespaced }]) => {
+      const modifiedText = wrapSourceModules
+        ? `${NATIVE_STORAGE_ID}.operators.get("wrapSourceModule")("${moduleName}", ${text}, ${REQUIRE_PROVIDER_ID})`
+        : `(${text})(${REQUIRE_PROVIDER_ID})`
+
+      return [
+        [...prefix, `const ${namespaced} = ${modifiedText}\n`],
+        [...decls, ...nodes]
+      ]
+    },
+    [[], []] as [string[], es.VariableDeclaration[]]
+  )
+
+  return [prefixes.join('\n'), declNodes, otherNodes]
 }
 
 export function getGloballyDeclaredIdentifiers(program: es.Program): string[] {
@@ -602,11 +632,12 @@ function getDeclarationsToAccessTranspilerInternals(
 
 export type TranspiledResult = { transpiled: string; sourceMapJson?: RawSourceMap }
 
-function transpileToSource(
+async function transpileToSource(
   program: es.Program,
   context: Context,
-  skipUndefined: boolean
-): TranspiledResult {
+  skipUndefined: boolean,
+  importOptions: ImportTransformOptions
+): Promise<TranspiledResult> {
   const usedIdentifiers = new Set<string>([
     ...getIdentifiersInProgram(program),
     ...getIdentifiersInNativeStorage(context.nativeStorage)
@@ -629,10 +660,11 @@ function transpileToSource(
   wrapArrowFunctionsToAllowNormalCallsAndNiceToString(program, functionsToStringMap, globalIds)
   addInfiniteLoopProtection(program, globalIds, usedIdentifiers)
 
-  const [modulePrefix, importNodes, otherNodes] = transformImportDeclarations(
+  const [modulePrefix, importNodes, otherNodes] = await transformImportDeclarations(
     program,
     usedIdentifiers,
-    true,
+    importOptions,
+    context,
     globalIds.native
   )
   program.body = (importNodes as es.Program['body']).concat(otherNodes)
@@ -659,11 +691,12 @@ function transpileToSource(
   return { transpiled, sourceMapJson }
 }
 
-function transpileToFullJS(
+async function transpileToFullJS(
   program: es.Program,
   context: Context,
+  importOptions: ImportTransformOptions,
   skipUndefined: boolean
-): TranspiledResult {
+): Promise<TranspiledResult> {
   const usedIdentifiers = new Set<string>([
     ...getIdentifiersInProgram(program),
     ...getIdentifiersInNativeStorage(context.nativeStorage)
@@ -672,10 +705,11 @@ function transpileToFullJS(
   const globalIds = getNativeIds(program, usedIdentifiers)
   checkForUndefinedVariables(program, context.nativeStorage, globalIds, skipUndefined)
 
-  const [modulePrefix, importNodes, otherNodes] = transformImportDeclarations(
+  const [modulePrefix, importNodes, otherNodes] = await transformImportDeclarations(
     program,
     usedIdentifiers,
-    false,
+    importOptions,
+    context,
     globalIds.native
   )
 
@@ -702,13 +736,33 @@ function transpileToFullJS(
 export function transpile(
   program: es.Program,
   context: Context,
+  importOptions: RecursivePartial<ImportTransformOptions> = {},
   skipUndefined = false
-): TranspiledResult {
+): Promise<TranspiledResult> {
   if (context.chapter === Chapter.FULL_JS || context.chapter === Chapter.PYTHON_1) {
-    return transpileToFullJS(program, context, true)
+    const fullImportOptions = {
+      checkImports: false,
+      loadTabs: true,
+      wrapSourceModules: false,
+      ...importOptions
+    }
+
+    return transpileToFullJS(program, context, fullImportOptions, true)
   } else if (context.variant == Variant.NATIVE) {
-    return transpileToFullJS(program, context, false)
+    const fullImportOptions = {
+      checkImports: true,
+      loadTabs: true,
+      wrapSourceModules: true,
+      ...importOptions
+    }
+    return transpileToFullJS(program, context, fullImportOptions, false)
   } else {
-    return transpileToSource(program, context, skipUndefined)
+    const fullImportOptions = {
+      checkImports: true,
+      loadTabs: true,
+      wrapSourceModules: true,
+      ...importOptions
+    }
+    return transpileToSource(program, context, skipUndefined, fullImportOptions)
   }
 }
diff --git a/src/types.ts b/src/types.ts
index 2188092f..3aeb284e 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -472,3 +472,18 @@ export type TypeEnvironment = {
   declKindMap: Map<string, AllowedDeclarations>
   typeAliasMap: Map<string, Type | ForAll>
 }[]
+
+/**
+ * Helper type to recursively make properties that are also objects
+ * partial
+ *
+ * By default, `Partial<Array<T>>` is equivalent to `Array<T | undefined>`. For this type, `Array<T>` will be
+ * transformed to Array<Partial<T>> instead
+ */
+export type RecursivePartial<T> = T extends Array<any>
+  ? Array<RecursivePartial<T[number]>>
+  : T extends Record<any, any>
+  ? Partial<{
+      [K in keyof T]: RecursivePartial<T[K]>
+    }>
+  : T
diff --git a/src/utils/arrayMap.ts b/src/utils/arrayMap.ts
new file mode 100644
index 00000000..e0d934c7
--- /dev/null
+++ b/src/utils/arrayMap.ts
@@ -0,0 +1,71 @@
+/**
+ * Convenience class for maps that store an array of values
+ */
+export default class ArrayMap<K, V> {
+  constructor(private readonly map: Map<K, V[]> = new Map()) {}
+
+  public get(key: K) {
+    return this.map.get(key)
+  }
+
+  public add(key: K, item: V) {
+    if (!this.map.has(key)) {
+      this.map.set(key, [])
+    }
+    this.map.get(key)!.push(item)
+  }
+
+  public entries() {
+    return Array.from(this.map.entries())
+  }
+
+  public keys() {
+    return new Set(this.map.keys())
+  }
+
+  /**
+   * Similar to `mapAsync`, but for an async mapping function that does not return any value
+   */
+  public async forEachAsync<F extends (k: K, v: V[]) => Promise<void>>(forEach: F): Promise<void> {
+    await Promise.all(this.entries().map(([key, value]) => forEach(key, value)))
+  }
+
+  /**
+   * Using a mapping function that returns a promise, transform an array map
+   * to another array map with different keys and values. All calls to the mapping function
+   * execute asynchronously
+   */
+  public async mapAsync<F extends (k: K, v: V[]) => Promise<[any, any[]]>>(mapper: F) {
+    const pairs = await Promise.all(this.entries().map(([key, value]) => mapper(key, value)))
+
+    type U = Awaited<ReturnType<F>>
+    const tempMap = new Map<U[0], U[1]>(pairs)
+    return new ArrayMap<U[0], U[1][number]>(tempMap)
+  }
+
+  public [Symbol.toStringTag]() {
+    return this.entries().map(([key, value]) => `${key}: ${value}`)
+  }
+}
+
+/**
+ * Create an ArrayMap from an iterable of key value pairs
+ */
+export function arrayMapFrom<K, V extends Array<any>>(
+  pairs: Iterable<[K, V]>
+): ArrayMap<K, V[number]>
+export function arrayMapFrom<K, V>(pairs: Iterable<[K, V]>): ArrayMap<K, V>
+export function arrayMapFrom<K, V>(pairs: Iterable<[K, V | V[]]>) {
+  const res = new ArrayMap<K, V>()
+  for (const [k, v] of pairs) {
+    if (Array.isArray(v)) {
+      for (const each of v) {
+        res.add(k, each)
+      }
+    } else {
+      res.add(k, v)
+    }
+  }
+
+  return res
+}
diff --git a/src/utils/assert.ts b/src/utils/assert.ts
new file mode 100644
index 00000000..d405343e
--- /dev/null
+++ b/src/utils/assert.ts
@@ -0,0 +1,27 @@
+/*
+ * Why not use the nodejs builtin assert? It needs polyfills to work in the browser.
+ * With this we have a lightweight assert that doesn't need any further packages.
+ * Plus, we can customize our own assert messages and handling
+ */
+
+import { RuntimeSourceError } from '../errors/runtimeSourceError'
+
+export class AssertionError extends RuntimeSourceError {
+  constructor(public readonly message: string) {
+    super()
+  }
+
+  public explain(): string {
+    return this.message
+  }
+
+  public elaborate(): string {
+    return 'Please contact the administrators to let them know that this error has occurred'
+  }
+}
+
+export default function assert(condition: boolean, message: string): asserts condition {
+  if (!condition) {
+    throw new AssertionError(message)
+  }
+}
diff --git a/src/utils/ast/helpers.ts b/src/utils/ast/helpers.ts
new file mode 100644
index 00000000..58264719
--- /dev/null
+++ b/src/utils/ast/helpers.ts
@@ -0,0 +1,38 @@
+import type * as es from 'estree'
+
+import assert from '../assert'
+import { isImportDeclaration } from './typeGuards'
+
+/**
+ * Filters out all import declarations from a program, and sorts them by
+ * the module they import from
+ */
+export function filterImportDeclarations({
+  body
+}: es.Program): [
+  Record<string, es.ImportDeclaration[]>,
+  Exclude<es.Program['body'][0], es.ImportDeclaration>[]
+] {
+  return body.reduce(
+    ([importNodes, otherNodes], node) => {
+      if (!isImportDeclaration(node)) return [importNodes, [...otherNodes, node]]
+
+      const moduleName = node.source.value
+      assert(
+        typeof moduleName === 'string',
+        `Expected import declaration to have source of type string, got ${moduleName}`
+      )
+
+      if (!(moduleName in importNodes)) {
+        importNodes[moduleName] = []
+      }
+
+      importNodes[moduleName].push(node)
+      return [importNodes, otherNodes]
+    },
+    [{}, []] as [
+      Record<string, es.ImportDeclaration[]>,
+      Exclude<es.Program['body'][0], es.ImportDeclaration>[]
+    ]
+  )
+}
diff --git a/src/localImports/typeGuards.ts b/src/utils/ast/typeGuards.ts
similarity index 89%
rename from src/localImports/typeGuards.ts
rename to src/utils/ast/typeGuards.ts
index 6bc97715..66e14f8b 100644
--- a/src/localImports/typeGuards.ts
+++ b/src/utils/ast/typeGuards.ts
@@ -1,4 +1,7 @@
-import es from 'estree'
+import type * as es from 'estree'
+
+export const isImportDeclaration = (node: es.Program['body'][0]): node is es.ImportDeclaration =>
+  node.type === 'ImportDeclaration'
 
 // It is necessary to write this type guard like this as the 'type' of both
 // 'Directive' & 'ExpressionStatement' is 'ExpressionStatement'.
@@ -44,7 +47,3 @@ export function isDeclaration(node: es.Node): node is es.Declaration {
     node.type === 'ClassDeclaration'
   )
 }
-
-export function isImportDeclaration(node: es.Node): node is es.ImportDeclaration {
-  return node.type === 'ImportDeclaration'
-}
diff --git a/src/utils/misc.ts b/src/utils/misc.ts
new file mode 100644
index 00000000..eed90584
--- /dev/null
+++ b/src/utils/misc.ts
@@ -0,0 +1,18 @@
+import { RuntimeSourceError } from '../errors/runtimeSourceError'
+
+export class PromiseTimeoutError extends RuntimeSourceError {}
+
+export const timeoutPromise = <T>(promise: Promise<T>, timeout: number) =>
+  new Promise<T>((resolve, reject) => {
+    const timeoutid = setTimeout(() => reject(new PromiseTimeoutError()), timeout)
+
+    promise
+      .then(res => {
+        clearTimeout(timeoutid)
+        resolve(res)
+      })
+      .catch(e => {
+        clearTimeout(timeoutid)
+        reject(e)
+      })
+  })
diff --git a/tsconfig.json b/tsconfig.json
index 5cf50d62..3e418186 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,6 +29,8 @@
   "exclude": [
     "src/stdlib/metacircular-interpreter",
     "src/stdlib/**/*.js",
+    "src/**/__tests__/**",
+    "src/**/__mocks__/**",
     "node_modules",
     "dist",
     "sicp_publish",


Test patch:
diff --git a/src/infiniteLoops/__tests__/instrument.ts b/src/infiniteLoops/__tests__/instrument.ts
index f4f69c92..56886b86 100644
--- a/src/infiniteLoops/__tests__/instrument.ts
+++ b/src/infiniteLoops/__tests__/instrument.ts
@@ -37,7 +37,11 @@ function mockFunctionsAndState() {
  * Returns the value saved in the code using the builtin 'output'.
  * e.g. runWithMock('output(2)') --> 2
  */
-function runWithMock(main: string, codeHistory?: string[], builtins: Map<string, any> = new Map()) {
+async function runWithMock(
+  main: string,
+  codeHistory?: string[],
+  builtins: Map<string, any> = new Map()
+) {
   let output = undefined
   builtins.set('output', (x: any) => (output = x))
   builtins.set('undefined', undefined)
@@ -53,21 +57,23 @@ function runWithMock(main: string, codeHistory?: string[], builtins: Map<string,
     previous = restOfCode as Program[]
   }
   const [mockFunctions, mockState] = mockFunctionsAndState()
-  const instrumentedCode = instrument(previous, program as Program, builtins.keys())
+  const instrumentedCode = await instrument(previous, program as Program, builtins.keys())
   const { builtinsId, functionsId, stateId } = InfiniteLoopRuntimeObjectNames
   const sandboxedRun = new Function('code', functionsId, stateId, builtinsId, `return eval(code)`)
-  sandboxedRun(instrumentedCode, mockFunctions, mockState, builtins)
+  await sandboxedRun(instrumentedCode, mockFunctions, mockState, builtins)
   return output
 }
 
 test('builtins work', () => {
   const main = 'output(2);'
-  expect(runWithMock(main, [])).toBe(2)
+  return expect(runWithMock(main, [])).resolves.toBe(2)
 })
 
 test('binary and unary expressions work', () => {
-  expect(runWithMock('output(1+1);', [])).toBe(2)
-  expect(runWithMock('output(!true);', [])).toBe(false)
+  return Promise.all([
+    expect(runWithMock('output(1+1);', [])).resolves.toBe(2),
+    expect(runWithMock('output(!true);', [])).resolves.toBe(false)
+  ])
 })
 
 test('assignment works as expected', () => {
@@ -75,13 +81,13 @@ test('assignment works as expected', () => {
   let a = [];
   a[0] = 3;
   output(x+a[0]);`
-  expect(runWithMock(main)).toBe(5)
+  return expect(runWithMock(main)).resolves.toBe(5)
 })
 
 test('globals from old code accessible', () => {
   const main = 'output(z+1);'
   const prev = ['const z = w+1;', 'let w = 10;']
-  expect(runWithMock(main, prev)).toBe(12)
+  return expect(runWithMock(main, prev)).resolves.toBe(12)
 })
 
 test('functions run as expected', () => {
@@ -89,7 +95,7 @@ test('functions run as expected', () => {
     return x===0?x:f(x-1,y)+y;
   }
   output(f(5,2));`
-  expect(runWithMock(main)).toBe(10)
+  return expect(runWithMock(main)).resolves.toBe(10)
 })
 
 test('nested functions run as expected', () => {
@@ -100,7 +106,7 @@ test('nested functions run as expected', () => {
     return x===0?x:f(x-1,y)+y;
   }
   output(f(5,2));`
-  expect(runWithMock(main)).toBe(2)
+  return expect(runWithMock(main)).resolves.toBe(2)
 })
 
 test('higher order functions run as expected', () => {
@@ -108,14 +114,14 @@ test('higher order functions run as expected', () => {
     return f(x+1);
   }
   output(run(x=>x+1, 1));`
-  expect(runWithMock(main)).toBe(3)
+  return expect(runWithMock(main)).resolves.toBe(3)
 })
 
 test('loops run as expected', () => {
   const main = `let w = 0;
   for (let i = w; i < 10; i=i+1) {w = i;}
   output(w);`
-  expect(runWithMock(main)).toBe(9)
+  return expect(runWithMock(main)).resolves.toBe(9)
 })
 
 test('nested loops run as expected', () => {
@@ -126,13 +132,13 @@ test('nested loops run as expected', () => {
     }
   }
   output(w);`
-  expect(runWithMock(main)).toBe(100)
+  return expect(runWithMock(main)).resolves.toBe(100)
 })
 
 test('multidimentional arrays work', () => {
   const main = `const x = [[1],[2]];
   output(x[1] === undefined? undefined: x[1][0]);`
-  expect(runWithMock(main)).toBe(2)
+  return expect(runWithMock(main)).resolves.toBe(2)
 })
 
 test('if statements work as expected', () => {
@@ -141,7 +147,7 @@ test('if statements work as expected', () => {
     x = x + 1;
   } else {}
   output(x);`
-  expect(runWithMock(main)).toBe(2)
+  return expect(runWithMock(main)).resolves.toBe(2)
 })
 
 test('combination of loops and functions run as expected', () => {
@@ -158,5 +164,5 @@ test('combination of loops and functions run as expected', () => {
     w = minus(w,1);
   }
   output(z);`
-  expect(runWithMock(main)).toBe(100)
+  return expect(runWithMock(main)).resolves.toBe(100)
 })
diff --git a/src/infiniteLoops/__tests__/runtime.ts b/src/infiniteLoops/__tests__/runtime.ts
index 4eb48dae..d6f6dd1a 100644
--- a/src/infiniteLoops/__tests__/runtime.ts
+++ b/src/infiniteLoops/__tests__/runtime.ts
@@ -3,15 +3,15 @@ import * as es from 'estree'
 import { runInContext } from '../..'
 import createContext from '../../createContext'
 import { mockContext } from '../../mocks/context'
-import * as moduleLoader from '../../modules/moduleLoader'
 import { parse } from '../../parser/parser'
 import { Chapter, Variant } from '../../types'
 import { stripIndent } from '../../utils/formatters'
 import { getInfiniteLoopData, InfiniteLoopError, InfiniteLoopErrorType } from '../errors'
 import { testForInfiniteLoop } from '../runtime'
 
-jest.spyOn(moduleLoader, 'memoizedGetModuleFile').mockImplementationOnce(() => {
-  return stripIndent`
+jest.mock('../../modules/moduleLoaderAsync', () => ({
+  memoizedGetModuleBundleAsync: jest.fn(() =>
+    Promise.resolve(stripIndent`
     require => {
       'use strict';
       var exports = {};
@@ -36,8 +36,21 @@ jest.spyOn(moduleLoader, 'memoizedGetModuleFile').mockImplementationOnce(() => {
       });
       return exports;
     }
-  `
-})
+  `)
+  ),
+  memoizedGetModuleManifestAsync: jest.fn(() =>
+    Promise.resolve({
+      repeat: { tabs: [] }
+    })
+  ),
+  memoizedGetModuleDocsAsync: jest.fn(() =>
+    Promise.resolve({
+      repeat: '',
+      twice: '',
+      thrice: ''
+    })
+  )
+}))
 
 test('works in runInContext when throwInfiniteLoops is true', async () => {
   const code = `function fib(x) {
@@ -77,84 +90,84 @@ const testForInfiniteLoopWithCode = (code: string, previousPrograms: es.Program[
   return testForInfiniteLoop(program, previousPrograms)
 }
 
-test('non-infinite recursion not detected', () => {
+test('non-infinite recursion not detected', async () => {
   const code = `function fib(x) {
         return x<=1?x:fib(x-1) + fib(x-2);
     }
     fib(100000);
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeUndefined()
 })
 
-test('non-infinite loop not detected', () => {
+test('non-infinite loop not detected', async () => {
   const code = `for(let i = 0;i<2000;i=i+1){i+1;}
     let j = 0;
     while(j<2000) {j=j+1;}
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeUndefined()
 })
 
-test('no base case function detected', () => {
+test('no base case function detected', async () => {
   const code = `function fib(x) {
         return fib(x-1) + fib(x-2);
     }
     fib(100000);
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase)
   expect(result?.streamMode).toBe(false)
 })
 
-test('no base case loop detected', () => {
+test('no base case loop detected', async () => {
   const code = `for(let i = 0;true;i=i+1){i+1;}
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase)
   expect(result?.streamMode).toBe(false)
 })
 
-test('no variables changing function detected', () => {
+test('no variables changing function detected', async () => {
   const code = `let x = 1;
     function f() {
         return x===0?x:f();
     }
     f();
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
   expect(result?.explain()).toContain('None of the variables are being updated.')
 })
 
-test('no state change function detected', () => {
+test('no state change function detected', async () => {
   const code = `let x = 1;
     function f() {
         return x===0?x:f();
     }
     f();
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
   expect(result?.explain()).toContain('None of the variables are being updated.')
 })
 
-test('infinite cycle detected', () => {
+test('infinite cycle detected', async () => {
   const code = `function f(x) {
         return x[0] === 1? x : f(x);
     }
     f([2,3,4]);
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
   expect(result?.explain()).toContain('cycle')
   expect(result?.explain()).toContain('[2,3,4]')
 })
 
-test('infinite data structures detected', () => {
+test('infinite data structures detected', async () => {
   const code = `function f(x) {
         return is_null(x)? x : f(tail(x));
     }
@@ -162,32 +175,32 @@ test('infinite data structures detected', () => {
     set_tail(tail(tail(circ)), circ);
     f(circ);
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
   expect(result?.explain()).toContain('cycle')
   expect(result?.explain()).toContain('(CIRCULAR)')
 })
 
-test('functions using SMT work', () => {
+test('functions using SMT work', async () => {
   const code = `function f(x) {
         return x===0? x: f(x+1);
     }
     f(1);
     `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt)
   expect(result?.streamMode).toBe(false)
 })
 
-test('detect forcing infinite streams', () => {
+test('detect forcing infinite streams', async () => {
   const code = `stream_to_list(integers_from(0));`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.NoBaseCase)
   expect(result?.streamMode).toBe(true)
 })
 
-test('detect mutual recursion', () => {
+test('detect mutual recursion', async () => {
   const code = `function e(x){
     return x===0?1:1-o(x-1);
     }
@@ -195,23 +208,23 @@ test('detect mutual recursion', () => {
         return x===1?0:1-e(x-1);
     }
     e(9);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt)
   expect(result?.streamMode).toBe(false)
 })
 
-test('functions passed as arguments not checked', () => {
+test('functions passed as arguments not checked', async () => {
   // if they are checked -> this will throw no base case
   const code = `const twice = f => x => f(f(x));
   const thrice = f => x => f(f(f(x)));
   const add = x => x + 1;
   
   (thrice)(twice(twice))(twice(add))(0);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeUndefined()
 })
 
-test('detect complicated cycle example', () => {
+test('detect complicated cycle example', async () => {
   const code = `function permutations(s) {
     return is_null(s)
     ? list(null)
@@ -230,12 +243,12 @@ test('detect complicated cycle example', () => {
    
    remove_duplicate(list(list(1,2,3), list(1,2,3)));
    `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
 })
 
-test('detect complicated cycle example 2', () => {
+test('detect complicated cycle example 2', async () => {
   const code = `function make_big_int_from_number(num){
     let output = num;
     while(output !== 0){
@@ -246,12 +259,12 @@ test('detect complicated cycle example 2', () => {
 }
 make_big_int_from_number(1234);
    `
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
   expect(result?.streamMode).toBe(false)
 })
 
-test('detect complicated fromSMT example 2', () => {
+test('detect complicated fromSMT example 2', async () => {
   const code = `function fast_power(b,n){
     if (n % 2 === 0){
         return b* fast_power(b, n-2);
@@ -261,47 +274,47 @@ test('detect complicated fromSMT example 2', () => {
 
   }
   fast_power(2,3);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.FromSmt)
   expect(result?.streamMode).toBe(false)
 })
 
-test('detect complicated stream example', () => {
+test('detect complicated stream example', async () => {
   const code = `function up(a, b) {
     return (a > b)
             ? up(1, 1 + b)
             : pair(a, () => stream_reverse(up(a + 1, b)));
   }
   eval_stream(up(1,1), 22);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeDefined()
   expect(result?.streamMode).toBe(true)
 })
 
-test('math functions are disabled in smt solver', () => {
+test('math functions are disabled in smt solver', async () => {
   const code = `
   function f(x) {
     return x===0? x: f(math_floor(x+1));
   }
   f(1);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeUndefined()
 })
 
-test('cycle detection ignores non deterministic functions', () => {
+test('cycle detection ignores non deterministic functions', async () => {
   const code = `
   function f(x) {
     return x===0?0:f(math_floor(math_random()/2) + 1);
   }
   f(1);`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result).toBeUndefined()
 })
 
-test('handle imports properly', () => {
+test('handle imports properly', async () => {
   const code = `import {thrice} from "repeat";
   function f(x) { return is_number(x) ? f(x) : 42; }
   display(f(thrice(x=>x+1)(0)));`
-  const result = testForInfiniteLoopWithCode(code, [])
+  const result = await testForInfiniteLoopWithCode(code, [])
   expect(result?.infiniteLoopType).toBe(InfiniteLoopErrorType.Cycle)
 })
diff --git a/src/modules/__tests__/moduleLoader.ts b/src/modules/__tests__/moduleLoader.ts
index d191d8fa..84a553fd 100644
--- a/src/modules/__tests__/moduleLoader.ts
+++ b/src/modules/__tests__/moduleLoader.ts
@@ -183,4 +183,17 @@ describe('Testing modules/moduleLoader.ts in a jsdom environment', () => {
     expect(mockedXMLHttpRequest3.send).toHaveBeenCalledTimes(1)
     expect(mockedXMLHttpRequest3.send).toHaveBeenCalledWith(null)
   })
+
+  test('Able to load tabs with export default declarations', () => {
+    mockXMLHttpRequest({ responseText: `{ "valid_module": { "tabs": ["Tab1", "Tab2"] } }` })
+    mockXMLHttpRequest({ responseText: `export default require => ({ foo: () => 'foo' })` })
+    mockXMLHttpRequest({ responseText: `require => ({ foo: () => 'foo' })` })
+
+    const [rawTab1, rawTab2] = moduleLoader.loadModuleTabs('valid_module')
+    const tab1 = rawTab1(jest.fn())
+    expect(tab1.foo()).toEqual('foo')
+
+    const tab2 = rawTab2(jest.fn())
+    expect(tab2.foo()).toEqual('foo')
+  })
 })
diff --git a/src/modules/__tests__/moduleLoaderAsync.ts b/src/modules/__tests__/moduleLoaderAsync.ts
new file mode 100644
index 00000000..a4f64fd2
--- /dev/null
+++ b/src/modules/__tests__/moduleLoaderAsync.ts
@@ -0,0 +1,197 @@
+import type { MockedFunction } from 'jest-mock'
+
+import { mockContext } from '../../mocks/context'
+import { Chapter, Variant } from '../../types'
+import { ModuleConnectionError, ModuleInternalError } from '../errors'
+import { MODULES_STATIC_URL } from '../moduleLoader'
+import * as moduleLoader from '../moduleLoaderAsync'
+
+jest.mock('lodash', () => ({
+  ...jest.requireActual('lodash'),
+  memoize: jest.fn(x => x)
+}))
+
+global.fetch = jest.fn()
+const mockedFetch = fetch as MockedFunction<typeof fetch>
+
+function mockResponse(response: string, status: number = 200) {
+  mockedFetch.mockResolvedValueOnce({
+    text: () => Promise.resolve(response),
+    json: () => Promise.resolve(JSON.parse(response)),
+    status
+  } as any)
+}
+
+async function expectSuccess<T extends string | object>(
+  correctUrl: string,
+  expectedResp: T,
+  func: () => Promise<T>,
+  callCount: number = 1
+) {
+  const response = await func()
+
+  expect(fetch).toHaveBeenCalledTimes(callCount)
+
+  const [calledUrl, callOpts] = mockedFetch.mock.calls[0]
+  expect(calledUrl).toEqual(correctUrl)
+  expect(callOpts).toMatchObject({ method: 'GET' })
+  if (typeof expectedResp === 'string') {
+    expect(response).toEqual(expectedResp)
+  } else {
+    expect(response).toMatchObject(expectedResp)
+  }
+}
+
+async function expectFailure(sampleUrl: string, expectedErr: any, func: () => Promise<any>) {
+  await expect(() => func()).rejects.toBeInstanceOf(expectedErr)
+
+  const [calledUrl, callOpts] = mockedFetch.mock.calls[0]
+  expect(calledUrl).toEqual(sampleUrl)
+  expect(callOpts).toMatchObject({ method: 'GET' })
+}
+
+beforeEach(() => {
+  jest.clearAllMocks()
+})
+
+describe('Test httpGetAsync', () => {
+  test('Http GET function httpGetAsync() works correctly', async () => {
+    const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }`
+    const sampleUrl = 'https://www.example.com'
+
+    mockResponse(sampleResponse)
+    await expectSuccess(sampleUrl, sampleResponse, () =>
+      moduleLoader.httpGetAsync(sampleUrl, 'text')
+    )
+  })
+
+  test('Http GET function httpGetAsync() throws ModuleConnectionError', async () => {
+    const sampleUrl = 'https://www.example.com'
+    mockResponse('', 404)
+
+    await expectFailure(sampleUrl, ModuleConnectionError, () =>
+      moduleLoader.httpGetAsync(sampleUrl, 'text')
+    )
+  })
+
+  test('Http GET modules manifest correctly', async () => {
+    const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }`
+    const correctUrl = MODULES_STATIC_URL + `/modules.json`
+    mockResponse(sampleResponse)
+
+    await expectSuccess(correctUrl, JSON.parse(sampleResponse), () =>
+      moduleLoader.memoizedGetModuleManifestAsync()
+    )
+  })
+
+  test('Http GET returns objects when "json" is specified', async () => {
+    const sampleResponse = `{ "repeat": { "contents": ["Repeat"] } }`
+    const correctUrl = MODULES_STATIC_URL + `/modules.json`
+    mockResponse(sampleResponse)
+    const result = await moduleLoader.httpGetAsync(correctUrl, 'json')
+    expect(result).toMatchObject(JSON.parse(sampleResponse))
+  })
+
+  test('Handles TypeErrors thrown by fetch', async () => {
+    mockedFetch.mockImplementationOnce(() => {
+      throw new TypeError()
+    })
+    await expectFailure('anyUrl', ModuleConnectionError, () =>
+      moduleLoader.httpGetAsync('anyUrl', 'text')
+    )
+  })
+})
+
+describe('Test bundle loading', () => {
+  const sampleModuleName = 'valid_module'
+  const sampleModuleUrl = MODULES_STATIC_URL + `/bundles/${sampleModuleName}.js`
+
+  test('Http GET module bundle correctly', async () => {
+    const sampleResponse = `require => ({ foo: () => 'foo' })`
+    mockResponse(sampleResponse)
+
+    const bundleText = await moduleLoader.memoizedGetModuleBundleAsync(sampleModuleName)
+
+    expect(fetch).toHaveBeenCalledTimes(1)
+
+    const [calledUrl, callOpts] = mockedFetch.mock.calls[0]
+    expect(calledUrl).toEqual(sampleModuleUrl)
+    expect(callOpts).toMatchObject({ method: 'GET' })
+    expect(bundleText).toEqual(sampleResponse)
+  })
+
+  test('Loading a correctly implemented module bundle', async () => {
+    const context = mockContext(Chapter.SOURCE_4, Variant.DEFAULT)
+    const sampleResponse = `require => ({ foo: () => 'foo' })`
+    mockResponse(sampleResponse)
+
+    const loadedModule = await moduleLoader.loadModuleBundleAsync(sampleModuleName, context, false)
+
+    expect(loadedModule.foo()).toEqual('foo')
+    expect(fetch).toHaveBeenCalledTimes(1)
+
+    const [calledUrl, callOpts] = mockedFetch.mock.calls[0]
+    expect(calledUrl).toEqual(sampleModuleUrl)
+    expect(callOpts).toMatchObject({ method: 'GET' })
+  })
+
+  test('Loading a wrongly implemented module bundle throws ModuleInternalError', async () => {
+    const context = mockContext(Chapter.SOURCE_4, Variant.DEFAULT)
+    const wrongModuleText = `export function es6_function(params) {};`
+    mockResponse(wrongModuleText)
+    await expect(() =>
+      moduleLoader.loadModuleBundleAsync(sampleModuleName, context, true)
+    ).rejects.toBeInstanceOf(ModuleInternalError)
+
+    expect(fetch).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('Test tab loading', () => {
+  const sampleTabUrl = `${MODULES_STATIC_URL}/tabs/Tab1.js`
+  const sampleManifest = `{ "one_module": { "tabs": ["Tab1", "Tab2"] } }`
+
+  test('Http GET module tab correctly', async () => {
+    const sampleResponse = `require => ({ foo: () => 'foo' })`
+    mockResponse(sampleResponse)
+
+    const bundleText = await moduleLoader.memoizedGetModuleTabAsync('Tab1')
+
+    expect(fetch).toHaveBeenCalledTimes(1)
+
+    const [calledUrl, callOpts] = mockedFetch.mock.calls[0]
+    expect(calledUrl).toEqual(sampleTabUrl)
+    expect(callOpts).toMatchObject({ method: 'GET' })
+    expect(bundleText).toEqual(sampleResponse)
+  })
+
+  test('Loading a wrongly implemented tab throws ModuleInternalError', async () => {
+    mockResponse(sampleManifest)
+
+    const wrongTabText = `export function es6_function(params) {};`
+    mockResponse(wrongTabText)
+    mockResponse(wrongTabText)
+
+    await expect(() => moduleLoader.loadModuleTabsAsync('one_module')).rejects.toBeInstanceOf(
+      ModuleInternalError
+    )
+    expect(fetch).toHaveBeenCalledTimes(3)
+
+    const [[call0Url], [call1Url], [call2Url]] = mockedFetch.mock.calls
+    expect(call0Url).toEqual(`${MODULES_STATIC_URL}/modules.json`)
+    expect(call1Url).toEqual(`${MODULES_STATIC_URL}/tabs/Tab1.js`)
+    expect(call2Url).toEqual(`${MODULES_STATIC_URL}/tabs/Tab2.js`)
+  })
+
+  test('Able to handle tabs with export default declarations', async () => {
+    mockResponse(sampleManifest)
+    mockResponse(`export default require => ({ foo: () => 'foo' })`)
+    mockResponse(`require => ({ foo: () => 'foo' })`)
+    const [rawTab1, rawTab2] = await moduleLoader.loadModuleTabsAsync('one_module')
+    const tab1 = rawTab1(jest.fn())
+    expect(tab1.foo()).toEqual('foo')
+
+    const tab2 = rawTab2(jest.fn())
+    expect(tab2.foo()).toEqual('foo')
+  })
+})
diff --git a/src/stepper/__tests__/stepper.ts b/src/stepper/__tests__/stepper.ts
index bb8e20f4..52b1022b 100644
--- a/src/stepper/__tests__/stepper.ts
+++ b/src/stepper/__tests__/stepper.ts
@@ -1,8 +1,8 @@
-import * as es from 'estree'
+import type * as es from 'estree'
 
 import { mockContext } from '../../mocks/context'
 import { parse } from '../../parser/parser'
-import { Chapter, substituterNodes } from '../../types'
+import { Chapter, Context, substituterNodes } from '../../types'
 import { codify, getEvaluationSteps } from '../stepper'
 
 function getLastStepAsString(steps: [substituterNodes, string[][], string][]): string {
@@ -45,11 +45,12 @@ describe('Test codify works on non-circular abstract syntax graphs', () => {
 })
 
 describe('Test codify works on circular abstract syntax graphs', () => {
-  test('functions', () => {
+  test('functions', async () => {
     const code = `
     x => x();
   `
     const program = parse(code, mockContext())!
+
     const arrowFunctionExpression = ((program as es.Program).body[0] as es.ExpressionStatement)
       .expression as es.ArrowFunctionExpression
     const callExpression = arrowFunctionExpression.body as es.CallExpression
@@ -62,12 +63,25 @@ describe('Test codify works on circular abstract syntax graphs', () => {
 })
 
 // source 0
-test('Test basic substitution', () => {
+const testEvalSteps = (programStr: string, context?: Context) => {
+  context = context ?? mockContext()
+  const program = parse(programStr, context)!
+  const options = {
+    stepLimit: 1000,
+    importOptions: {
+      loadTabs: false,
+      wrapSourceModules: false,
+      checkImports: false
+    }
+  }
+  return getEvaluationSteps(program, context, options)
+}
+
+test('Test basic substitution', async () => {
   const code = `
     (1 + 2) * (3 + 4);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "(1 + 2) * (3 + 4);
 
@@ -88,13 +102,11 @@ test('Test basic substitution', () => {
   `)
 })
 
-test('Test binary operator error', () => {
+test('Test binary operator error', async () => {
   const code = `
     (1 + 2) * ('a' + 'string');
   `
-  const context = mockContext()
-  const program = parse(code, context)!
-  const steps = getEvaluationSteps(program, context, 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "(1 + 2) * ('a' + 'string');
 
@@ -111,13 +123,12 @@ test('Test binary operator error', () => {
   `)
 })
 
-test('Test two statement substitution', () => {
+test('Test two statement substitution', async () => {
   const code = `
     (1 + 2) * (3 + 4);
     3 * 5;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(Chapter.SOURCE_4), 1000)
+  const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_4))
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "(1 + 2) * (3 + 4);
     3 * 5;
@@ -154,12 +165,11 @@ test('Test two statement substitution', () => {
   `)
 })
 
-test('Test unary and binary boolean operations', () => {
+test('Test unary and binary boolean operations', async () => {
   const code = `
   !!!true || true;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "!!!true || true;
 
@@ -184,12 +194,11 @@ test('Test unary and binary boolean operations', () => {
   `)
 })
 
-test('Test ternary operator', () => {
+test('Test ternary operator', async () => {
   const code = `
   1 + -1 === 0 ? false ? true : Infinity : undefined;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "1 + -1 === 0 ? false ? true : Infinity : undefined;
 
@@ -214,15 +223,14 @@ test('Test ternary operator', () => {
   `)
 })
 
-test('Test basic function', () => {
+test('Test basic function', async () => {
   const code = `
   function f(n) {
     return n;
   }
   f(5+1*6-40);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "function f(n) {
       return n;
@@ -257,15 +265,14 @@ test('Test basic function', () => {
   `)
 })
 
-test('Test basic bifunction', () => {
+test('Test basic bifunction', async () => {
   const code = `
   function f(n, m) {
     return n * m;
   }
   f(5+1*6-40, 2-5);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "function f(n, m) {
       return n * m;
@@ -308,7 +315,7 @@ test('Test basic bifunction', () => {
   `)
 })
 
-test('Test "recursive" function calls', () => {
+test('Test "recursive" function calls', async () => {
   const code = `
   function factorial(n) {
     return n === 0
@@ -317,8 +324,7 @@ test('Test "recursive" function calls', () => {
   }
   factorial(5);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "function factorial(n) {
       return n === 0 ? 1 : n * factorial(n - 1);
@@ -450,12 +456,11 @@ test('Test "recursive" function calls', () => {
 })
 
 // source 0
-test('undefined || 1', () => {
+test('undefined || 1', async () => {
   const code = `
   undefined || 1;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "undefined || 1;
 
@@ -465,12 +470,11 @@ test('undefined || 1', () => {
 })
 
 // source 0
-test('1 + math_sin', () => {
+test('1 + math_sin', async () => {
   const code = `
   1 + math_sin;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "1 + math_sin;
 
@@ -480,12 +484,12 @@ test('1 + math_sin', () => {
 })
 
 // source 0
-test('plus undefined', () => {
+test('plus undefined', async () => {
   const code = `
   math_sin(1) + undefined;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "math_sin(1) + undefined;
 
@@ -499,12 +503,11 @@ test('plus undefined', () => {
 })
 
 // source 0
-test('math_pow', () => {
+test('math_pow', async () => {
   const code = `
   math_pow(2, 20) || NaN;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "math_pow(2, 20) || NaN;
 
@@ -518,7 +521,7 @@ test('math_pow', () => {
 })
 
 // source 0
-test('expmod', () => {
+test('expmod', async () => {
   const code = `
   function is_even(n) {
     return n % 2 === 0;
@@ -539,26 +542,24 @@ function expmod(base, exp, m) {
 
 expmod(4, 3, 5);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
 })
 
 // source 0
-test('Infinite recursion', () => {
+test('Infinite recursion', async () => {
   const code = `
   function f() {
     return f();
 }
 f();
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
 })
 
 // source 0
-test('subsets', () => {
+test('subsets', async () => {
   const code = `
   function subsets(s) {
     if (is_null(s)) {
@@ -571,20 +572,18 @@ test('subsets', () => {
 
  subsets(list(1, 2, 3));
   `
-  const program = parse(code, mockContext(Chapter.SOURCE_2))!
-  const steps = getEvaluationSteps(program, mockContext(Chapter.SOURCE_2), 1000)
+  const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
 })
 
 // source 0
-test('even odd mutual', () => {
+test('even odd mutual', async () => {
   const code = `
   const odd = n => n === 0 ? false : even(n-1);
   const even = n => n === 0 || odd(n-1);
   even(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(getLastStepAsString(steps)).toEqual('false;')
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "const odd = n => n === 0 ? false : even(n - 1);
@@ -637,13 +636,12 @@ test('even odd mutual', () => {
 })
 
 // source 0
-test('assign undefined', () => {
+test('assign undefined', async () => {
   const code = `
   const a = undefined;
   a;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(getLastStepAsString(steps)).toEqual('undefined;')
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "const a = undefined;
@@ -659,12 +657,11 @@ test('assign undefined', () => {
   `)
 })
 
-test('builtins return identifiers', () => {
+test('builtins return identifiers', async () => {
   const code = `
   math_sin();
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(getLastStepAsString(steps)).toEqual('NaN;')
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "math_sin();
@@ -678,12 +675,12 @@ test('builtins return identifiers', () => {
   `)
 })
 
-test('negative numbers as arguments', () => {
+test('negative numbers as arguments', async () => {
   const code = `
   math_sin(-1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "math_sin(-1);
 
@@ -696,12 +693,11 @@ test('negative numbers as arguments', () => {
   `)
 })
 
-test('is_function checks for builtin', () => {
+test('is_function checks for builtin', async () => {
   const code = `
     is_function(is_function);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "is_function(is_function);
 
@@ -714,15 +710,14 @@ test('is_function checks for builtin', () => {
   `)
 })
 
-test('triple equals work on function', () => {
+test('triple equals work on function', async () => {
   const code = `
     function f() { return g(); } function g() { return f(); }
     f === f;
     g === g;
     f === g;
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "function f() {
       return g();
@@ -797,7 +792,7 @@ test('triple equals work on function', () => {
   `)
 })
 
-test('constant declarations in blocks are protected', () => {
+test('constant declarations in blocks are protected', async () => {
   const code = `
     const z = 1;
 
@@ -808,8 +803,7 @@ function f(g) {
 
 f(y => y + z);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
     "const z = 1;
     function f(g) {
@@ -875,7 +869,7 @@ f(y => y + z);
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test('function declarations in blocks are protected', () => {
+test('function declarations in blocks are protected', async () => {
   const code = `
     function repeat_pattern(n, p, r) {
     function twice_p(r) {
@@ -895,13 +889,12 @@ function plus_one(x) {
 repeat_pattern(5, plus_one, 0);
 
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('5;')
 })
 
-test('const declarations in blocks subst into call expressions', () => {
+test('const declarations in blocks subst into call expressions', async () => {
   const code = `
   const z = 1;
   function f(g) {
@@ -910,13 +903,13 @@ test('const declarations in blocks subst into call expressions', () => {
   }
   f(undefined);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('6;')
 })
 
-test('scoping test for lambda expressions nested in blocks', () => {
+test('scoping test for lambda expressions nested in blocks', async () => {
   const code = `
   {
     const f = x => g();
@@ -925,26 +918,26 @@ test('scoping test for lambda expressions nested in blocks', () => {
     f(0);
   }
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('scoping test for blocks nested in lambda expressions', () => {
+test('scoping test for blocks nested in lambda expressions', async () => {
   const code = `
   const f = x => { g(); };
   const g = () => { x; };
   const x = 1;
   f(0);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('undefined;')
 })
 
-test('scoping test for function expressions', () => {
+test('scoping test for function expressions', async () => {
   const code = `
   function f(x) {
     return g();
@@ -955,26 +948,26 @@ test('scoping test for function expressions', () => {
   const x = 1;
   f(0);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('scoping test for lambda expressions', () => {
+test('scoping test for lambda expressions', async () => {
   const code = `
   const f = x => g();
   const g = () => x;
   const x = 1;
   f(0);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('scoping test for block expressions', () => {
+test('scoping test for block expressions', async () => {
   const code = `
   function f(x) {
     const y = x;
@@ -986,13 +979,13 @@ test('scoping test for block expressions', () => {
   const y = 1;
   f(0);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('scoping test for block expressions, no renaming', () => {
+test('scoping test for block expressions, no renaming', async () => {
   const code = `
   function h(w) {
     function f(w) {
@@ -1005,37 +998,37 @@ test('scoping test for block expressions, no renaming', () => {
   }
   h(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('return in nested blocks', () => {
+test('return in nested blocks', async () => {
   const code = `
   function f(x) {{ return 1; }}
   f(0);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test('renaming clash test for lambda function', () => {
+test('renaming clash test for lambda function', async () => {
   const code = `
   const f = w_11 => w_10 => w_11 + w_10 + g();
   const g = () => w_10;
   const w_10 = 0;
   f(1)(2);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test('renaming clash test for functions', () => {
+test('renaming clash test for functions', async () => {
   const code = `
   function f(w_8) {
     function h(w_9) {
@@ -1051,13 +1044,13 @@ function g() {
 const w_9 = 0;
 f(1)(2);
 `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test('renaming clash in replacement for lambda function', () => {
+test('renaming clash in replacement for lambda function', async () => {
   const code = `
   const g = () => x_1 + x_2;
   const f = x_1 => x_2 => g();
@@ -1065,13 +1058,13 @@ test('renaming clash in replacement for lambda function', () => {
   const x_2 = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('0;')
 })
 
-test(`renaming clash in replacement for function expression`, () => {
+test(`renaming clash in replacement for function expression`, async () => {
   const code = `
   function f(x_1) {
     function h(x_2) {
@@ -1086,13 +1079,13 @@ test(`renaming clash in replacement for function expression`, () => {
   const x_2 = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('0;')
 })
 
-test(`renaming clash in replacement for function declaration`, () => {
+test(`renaming clash in replacement for function declaration`, async () => {
   const code = `
   function g() {
     return x_1 + x_2;
@@ -1107,13 +1100,13 @@ test(`renaming clash in replacement for function declaration`, () => {
   const x_2 = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('0;')
 })
 
-test(`multiple clash for function declaration`, () => {
+test(`multiple clash for function declaration`, async () => {
   const code = `
   function g() {
     return x_2 + x_3;
@@ -1129,13 +1122,13 @@ test(`multiple clash for function declaration`, () => {
   const x_4 = 2;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test(`multiple clash for function expression`, () => {
+test(`multiple clash for function expression`, async () => {
   const code = `
   function f(x_2) {
     function h(x_3) {
@@ -1151,13 +1144,13 @@ test(`multiple clash for function expression`, () => {
   const x_4 = 2;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test(`multiple clash for lambda function`, () => {
+test(`multiple clash for lambda function`, async () => {
   const code = `
   const f = x_2 => x_3 => x_4 + g();
   const g = () => x_2 + x_3;
@@ -1166,13 +1159,13 @@ test(`multiple clash for lambda function`, () => {
   const x_4 = 2;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test(`multiple clash 2 for lambda function`, () => {
+test(`multiple clash 2 for lambda function`, async () => {
   const code = `
   const f = x => x_1 => x_2 + g();
   const g = () => x + x_1;
@@ -1181,13 +1174,13 @@ test(`multiple clash 2 for lambda function`, () => {
   const x = 1;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test(`multiple clash 2 for function expression`, () => {
+test(`multiple clash 2 for function expression`, async () => {
   const code = `
   function f(x) {
     function h(x_1) {
@@ -1203,13 +1196,13 @@ test(`multiple clash 2 for function expression`, () => {
   const x = 1;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test(`multiple clash 2 for function declaration`, () => {
+test(`multiple clash 2 for function declaration`, async () => {
   const code = `
   function g() {
     return x + x_1;
@@ -1225,13 +1218,13 @@ test(`multiple clash 2 for function declaration`, () => {
   const x = 1;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test(`renaming clash with declaration in replacement for function declaration`, () => {
+test(`renaming clash with declaration in replacement for function declaration`, async () => {
   const code = `
   function g() {
     const x_2 = 2;
@@ -1249,13 +1242,13 @@ test(`renaming clash with declaration in replacement for function declaration`,
   const x = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('3;')
 })
 
-test(`renaming clash with declaration in replacement for function expression`, () => {
+test(`renaming clash with declaration in replacement for function expression`, async () => {
   const code = `
   function f(x) {
     function h(x_1) {
@@ -1273,13 +1266,13 @@ test(`renaming clash with declaration in replacement for function expression`, (
   const x = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('2;')
 })
 
-test(`renaming clash with declaration in replacement for lambda function`, () => {
+test(`renaming clash with declaration in replacement for lambda function`, async () => {
   const code = `
   const f = x => x_1 => g();
   const g = () => { const x_2 = 2; return x_1 + x + x_2; };
@@ -1287,13 +1280,13 @@ test(`renaming clash with declaration in replacement for lambda function`, () =>
   const x_1 = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('2;')
 })
 
-test(`renaming clash with parameter of lambda function declaration in block`, () => {
+test(`renaming clash with parameter of lambda function declaration in block`, async () => {
   const code = `
   const g = () => x_1;
   const f = x_1 => {
@@ -1304,13 +1297,13 @@ test(`renaming clash with parameter of lambda function declaration in block`, ()
   const x_1 = 1;
   f(3)(2);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test(`renaming clash with parameter of function declaration in block`, () => {
+test(`renaming clash with parameter of function declaration in block`, async () => {
   const code = `
   function g() {
     return x_1;
@@ -1324,26 +1317,26 @@ test(`renaming clash with parameter of function declaration in block`, () => {
   const x_1 = 1;
   f(3)(2);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('4;')
 })
 
-test(`renaming of outer parameter in lambda function`, () => {
+test(`renaming of outer parameter in lambda function`, async () => {
   const code = `
   const g = () =>  w_1;
   const f = w_1 => w_2 => w_1 + g();
   const w_1 = 0;
   f(1)(1);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('1;')
 })
 
-test(`removes debugger statements`, () => {
+test(`removes debugger statements`, async () => {
   const code = `
   function f(n) {
     debugger;
@@ -1352,71 +1345,67 @@ test(`removes debugger statements`, () => {
   debugger;
   f(3);
   `
-  const program = parse(code, mockContext())!
-  const steps = getEvaluationSteps(program, mockContext(), 1000)
+  const steps = await testEvalSteps(code)
+
   expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
   expect(getLastStepAsString(steps)).toEqual('6;')
 })
 
 describe(`redeclaration of predeclared functions work`, () => {
-  test('control', () => {
+  test('control', async () => {
     const code = `
     length(list(1, 2, 3));
     `
-    const context = mockContext(Chapter.SOURCE_2)
-    const program = parse(code, context)!
-    const steps = getEvaluationSteps(program, context, 1000)
+
+    const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('3;')
   })
 
-  test('test', () => {
+  test('test', async () => {
     const code = `
     function length(xs) {
       return 0;
     }
     length(list(1, 2, 3));
     `
-    const context = mockContext(Chapter.SOURCE_2)
-    const program = parse(code, context)!
-    const steps = getEvaluationSteps(program, context, 1000)
+    const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
+
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('0;')
   })
 })
 
 describe(`#1109: Empty function bodies don't break execution`, () => {
-  test('Function declaration', () => {
+  test('Function declaration', async () => {
     const code = `
     function a() {}
     "other statement";
     a();
     "Gets returned by normal run";
     `
-    const context = mockContext(Chapter.SOURCE_2)
-    const program = parse(code, context)!
-    const steps = getEvaluationSteps(program, context, 1000)
+    const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
+
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('"Gets returned by normal run";')
   })
 
-  test('Constant declaration of lambda', () => {
+  test('Constant declaration of lambda', async () => {
     const code = `
     const a = () => {};
     "other statement";
     a();
     "Gets returned by normal run";
     `
-    const context = mockContext(Chapter.SOURCE_2)
-    const program = parse(code, context)!
-    const steps = getEvaluationSteps(program, context, 1000)
+    const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
+
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('"Gets returned by normal run";')
   })
 })
 
 describe(`#1342: Test the fix of #1341: Stepper limit off by one`, () => {
-  test('Program steps equal to Stepper limit', () => {
+  test('Program steps equal to Stepper limit', async () => {
     const code = `
       function factorial(n) {
         return n === 1
@@ -1425,32 +1414,29 @@ describe(`#1342: Test the fix of #1341: Stepper limit off by one`, () => {
       }
       factorial(100);
       `
-    const context = mockContext(Chapter.SOURCE_2)
-    const program = parse(code, context)!
-    const steps = getEvaluationSteps(program, context, 1000)
+    const steps = await testEvalSteps(code, mockContext(Chapter.SOURCE_2))
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('9.33262154439441e+157;')
   })
 })
 
 describe(`Evaluation of empty code and imports`, () => {
-  test('Evaluate empty program', () => {
+  test('Evaluate empty program', async () => {
     const code = ``
-    const program = parse(code, mockContext())!
-    const steps = getEvaluationSteps(program, mockContext(), 1000)
+    const steps = await testEvalSteps(code, mockContext())
     expect(steps.map(x => codify(x[0])).join('\n')).toMatchSnapshot()
     expect(getLastStepAsString(steps)).toEqual('')
   })
 })
 
 // describe(`#1223: Stepper: Import statements cause errors`, () => {
-//   test('import a module and invoke its functions', () => {
+//   test('import a module and invoke its functions', async () => {
 //     const code = `
 //     import {circle, show, red, stack} from "rune";
 //     show(stack(red(circle), circle));
 //     `
-//     const program = parse(code, mockContext())!
-//     const steps = getEvaluationSteps(program, mockContext(), 1000)
+//     const steps = await testEvalSteps(code)
+//
 //     expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
 //       "show(stack(red(circle), circle));
 
@@ -1471,13 +1457,13 @@ describe(`Evaluation of empty code and imports`, () => {
 //     `)
 //   })
 
-//   test('return function from module function and invoke built-in with lambda', () => {
+//   test('return function from module function and invoke built-in with lambda', async () => {
 //     const code = `
 //     import {draw_points, make_point} from "curve";
 //     draw_points(100)(t => make_point(t, t));
 //     `
-//     const program = parse(code, mockContext())!
-//     const steps = getEvaluationSteps(program, mockContext(), 1000)
+//     const steps = await testEvalSteps(code)
+//
 //     expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
 //       "draw_points(100)(t => make_point(t, t));
 
@@ -1494,7 +1480,7 @@ describe(`Evaluation of empty code and imports`, () => {
 //     `)
 //   })
 
-//   test('invoke built-in with function expression', () => {
+//   test('invoke built-in with function expression', async () => {
 //     const code = `
 //     import {draw_3D_points, make_3D_point} from "curve";
 //     function f(t) {
@@ -1502,8 +1488,8 @@ describe(`Evaluation of empty code and imports`, () => {
 //     }
 //     draw_3D_points(100)(f);
 //     `
-//     const program = parse(code, mockContext())!
-//     const steps = getEvaluationSteps(program, mockContext(), 1000)
+//     const steps = await testEvalSteps(code)
+//
 //     expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
 //       "function f(t) {
 //         return make_3D_point(t, t, t);
@@ -1530,7 +1516,7 @@ describe(`Evaluation of empty code and imports`, () => {
 //     `)
 //   })
 
-//   test('recursive function and invoking with module function and object', () => {
+//   test('recursive function and invoking with module function and object', async () => {
 //     const code = `
 //     import { stack, heart, show, make_cross } from "rune";
 //     function repeat(n, f, i) {
@@ -1540,8 +1526,8 @@ describe(`Evaluation of empty code and imports`, () => {
 //     }
 //     show(repeat(1, make_cross, heart));
 //     `
-//     const program = parse(code, mockContext())!
-//     const steps = getEvaluationSteps(program, mockContext(), 1000)
+//     const steps = await testEvalSteps(code)
+//
 //     expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
 //       "function repeat(n, f, i) {
 //         return n === 0 ? i : repeat(n - 1, f, f(i));
@@ -1596,13 +1582,13 @@ describe(`Evaluation of empty code and imports`, () => {
 //     `)
 //   })
 
-//   test('display unnamed object', () => {
+//   test('display unnamed object', async () => {
 //     const code = `
 //     import {play, sine_sound} from "sound";
 //     play(sine_sound(440, 5));
 //     `
-//     const program = parse(code, mockContext())!
-//     const steps = getEvaluationSteps(program, mockContext(), 1000)
+//     const steps = await testEvalSteps(code)
+//
 //     expect(steps.map(x => codify(x[0])).join('\n')).toMatchInlineSnapshot(`
 //       "play(sine_sound(440, 5));
 
diff --git a/src/transpiler/__tests__/modules.ts b/src/transpiler/__tests__/modules.ts
index c7b8c1ae..0bba8c24 100644
--- a/src/transpiler/__tests__/modules.ts
+++ b/src/transpiler/__tests__/modules.ts
@@ -1,51 +1,29 @@
 import type { Identifier, Literal, MemberExpression, VariableDeclaration } from 'estree'
-import type { FunctionLike, MockedFunction } from 'jest-mock'
 
+import { runInContext } from '../..'
 import { mockContext } from '../../mocks/context'
 import { UndefinedImportError } from '../../modules/errors'
-import { memoizedGetModuleFile } from '../../modules/moduleLoader'
 import { parse } from '../../parser/parser'
-import { Chapter } from '../../types'
+import { Chapter, Value } from '../../types'
 import { stripIndent } from '../../utils/formatters'
 import { transformImportDeclarations, transpile } from '../transpiler'
 
-jest.mock('../../modules/moduleLoader', () => ({
-  ...jest.requireActual('../../modules/moduleLoader'),
-  memoizedGetModuleFile: jest.fn(),
-  memoizedGetModuleManifest: jest.fn().mockReturnValue({
-    one_module: {
-      tabs: []
-    },
-    another_module: {
-      tabs: []
-    }
-  }),
-  memoizedloadModuleDocs: jest.fn().mockReturnValue({
-    foo: 'foo',
-    bar: 'bar'
-  })
-}))
-
-const asMock = <T extends FunctionLike>(func: T) => func as MockedFunction<T>
-const mockedModuleFile = asMock(memoizedGetModuleFile)
-
-test('Transform import declarations into variable declarations', () => {
-  mockedModuleFile.mockImplementation((name, type) => {
-    if (type === 'json') {
-      return name === 'one_module' ? "{ foo: 'foo' }" : "{ bar: 'bar' }"
-    } else {
-      return 'undefined'
-    }
-  })
+jest.mock('../../modules/moduleLoaderAsync')
+jest.mock('../../modules/moduleLoader')
 
+test('Transform import declarations into variable declarations', async () => {
   const code = stripIndent`
-    import { foo } from "test/one_module";
-    import { bar } from "test/another_module";
+    import { foo } from "one_module";
+    import { bar } from "another_module";
     foo(bar);
   `
   const context = mockContext(Chapter.SOURCE_4)
   const program = parse(code, context)!
-  const [, importNodes] = transformImportDeclarations(program, new Set<string>(), false)
+  const [, importNodes] = await transformImportDeclarations(program, new Set<string>(), {
+    wrapSourceModules: true,
+    loadTabs: false,
+    checkImports: true
+  })
 
   expect(importNodes[0].type).toBe('VariableDeclaration')
   expect((importNodes[0].declarations[0].id as Identifier).name).toEqual('foo')
@@ -54,28 +32,24 @@ test('Transform import declarations into variable declarations', () => {
   expect((importNodes[1].declarations[0].id as Identifier).name).toEqual('bar')
 })
 
-test('Transpiler accounts for user variable names when transforming import statements', () => {
-  mockedModuleFile.mockImplementation((name, type) => {
-    if (type === 'json') {
-      return name === 'one_module' ? "{ foo: 'foo' }" : "{ bar: 'bar' }"
-    } else {
-      return 'undefined'
-    }
-  })
-
+test('Transpiler accounts for user variable names when transforming import statements', async () => {
   const code = stripIndent`
-    import { foo } from "test/one_module";
-    import { bar as __MODULE__2 } from "test/another_module";
+    import { foo } from "one_module";
+    import { bar as __MODULE__2 } from "another_module";
     const __MODULE__ = 'test0';
     const __MODULE__0 = 'test1';
     foo(bar);
   `
   const context = mockContext(4)
   const program = parse(code, context)!
-  const [, importNodes, [varDecl0, varDecl1]] = transformImportDeclarations(
+  const [, importNodes, [varDecl0, varDecl1]] = await transformImportDeclarations(
     program,
     new Set<string>(['__MODULE__', '__MODULE__0']),
-    false
+    {
+      loadTabs: false,
+      wrapSourceModules: false,
+      checkImports: false
+    }
   )
 
   expect(importNodes[0].type).toBe('VariableDeclaration')
@@ -95,40 +69,31 @@ test('Transpiler accounts for user variable names when transforming import state
   ).toEqual('__MODULE__3')
 })
 
-test('checkForUndefinedVariables accounts for import statements', () => {
-  mockedModuleFile.mockImplementation((name, type) => {
-    if (type === 'json') {
-      return "{ hello: 'hello' }"
-    } else {
-      return 'undefined'
-    }
-  })
-
+test('Module loading functionality', async () => {
   const code = stripIndent`
-    import { foo } from "one_module";
-    foo;
+    import { foo } from 'one_module';
+    foo();
   `
   const context = mockContext(Chapter.SOURCE_4)
-  const program = parse(code, context)!
-  transpile(program, context, false)
-})
+  const result = await runInContext(code, context)
+  expect(result.status).toEqual('finished')
 
-test('importing undefined variables should throw errors', () => {
-  mockedModuleFile.mockImplementation((name, type) => {
-    if (type === 'json') {
-      return '{}'
-    } else {
-      return 'undefined'
-    }
-  })
+  expect((result as Value).value).toEqual('foo')
+})
 
+test('importing undefined variables should throw errors', async () => {
   const code = stripIndent`
     import { hello } from 'one_module';
   `
   const context = mockContext(Chapter.SOURCE_4)
   const program = parse(code, context)!
   try {
-    transpile(program, context, false)
+    await transpile(
+      program,
+      context,
+      { checkImports: true, loadTabs: false, wrapSourceModules: false },
+      false
+    )
   } catch (error) {
     expect(error).toBeInstanceOf(UndefinedImportError)
     expect((error as UndefinedImportError).symbol).toEqual('hello')
diff --git a/src/transpiler/__tests__/transpiled-code.ts b/src/transpiler/__tests__/transpiled-code.ts
index 104ff6cb..54d5ba3f 100644
--- a/src/transpiler/__tests__/transpiled-code.ts
+++ b/src/transpiler/__tests__/transpiled-code.ts
@@ -9,10 +9,10 @@ import { transpile } from '../transpiler'
  *  code being tested from being transformed into eval.
  *  Check for variables being stored back by looking at all the tests.
  */
-test('builtins do get prepended', () => {
+test('builtins do get prepended', async () => {
   const code = '"ensure_builtins";'
   const context = mockContext(Chapter.SOURCE_4)
-  const transpiled = transpile(parse(code, context)!, context).transpiled
+  const { transpiled } = await transpile(parse(code, context)!, context)
   // replace native[<number>] as they may be inconsistent
   const replacedNative = transpiled.replace(/native\[\d+]/g, 'native')
   // replace the line hiding globals as they may differ between environments
@@ -20,7 +20,7 @@ test('builtins do get prepended', () => {
   expect({ code, transpiled: replacedGlobalsLine }).toMatchSnapshot()
 })
 
-test('Ensure no name clashes', () => {
+test('Ensure no name clashes', async () => {
   const code = stripIndent`
     const boolOrErr = 1;
     boolOrErr[123] = 1;
@@ -32,7 +32,7 @@ test('Ensure no name clashes', () => {
     const native = 123;
   `
   const context = mockContext(Chapter.SOURCE_4)
-  const transpiled = transpile(parse(code, context)!, context).transpiled
+  const { transpiled } = await transpile(parse(code, context)!, context)
   const replacedNative = transpiled.replace(/native0\[\d+]/g, 'native')
   const replacedGlobalsLine = replacedNative.replace(/\n\(\(.*\)/, '\n(( <globals redacted> )')
   expect(replacedGlobalsLine).toMatchSnapshot()
diff --git a/src/utils/__tests__/arrayMap.ts b/src/utils/__tests__/arrayMap.ts
new file mode 100644
index 00000000..b9a423f1
--- /dev/null
+++ b/src/utils/__tests__/arrayMap.ts
@@ -0,0 +1,27 @@
+import { arrayMapFrom } from '../arrayMap'
+
+test('arrayMapFrom', () => {
+  const arrMap = arrayMapFrom([
+    [1, [1, 2, 3]],
+    [2, [2, 4, 6]]
+  ])
+
+  expect(arrMap.get(1)).toEqual([1, 2, 3])
+  expect(arrMap.get(2)).toEqual([2, 4, 6])
+})
+
+test('mapAsync', async () => {
+  const arrMap = arrayMapFrom([
+    [1, [1, 2, 3]],
+    [2, [2, 4, 6]]
+  ])
+
+  const mapper = jest.fn((k: number, entry: number[]) =>
+    Promise.resolve([k, entry.map(each => each * 2)] as [number, number[]])
+  )
+  const newMap = await arrMap.mapAsync(mapper)
+
+  expect(newMap.get(1)).toEqual([2, 4, 6])
+  expect(newMap.get(2)).toEqual([4, 8, 12])
+  expect(mapper).toHaveBeenCalledTimes(2)
+})
diff --git a/src/utils/__tests__/misc.ts b/src/utils/__tests__/misc.ts
new file mode 100644
index 00000000..2055c136
--- /dev/null
+++ b/src/utils/__tests__/misc.ts
@@ -0,0 +1,28 @@
+import { PromiseTimeoutError, timeoutPromise } from '../misc'
+
+describe('test timeoutPromise', () => {
+  const timedResolvedPromise = (duration: number) =>
+    new Promise<void>(resolve => setTimeout(resolve, duration))
+  const timedRejectedPromise = (duration: number) =>
+    new Promise<void>((_, reject) => setTimeout(() => reject(-1), duration))
+
+  test('Regular timeouts', async () => {
+    const promise = timeoutPromise(timedResolvedPromise(100), 200)
+    return expect(promise).resolves.toBeUndefined()
+  })
+
+  test('Rejected promise', () => {
+    const promise = timeoutPromise(timedRejectedPromise(100), 200)
+    return expect(promise).rejects.toEqual(-1)
+  })
+
+  test('Timeout on resolved promise', () => {
+    const promise = timeoutPromise(timedResolvedPromise(200), 100)
+    return expect(promise).rejects.toBeInstanceOf(PromiseTimeoutError)
+  })
+
+  test('Timeout on rejected promise', () => {
+    const promise = timeoutPromise(timedRejectedPromise(200), 100)
+    return expect(promise).rejects.toBeInstanceOf(PromiseTimeoutError)
+  })
+})
diff --git a/src/utils/testing.ts b/src/utils/testing.ts
index e3dd710d..8b1d0be1 100644
--- a/src/utils/testing.ts
+++ b/src/utils/testing.ts
@@ -5,6 +5,7 @@ import { transpileToGPU } from '../gpu/gpu'
 import { parseError, Result, runInContext } from '../index'
 import { transpileToLazy } from '../lazy/lazy'
 import { mockContext } from '../mocks/context'
+import { ImportTransformOptions } from '../modules/moduleTypes'
 import { parse } from '../parser/parser'
 import { transpile } from '../transpiler/transpiler'
 import { Chapter, Context, CustomBuiltIns, SourceError, Value, Variant } from '../types'
@@ -48,6 +49,7 @@ interface TestOptions {
   native?: boolean
   showTranspiledCode?: boolean
   showErrorJSON?: boolean
+  importOptions?: Partial<ImportTransformOptions>
 }
 
 export function createTestContext({
@@ -141,7 +143,7 @@ async function testInContext(code: string, options: TestOptions): Promise<TestRe
           break
       }
       try {
-        transpiled = transpile(parsed, nativeTestContext, true).transpiled
+        ;({ transpiled } = await transpile(parsed, nativeTestContext, options.importOptions))
         // replace declaration of builtins since they're repetitive
         transpiled = transpiled.replace(/\n  const \w+ = nativeStorage\..*;/g, '')
         transpiled = transpiled.replace(/\n\s*const \w+ = .*\.operators\..*;/g, '')