From a86fded1d30faee95e59e11f461f5f1fdcdf7e27 Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Tue, 30 Jun 2026 16:54:10 +0800 Subject: [PATCH 1/5] feat: add module loader packages --- .yarnrc.yml | 1 - src/common/module-loader/manifest.json | 3 + src/common/module-loader/package.json | 33 +++++++++ src/common/module-loader/rollup.config.mjs | 21 ++++++ src/common/module-loader/src/index.ts | 24 ++++++ src/common/module-loader/tsconfig.json | 10 +++ src/runner/module-loader/manifest.json | 3 + src/runner/module-loader/package.json | 40 ++++++++++ src/runner/module-loader/rollup.config.mjs | 19 +++++ src/runner/module-loader/src/index.ts | 59 +++++++++++++++ src/runner/module-loader/tsconfig.json | 10 +++ src/web/module-loader/manifest.json | 3 + src/web/module-loader/package.json | 43 +++++++++++ src/web/module-loader/rollup.config.mjs | 22 ++++++ src/web/module-loader/src/index.ts | 86 ++++++++++++++++++++++ src/web/module-loader/tsconfig.json | 10 +++ yarn.lock | 54 ++++++++++++++ 17 files changed, 440 insertions(+), 1 deletion(-) delete mode 100644 .yarnrc.yml create mode 100644 src/common/module-loader/manifest.json create mode 100644 src/common/module-loader/package.json create mode 100644 src/common/module-loader/rollup.config.mjs create mode 100644 src/common/module-loader/src/index.ts create mode 100644 src/common/module-loader/tsconfig.json create mode 100644 src/runner/module-loader/manifest.json create mode 100644 src/runner/module-loader/package.json create mode 100644 src/runner/module-loader/rollup.config.mjs create mode 100644 src/runner/module-loader/src/index.ts create mode 100644 src/runner/module-loader/tsconfig.json create mode 100644 src/web/module-loader/manifest.json create mode 100644 src/web/module-loader/package.json create mode 100644 src/web/module-loader/rollup.config.mjs create mode 100644 src/web/module-loader/src/index.ts create mode 100644 src/web/module-loader/tsconfig.json diff --git a/.yarnrc.yml b/.yarnrc.yml deleted file mode 100644 index 3186f3f..0000000 --- a/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/src/common/module-loader/manifest.json b/src/common/module-loader/manifest.json new file mode 100644 index 0000000..e263f73 --- /dev/null +++ b/src/common/module-loader/manifest.json @@ -0,0 +1,3 @@ +{ + "type": "installable" +} diff --git a/src/common/module-loader/package.json b/src/common/module-loader/package.json new file mode 100644 index 0000000..4790468 --- /dev/null +++ b/src/common/module-loader/package.json @@ -0,0 +1,33 @@ +{ + "name": "@sourceacademy/common-module-loader", + "version": "0.0.1", + "packageManager": "yarn@4.6.0", + "description": "The common types for loading modules in Source Academy", + "scripts": { + "build": "rollup -c", + "prepack": "yarn build" + }, + "license": "ISC", + "files": [ + "dist" + ], + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", + "rollup": "^4.60.2", + "tslib": "^2.8.1", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/src/common/module-loader/rollup.config.mjs b/src/common/module-loader/rollup.config.mjs new file mode 100644 index 0000000..238a07c --- /dev/null +++ b/src/common/module-loader/rollup.config.mjs @@ -0,0 +1,21 @@ +import nodeResolve from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; +import typescript from "@rollup/plugin-typescript"; + +/** + * @type {import('rollup').RollupOptions} + */ +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.cjs", + format: "cjs", + }, + { + file: "dist/index.mjs", + format: "esm", + }, + ], + plugins: [nodeResolve(), typescript(), terser()], +}; diff --git a/src/common/module-loader/src/index.ts b/src/common/module-loader/src/index.ts new file mode 100644 index 0000000..5f28914 --- /dev/null +++ b/src/common/module-loader/src/index.ts @@ -0,0 +1,24 @@ +export const WEB_ID = "__web_module_loader"; +export const RUNNER_ID = "__runner_module_loader"; + +export const CHANNEL_ID = "module_config"; + +export enum ModuleLoaderMessageType { + REQUEST_MODULE = "request_module", + MODULE_RESPONSE = "module_response", + MODULE_ERROR = "module_error" +} + +export type ModuleLoaderMessage = + |{ + type: ModuleLoaderMessageType.REQUEST_MODULE; + moduleName: string; +} | { + type: ModuleLoaderMessageType.MODULE_RESPONSE; + moduleURL: string; + tabs: string[]; +} | { + type: ModuleLoaderMessageType.MODULE_ERROR; + error: string; +} + diff --git a/src/common/module-loader/tsconfig.json b/src/common/module-loader/tsconfig.json new file mode 100644 index 0000000..c7482f9 --- /dev/null +++ b/src/common/module-loader/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["./dist"], + "include": ["./src"], + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/src/runner/module-loader/manifest.json b/src/runner/module-loader/manifest.json new file mode 100644 index 0000000..e263f73 --- /dev/null +++ b/src/runner/module-loader/manifest.json @@ -0,0 +1,3 @@ +{ + "type": "installable" +} diff --git a/src/runner/module-loader/package.json b/src/runner/module-loader/package.json new file mode 100644 index 0000000..2db5301 --- /dev/null +++ b/src/runner/module-loader/package.json @@ -0,0 +1,40 @@ +{ + "name": "@sourceacademy/runner-module-loader", + "version": "0.0.1", + "packageManager": "yarn@4.12.0", + "description": "The runner plugin for the module loader in Source Academy", + "scripts": { + "build": "rollup -c", + "prepack": "yarn build" + }, + "license": "ISC", + "files": [ + "dist" + ], + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "peerDependencies": { + "@sourceacademy/conductor": ">=0.3.0" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", + "@sourceacademy/common-module-loader": "workspace:*", + "@sourceacademy/conductor": ">=0.3.0", + "@sourceacademy/runner-remote-execution": "workspace:*", + "@vitest/coverage-istanbul": "^4.1.9", + "rollup": "^4.60.2", + "tslib": "^2.8.1", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/src/runner/module-loader/rollup.config.mjs b/src/runner/module-loader/rollup.config.mjs new file mode 100644 index 0000000..3f56b30 --- /dev/null +++ b/src/runner/module-loader/rollup.config.mjs @@ -0,0 +1,19 @@ +import nodeResolve from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; +import typescript from "@rollup/plugin-typescript"; + +/** @type {import('rollup').RollupOptions} */ +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.cjs", + format: "cjs", + }, + { + file: "dist/index.mjs", + format: "esm", + }, + ], + plugins: [nodeResolve(), typescript(), terser()], +}; diff --git a/src/runner/module-loader/src/index.ts b/src/runner/module-loader/src/index.ts new file mode 100644 index 0000000..aa4d66a --- /dev/null +++ b/src/runner/module-loader/src/index.ts @@ -0,0 +1,59 @@ +import { CHANNEL_ID, ModuleLoaderMessageType, RUNNER_ID, type ModuleLoaderMessage } from "@sourceacademy/common-module-loader"; +import { + checkIsPluginClass, + type IChannel, + type IConduit, + type IPlugin, +} from "@sourceacademy/conductor/conduit"; +import type { IModulePlugin } from "@sourceacademy/conductor/module"; +import type { IRunnerPlugin } from "@sourceacademy/conductor/runner"; + +export class ModuleLoaderRunnerPlugin implements IPlugin { + readonly id: string = RUNNER_ID; + static readonly channelAttach = [CHANNEL_ID]; + private readonly __moduleRequestChannel: IChannel; + private readonly __conductor: IRunnerPlugin; + static instance: ModuleLoaderRunnerPlugin | null = null; + constructor( + conduit: IConduit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [moduleRequestChannel]: IChannel[], + conductor: IRunnerPlugin + ) { + this.__moduleRequestChannel = moduleRequestChannel; + this.__conductor = conductor; + ModuleLoaderRunnerPlugin.instance = this; + } + async requestModule(moduleName: string): Promise { + return new Promise((resolve, reject) => { + const handleResponse = async (msg: ModuleLoaderMessage) => { + this.__moduleRequestChannel.unsubscribe(handleResponse); + if (msg.type === ModuleLoaderMessageType.MODULE_RESPONSE) { + const plugin = await import(msg.moduleURL) + .then(module => { + return module.default; + }) + const pluginObj = this.__conductor.registerPlugin(plugin, { + tabs: msg.tabs, + loadTab: (tabName: string) => { + if (!msg.tabs.includes(tabName)) { + throw new Error(`Tab ${tabName} not found in module ${moduleName}`); + } + this.__conductor.hostLoadPlugin(tabName); + } + }) as IModulePlugin; + await pluginObj?.initialise(); + resolve(pluginObj); + } else if (msg.type === ModuleLoaderMessageType.MODULE_ERROR) { + reject(new Error(msg.error)); + } + }; + this.__moduleRequestChannel.subscribe(handleResponse); + this.__moduleRequestChannel.send({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName, + }); + }) + } +} +checkIsPluginClass(ModuleLoaderRunnerPlugin); diff --git a/src/runner/module-loader/tsconfig.json b/src/runner/module-loader/tsconfig.json new file mode 100644 index 0000000..c7482f9 --- /dev/null +++ b/src/runner/module-loader/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["./dist"], + "include": ["./src"], + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/src/web/module-loader/manifest.json b/src/web/module-loader/manifest.json new file mode 100644 index 0000000..e263f73 --- /dev/null +++ b/src/web/module-loader/manifest.json @@ -0,0 +1,3 @@ +{ + "type": "installable" +} diff --git a/src/web/module-loader/package.json b/src/web/module-loader/package.json new file mode 100644 index 0000000..849d7b6 --- /dev/null +++ b/src/web/module-loader/package.json @@ -0,0 +1,43 @@ +{ + "name": "@sourceacademy/web-module-loader", + "version": "0.0.2", + "packageManager": "yarn@4.6.0", + "description": "The web plugin for the module loader in Source Academy", + "scripts": { + "build": "rollup -c", + "prepack": "yarn build" + }, + "license": "ISC", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "peerDependencies": { + "@sourceacademy/conductor": ">=0.3.0" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^29.0.3", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", + "@sourceacademy/common-module-loader": "workspace:*", + "@sourceacademy/common-tabs": "workspace:^", + "@sourceacademy/conductor": ">=0.3.0", + "rollup": "^4.60.2", + "tslib": "^2.8.1", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/src/web/module-loader/rollup.config.mjs b/src/web/module-loader/rollup.config.mjs new file mode 100644 index 0000000..06a3173 --- /dev/null +++ b/src/web/module-loader/rollup.config.mjs @@ -0,0 +1,22 @@ +import nodeResolve from "@rollup/plugin-node-resolve"; +import terser from "@rollup/plugin-terser"; +import typescript from "@rollup/plugin-typescript"; +import commonjs from "@rollup/plugin-commonjs"; +/** + * @type {import('rollup').RollupOptions} + */ +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.cjs", + format: "cjs", + }, + { + file: "dist/index.mjs", + format: "esm", + }, + ], + plugins: [nodeResolve(), commonjs(), typescript(), terser()], + external: ["react", "react-dom", "react/jsx-runtime"], +}; diff --git a/src/web/module-loader/src/index.ts b/src/web/module-loader/src/index.ts new file mode 100644 index 0000000..71abf6c --- /dev/null +++ b/src/web/module-loader/src/index.ts @@ -0,0 +1,86 @@ +import { CHANNEL_ID, ModuleLoaderMessageType, WEB_ID, type ModuleLoaderMessage } from "@sourceacademy/common-module-loader"; +import { + type IPlugin, + type IChannel, + type IConduit, + checkIsPluginClass, + type PluginClass, +} from "@sourceacademy/conductor/conduit"; + + +type ModuleDirectoryBundle = { + tabs: string[] +}; +type ModuleDirectory = Record; +export class ModuleLoaderWebPlugin implements IPlugin { + readonly id: string = WEB_ID; + static readonly channelAttach = [CHANNEL_ID]; + private readonly __moduleRequestChannel: IChannel; + + static instance: ModuleLoaderWebPlugin | null = null; + private moduleDirectoryURL: string | null = null; + private moduleDirectory: ModuleDirectory | null = null; + + constructor( + _conduit: IConduit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [moduleRequestChannel]: IChannel[] + ) { + this.__moduleRequestChannel = moduleRequestChannel; + ModuleLoaderWebPlugin.instance = this; + this.__moduleRequestChannel.subscribe(message => { + if (message.type !== ModuleLoaderMessageType.REQUEST_MODULE) return; + if (this.moduleDirectory === null || this.moduleDirectoryURL === null) { + return this.__moduleRequestChannel.send({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: "Module directory not loaded yet", + }); + } + if (!(message.moduleName in this.moduleDirectory)) { + return this.__moduleRequestChannel.send({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: `Module not found: ${message.moduleName}`, + }); + } + if (!/[a-zA-Z0-9_-]+/.test(message.moduleName)) { + return this.__moduleRequestChannel.send({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: `Invalid module name: ${message.moduleName}`, + }); + } + const moduleBaseUrl = this.moduleDirectoryURL.slice(0, this.moduleDirectoryURL.lastIndexOf("/") + 1); + return this.__moduleRequestChannel.send({ + type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleURL: moduleBaseUrl + "bundles/" + message.moduleName + ".js", + tabs: this.moduleDirectory[message.moduleName].tabs + }) + }) + } + + onModuleDirectoryURLChange(newURL: string): void { + if (newURL === this.moduleDirectoryURL) { + return; + } + this.moduleDirectoryURL = newURL; + fetch(newURL) + .then(response => response.json()) + .then(data => { + this.moduleDirectory = data; + }); + } + + getModuleTabLocation(tabName: string): string | null { + if (!this.moduleDirectory || !this.moduleDirectoryURL) { + return null; + } + for (const moduleName in this.moduleDirectory) { + if (this.moduleDirectory[moduleName].tabs.includes(tabName)) { + const moduleBaseUrl = this.moduleDirectoryURL.slice(0, this.moduleDirectoryURL.lastIndexOf("/") + 1); + return moduleBaseUrl + "tabs/" + tabName + ".js"; + } + } + return null; + } + +} +checkIsPluginClass(ModuleLoaderWebPlugin); diff --git a/src/web/module-loader/tsconfig.json b/src/web/module-loader/tsconfig.json new file mode 100644 index 0000000..5d6b09f --- /dev/null +++ b/src/web/module-loader/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "exclude": ["./dist"], + "include": ["./src"], + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + } +} diff --git a/yarn.lock b/yarn.lock index 00fcdd0..304c5bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1961,6 +1961,20 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/common-module-loader@workspace:*, @sourceacademy/common-module-loader@workspace:src/common/module-loader": + version: 0.0.0-use.local + resolution: "@sourceacademy/common-module-loader@workspace:src/common/module-loader" + dependencies: + "@rollup/plugin-node-resolve": "npm:^16.0.3" + "@rollup/plugin-terser": "npm:^1.0.0" + "@rollup/plugin-typescript": "npm:^12.3.0" + rollup: "npm:^4.60.2" + tslib: "npm:^2.8.1" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.9" + languageName: unknown + linkType: soft + "@sourceacademy/common-stepper@workspace:*, @sourceacademy/common-stepper@workspace:src/common/stepper": version: 0.0.0-use.local resolution: "@sourceacademy/common-stepper@workspace:src/common/stepper" @@ -2050,6 +2064,26 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/runner-module-loader@workspace:src/runner/module-loader": + version: 0.0.0-use.local + resolution: "@sourceacademy/runner-module-loader@workspace:src/runner/module-loader" + dependencies: + "@rollup/plugin-node-resolve": "npm:^16.0.3" + "@rollup/plugin-terser": "npm:^1.0.0" + "@rollup/plugin-typescript": "npm:^12.3.0" + "@sourceacademy/common-module-loader": "workspace:*" + "@sourceacademy/conductor": "npm:>=0.3.0" + "@sourceacademy/runner-remote-execution": "workspace:*" + "@vitest/coverage-istanbul": "npm:^4.1.9" + rollup: "npm:^4.60.2" + tslib: "npm:^2.8.1" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.9" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + languageName: unknown + linkType: soft + "@sourceacademy/runner-remote-execution@workspace:*, @sourceacademy/runner-remote-execution@workspace:src/runner/remoteExecution": version: 0.0.0-use.local resolution: "@sourceacademy/runner-remote-execution@workspace:src/runner/remoteExecution" @@ -2138,6 +2172,26 @@ __metadata: languageName: unknown linkType: soft +"@sourceacademy/web-module-loader@workspace:src/web/module-loader": + version: 0.0.0-use.local + resolution: "@sourceacademy/web-module-loader@workspace:src/web/module-loader" + dependencies: + "@rollup/plugin-commonjs": "npm:^29.0.3" + "@rollup/plugin-node-resolve": "npm:^16.0.3" + "@rollup/plugin-terser": "npm:^1.0.0" + "@rollup/plugin-typescript": "npm:^12.3.0" + "@sourceacademy/common-module-loader": "workspace:*" + "@sourceacademy/common-tabs": "workspace:^" + "@sourceacademy/conductor": "npm:>=0.3.0" + rollup: "npm:^4.60.2" + tslib: "npm:^2.8.1" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.9" + peerDependencies: + "@sourceacademy/conductor": ">=0.3.0" + languageName: unknown + linkType: soft + "@sourceacademy/web-stepper@workspace:src/web/stepper": version: 0.0.0-use.local resolution: "@sourceacademy/web-stepper@workspace:src/web/stepper" From 4ee5861582abf2f5c168cdde161a465a94ef0cff Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Thu, 2 Jul 2026 02:50:50 +0530 Subject: [PATCH 2/5] fix: if the module dir URL is changed to the same URL, then only skip if the directory is loaded --- src/common/module-loader/src/index.ts | 29 ++++++++++++----------- src/runner/module-loader/src/index.ts | 27 +++++++++++++-------- src/web/module-loader/src/index.ts | 34 +++++++++++++++++---------- 3 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/common/module-loader/src/index.ts b/src/common/module-loader/src/index.ts index 5f28914..f4a0e75 100644 --- a/src/common/module-loader/src/index.ts +++ b/src/common/module-loader/src/index.ts @@ -6,19 +6,20 @@ export const CHANNEL_ID = "module_config"; export enum ModuleLoaderMessageType { REQUEST_MODULE = "request_module", MODULE_RESPONSE = "module_response", - MODULE_ERROR = "module_error" -} - -export type ModuleLoaderMessage = - |{ - type: ModuleLoaderMessageType.REQUEST_MODULE; - moduleName: string; -} | { - type: ModuleLoaderMessageType.MODULE_RESPONSE; - moduleURL: string; - tabs: string[]; -} | { - type: ModuleLoaderMessageType.MODULE_ERROR; - error: string; + MODULE_ERROR = "module_error", } +export type ModuleLoaderMessage = + | { + type: ModuleLoaderMessageType.REQUEST_MODULE; + moduleName: string; + } + | { + type: ModuleLoaderMessageType.MODULE_RESPONSE; + moduleURL: string; + tabs: string[]; + } + | { + type: ModuleLoaderMessageType.MODULE_ERROR; + error: string; + }; diff --git a/src/runner/module-loader/src/index.ts b/src/runner/module-loader/src/index.ts index aa4d66a..859583a 100644 --- a/src/runner/module-loader/src/index.ts +++ b/src/runner/module-loader/src/index.ts @@ -1,4 +1,9 @@ -import { CHANNEL_ID, ModuleLoaderMessageType, RUNNER_ID, type ModuleLoaderMessage } from "@sourceacademy/common-module-loader"; +import { + CHANNEL_ID, + ModuleLoaderMessageType, + RUNNER_ID, + type ModuleLoaderMessage, +} from "@sourceacademy/common-module-loader"; import { checkIsPluginClass, type IChannel, @@ -6,22 +11,25 @@ import { type IPlugin, } from "@sourceacademy/conductor/conduit"; import type { IModulePlugin } from "@sourceacademy/conductor/module"; -import type { IRunnerPlugin } from "@sourceacademy/conductor/runner"; +import type { IInterfacableEvaluator, IRunnerPlugin } from "@sourceacademy/conductor/runner"; export class ModuleLoaderRunnerPlugin implements IPlugin { readonly id: string = RUNNER_ID; static readonly channelAttach = [CHANNEL_ID]; private readonly __moduleRequestChannel: IChannel; private readonly __conductor: IRunnerPlugin; + private readonly __evaluator: IInterfacableEvaluator; static instance: ModuleLoaderRunnerPlugin | null = null; constructor( conduit: IConduit, // eslint-disable-next-line @typescript-eslint/no-explicit-any [moduleRequestChannel]: IChannel[], - conductor: IRunnerPlugin + conductor: IRunnerPlugin, + evaluator: IInterfacableEvaluator, ) { this.__moduleRequestChannel = moduleRequestChannel; this.__conductor = conductor; + this.__evaluator = evaluator; ModuleLoaderRunnerPlugin.instance = this; } async requestModule(moduleName: string): Promise { @@ -29,18 +37,17 @@ export class ModuleLoaderRunnerPlugin implements IPlugin { const handleResponse = async (msg: ModuleLoaderMessage) => { this.__moduleRequestChannel.unsubscribe(handleResponse); if (msg.type === ModuleLoaderMessageType.MODULE_RESPONSE) { - const plugin = await import(msg.moduleURL) - .then(module => { - return module.default; - }) - const pluginObj = this.__conductor.registerPlugin(plugin, { + const plugin = await import(msg.moduleURL).then(module => { + return module.default(() => {}).default; + }); + const pluginObj = this.__conductor.registerPlugin(plugin, this.__evaluator, { tabs: msg.tabs, loadTab: (tabName: string) => { if (!msg.tabs.includes(tabName)) { throw new Error(`Tab ${tabName} not found in module ${moduleName}`); } this.__conductor.hostLoadPlugin(tabName); - } + }, }) as IModulePlugin; await pluginObj?.initialise(); resolve(pluginObj); @@ -53,7 +60,7 @@ export class ModuleLoaderRunnerPlugin implements IPlugin { type: ModuleLoaderMessageType.REQUEST_MODULE, moduleName, }); - }) + }); } } checkIsPluginClass(ModuleLoaderRunnerPlugin); diff --git a/src/web/module-loader/src/index.ts b/src/web/module-loader/src/index.ts index 71abf6c..7adca36 100644 --- a/src/web/module-loader/src/index.ts +++ b/src/web/module-loader/src/index.ts @@ -1,22 +1,25 @@ -import { CHANNEL_ID, ModuleLoaderMessageType, WEB_ID, type ModuleLoaderMessage } from "@sourceacademy/common-module-loader"; +import { + CHANNEL_ID, + ModuleLoaderMessageType, + WEB_ID, + type ModuleLoaderMessage, +} from "@sourceacademy/common-module-loader"; import { type IPlugin, type IChannel, type IConduit, checkIsPluginClass, - type PluginClass, } from "@sourceacademy/conductor/conduit"; - type ModuleDirectoryBundle = { - tabs: string[] + tabs: string[]; }; type ModuleDirectory = Record; export class ModuleLoaderWebPlugin implements IPlugin { readonly id: string = WEB_ID; static readonly channelAttach = [CHANNEL_ID]; private readonly __moduleRequestChannel: IChannel; - + static instance: ModuleLoaderWebPlugin | null = null; private moduleDirectoryURL: string | null = null; private moduleDirectory: ModuleDirectory | null = null; @@ -24,7 +27,7 @@ export class ModuleLoaderWebPlugin implements IPlugin { constructor( _conduit: IConduit, // eslint-disable-next-line @typescript-eslint/no-explicit-any - [moduleRequestChannel]: IChannel[] + [moduleRequestChannel]: IChannel[], ) { this.__moduleRequestChannel = moduleRequestChannel; ModuleLoaderWebPlugin.instance = this; @@ -48,17 +51,20 @@ export class ModuleLoaderWebPlugin implements IPlugin { error: `Invalid module name: ${message.moduleName}`, }); } - const moduleBaseUrl = this.moduleDirectoryURL.slice(0, this.moduleDirectoryURL.lastIndexOf("/") + 1); + const moduleBaseUrl = this.moduleDirectoryURL.slice( + 0, + this.moduleDirectoryURL.lastIndexOf("/") + 1, + ); return this.__moduleRequestChannel.send({ type: ModuleLoaderMessageType.MODULE_RESPONSE, moduleURL: moduleBaseUrl + "bundles/" + message.moduleName + ".js", - tabs: this.moduleDirectory[message.moduleName].tabs - }) - }) + tabs: this.moduleDirectory[message.moduleName].tabs, + }); + }); } onModuleDirectoryURLChange(newURL: string): void { - if (newURL === this.moduleDirectoryURL) { + if (newURL === this.moduleDirectoryURL && this.moduleDirectory) { return; } this.moduleDirectoryURL = newURL; @@ -75,12 +81,14 @@ export class ModuleLoaderWebPlugin implements IPlugin { } for (const moduleName in this.moduleDirectory) { if (this.moduleDirectory[moduleName].tabs.includes(tabName)) { - const moduleBaseUrl = this.moduleDirectoryURL.slice(0, this.moduleDirectoryURL.lastIndexOf("/") + 1); + const moduleBaseUrl = this.moduleDirectoryURL.slice( + 0, + this.moduleDirectoryURL.lastIndexOf("/") + 1, + ); return moduleBaseUrl + "tabs/" + tabName + ".js"; } } return null; } - } checkIsPluginClass(ModuleLoaderWebPlugin); From c9f39c00f84ee5a88f98bedd984523af809c2d92 Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Thu, 2 Jul 2026 03:10:33 +0530 Subject: [PATCH 3/5] chore: add documentation --- src/common/module-loader/README.md | 42 +++++++++++++++++++ src/runner/module-loader/README.md | 46 ++++++++++++++++++++ src/web/module-loader/README.md | 67 ++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/common/module-loader/README.md create mode 100644 src/runner/module-loader/README.md create mode 100644 src/web/module-loader/README.md diff --git a/src/common/module-loader/README.md b/src/common/module-loader/README.md new file mode 100644 index 0000000..0a82c95 --- /dev/null +++ b/src/common/module-loader/README.md @@ -0,0 +1,42 @@ +

Module Loader

+ +

A protocol to load Source Academy modules

+ +

+ + +

+ +## Features +- Protocol for loading Source Academy modules on the runner side, from a changeable module directory on the host side + +## Installation +```bash +yarn add @sourceacademy/common-module-loader +# OR +npm i @sourceacademy/common-module-loader +# OR +pnpm add @sourceacademy/common-module-loader +``` + +## Structure +This package ([`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader)) contains the shared constants and types required by both the runner-side and web-side plugin implementations. + +These include: +- The IDs and channel name the two plugins use to find each other (`RUNNER_ID`, `WEB_ID`, `CHANNEL_ID`) +- The message type (`ModuleLoaderMessage`) sent between the two plugins + +## Usage +Ideally, you should not need to use this package directly. Instead, use the runner-side plugin ([`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)) or the web-side plugin ([`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)). + +However, if you do need to use this package directly, you can import the constants and types as follows: + +```ts +import type { ModuleLoaderMessage } from '@sourceacademy/common-module-loader'; +... +``` + +## Further reading +- To load a module, use [`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader) +- To provide the module directory URL, use [`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader) +- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate diff --git a/src/runner/module-loader/README.md b/src/runner/module-loader/README.md new file mode 100644 index 0000000..015b87e --- /dev/null +++ b/src/runner/module-loader/README.md @@ -0,0 +1,46 @@ +

Module Loader

+ +

Runner-side plugin for loading Source Academy modules

+ +

+ + +

+ +## Features +The plugin provides an interface to load modules with a module ID + +## Installation +```bash +yarn add @sourceacademy/runner-module-loader +# OR +npm i @sourceacademy/runner-module-loader +# OR +pnpm add @sourceacademy/runner-module-loader +``` + +## Structure +This package ([`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader)) contains the `ModuleLoaderRunnerPlugin` class — a Conductor runner plugin that an evaluator can call to load modules. + +### API Reference +| Name | Description | +|------|-------------| +| `instance` | The singleton instance of the plugin. | +| `async requestModule(moduleName: string): Promise` | Request a module by name from the host plugin. Rejects with an error if the module is not found. | + +## Usage +After installation, import `ModuleLoaderRunnerPlugin` and register it with the Conductor evaluator. When evaluating `import` statements, call `requestModule` to load the module. + +```ts +... +const pluginObj = await ModuleLoaderRunnerPlugin.instance.requestModule(moduleName); +context.nativeStorage.loadedModules[moduleName] = Object.fromEntries( + pluginObj?.exports.map(t => [t.symbol, t]) || [], +); +... +``` + +## Further reading +- For the shared protocol types and IDs, see [`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader) +- For the host-side plugin that accepts the module directory URL, see [`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader) +- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate diff --git a/src/web/module-loader/README.md b/src/web/module-loader/README.md new file mode 100644 index 0000000..70d66cf --- /dev/null +++ b/src/web/module-loader/README.md @@ -0,0 +1,67 @@ +

Module Loader

+ +

Host-side plugin to load Source Academy modules

+ +

+ + +

+ +## Features +- Provides an interface to load modules with a module ID, as well as the location of tabs on the host-side +- Accepts a module directory URL from the host app + +## Installation +To install the package, use: +```bash +yarn add @sourceacademy/web-module-loader +# OR +npm i @sourceacademy/web-module-loader +# OR +pnpm add @sourceacademy/web-module-loader +``` + +## Structure +This package ([`@sourceacademy/web-module-loader`](https://github.com/source-academy/plugins/tree/main/src/web/module-loader)) contains the `ModuleLoaderWebPlugin` class — a Conductor host plugin that subscribes to a Conductor channel and delivers module information to the host app. + +### API Reference +| Name | Description | +|------|-------------| +| `instance` | The singleton instance of the plugin. | +| `onModuleDirectoryURLChange(newUrl: string): void` | To be called during module directory URL changes. | +| `getModuleTabLocation(tabName: string): string | null` | Returns the location of the specified module tab, or null if not found. | + +## Usage +During the host-side Conductor initialisation, register the class with the conduit. + +```ts +... +hostPlugin.registerPlugin(ModuleLoaderWebPlugin); +ModuleLoaderWebPlugin.instance.onModuleDirectoryURLChange(moduleDirectoryURL); +... +``` + +Then, on module directory changes, call `onModuleDirectoryURLChange` to update the module directory URL. + +```ts +export const flagDirectoryModulesUrl = createFeatureFlag( + 'directory.modules.url', + 'https://source-academy.github.io/modules-conductor/modules.json', + 'The URL where the module directory may be found.', + // eslint-disable-next-line require-yield + function* (url: string) { + ModuleLoaderWebPlugin.instance?.onModuleDirectoryURLChange(url); + }, +); +``` + +To receive the location of a module tab, call `getModuleTabLocation` with the tab name. + +```ts +const tabLocation = ModuleLoaderWebPlugin.instance.getModuleTabLocation(tabName); +``` + +## Further reading +- For the shared protocol types and IDs, see [`@sourceacademy/common-module-loader`](https://github.com/source-academy/plugins/tree/main/src/common/module-loader) +- For the runner-side plugin that sends snapshots, see [`@sourceacademy/runner-module-loader`](https://github.com/source-academy/plugins/tree/main/src/runner/module-loader) +- The [plugins wiki](https://github.com/source-academy/plugins/wiki) covers how Conductor plugins communicate From a55a9054e2ae6e8412ef75d59a2bde5c48707a33 Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Thu, 2 Jul 2026 03:21:47 +0530 Subject: [PATCH 4/5] chore: add tests --- .../src/__tests__/runner.test.ts | 180 ++++++++++++++++ .../module-loader/src/__tests__/web.test.ts | 196 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 src/runner/module-loader/src/__tests__/runner.test.ts create mode 100644 src/web/module-loader/src/__tests__/web.test.ts diff --git a/src/runner/module-loader/src/__tests__/runner.test.ts b/src/runner/module-loader/src/__tests__/runner.test.ts new file mode 100644 index 0000000..891fb43 --- /dev/null +++ b/src/runner/module-loader/src/__tests__/runner.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, test, vi, type Mock } from "vitest"; +import { ModuleLoaderRunnerPlugin } from ".."; +import { + CHANNEL_ID, + ModuleLoaderMessageType, + RUNNER_ID, + type ModuleLoaderMessage, +} from "@sourceacademy/common-module-loader"; +import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit"; +import type { IModulePlugin } from "@sourceacademy/conductor/module"; +import type { + IInterfacableEvaluator, + IRunnerPlugin, +} from "@sourceacademy/conductor/runner"; + +type ChannelSubscriber = (msg: ModuleLoaderMessage) => void | Promise; + +type TestChannel = IChannel & { + send: Mock<(msg: ModuleLoaderMessage) => void>; + subscribe: Mock<(handler: ChannelSubscriber) => void>; + unsubscribe: Mock<(handler: ChannelSubscriber) => void>; +}; + +const mockBundleURL = `data:text/javascript;charset=utf-8,${encodeURIComponent(` +class MockModulePlugin {} +export default () => ({ default: MockModulePlugin }); +`)}`; + +const makeChannel = ( + getResponse?: (msg: ModuleLoaderMessage) => ModuleLoaderMessage | undefined, +): TestChannel => { + let subscriber: ChannelSubscriber | undefined; + const send = vi.fn((msg: ModuleLoaderMessage) => { + const response = getResponse?.(msg); + if (response) { + queueMicrotask(() => subscriber?.(response)); + } + }); + const subscribe = vi.fn((handler: ChannelSubscriber) => { + subscriber = handler; + }); + const unsubscribe = vi.fn((handler: ChannelSubscriber) => { + if (subscriber === handler) { + subscriber = undefined; + } + }); + return { + name: CHANNEL_ID, + send, + subscribe, + unsubscribe, + close: vi.fn(), + }; +}; + +const makeConductor = () => { + const initialise = vi.fn<() => Promise>().mockResolvedValue(undefined); + const pluginObj = { initialise } as unknown as IModulePlugin; + const registerPlugin = vi.fn(() => pluginObj); + const hostLoadPlugin = vi.fn(); + const conductor = { + registerPlugin, + hostLoadPlugin, + } as unknown as IRunnerPlugin; + return { conductor, registerPlugin, hostLoadPlugin, initialise, pluginObj }; +}; + +const makePlugin = (channel = makeChannel()) => { + const evaluator = {} as IInterfacableEvaluator; + const { conductor, registerPlugin, hostLoadPlugin, initialise, pluginObj } = makeConductor(); + const plugin = new ModuleLoaderRunnerPlugin( + {} as IConduit, + [channel], + conductor, + evaluator, + ); + return { + plugin, + channel, + evaluator, + registerPlugin, + hostLoadPlugin, + initialise, + pluginObj, + }; +}; + +afterEach(() => { + ModuleLoaderRunnerPlugin.instance = null; +}); + +describe("plugin identity", () => { + test("id is RUNNER_ID", () => { + expect(makePlugin().plugin.id).toBe(RUNNER_ID); + }); + + test("channelAttach declares the module loader channel", () => { + expect(ModuleLoaderRunnerPlugin.channelAttach).toEqual([CHANNEL_ID]); + }); + + test("constructor sets the singleton instance", () => { + const { plugin } = makePlugin(); + expect(ModuleLoaderRunnerPlugin.instance).toBe(plugin); + }); +}); + +describe("requestModule", () => { + test("requests a module, imports the returned bundle, registers it, and initialises it", async () => { + const tabs = ["ChartTab", "SettingsTab"]; + const channel = makeChannel(msg => { + if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) { + return undefined; + } + return { + type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleURL: mockBundleURL, + tabs, + }; + }); + const { plugin, evaluator, registerPlugin, initialise, pluginObj } = makePlugin(channel); + + await expect(plugin.requestModule("chart")).resolves.toBe(pluginObj); + + expect(channel.subscribe).toHaveBeenCalledOnce(); + expect(channel.send).toHaveBeenCalledWith({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName: "chart", + }); + expect(channel.unsubscribe).toHaveBeenCalledOnce(); + expect(registerPlugin).toHaveBeenCalledOnce(); + const [pluginClass, receivedEvaluator, options] = registerPlugin.mock.calls[0]; + expect(pluginClass).toEqual(expect.any(Function)); + expect(pluginClass.name).toBe("MockModulePlugin"); + expect(receivedEvaluator).toBe(evaluator); + expect(options).toMatchObject({ tabs }); + expect(initialise).toHaveBeenCalledOnce(); + }); + + test("loadTab delegates to the conductor for declared tabs only", async () => { + const channel = makeChannel(msg => { + if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) { + return undefined; + } + return { + type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleURL: mockBundleURL, + tabs: ["ChartTab"], + }; + }); + const { plugin, registerPlugin, hostLoadPlugin } = makePlugin(channel); + + await plugin.requestModule("chart"); + const options = registerPlugin.mock.calls[0][2] as { + loadTab: (tabName: string) => void; + }; + + options.loadTab("ChartTab"); + expect(hostLoadPlugin).toHaveBeenCalledWith("ChartTab"); + expect(() => options.loadTab("MissingTab")).toThrow( + "Tab MissingTab not found in module chart", + ); + }); + + test("rejects when the mocked web side returns a module error", async () => { + const channel = makeChannel(msg => { + if (msg.type !== ModuleLoaderMessageType.REQUEST_MODULE) { + return undefined; + } + return { + type: ModuleLoaderMessageType.MODULE_ERROR, + error: "Module not found: missing", + }; + }); + const { plugin, registerPlugin } = makePlugin(channel); + + await expect(plugin.requestModule("missing")).rejects.toThrow("Module not found: missing"); + expect(channel.unsubscribe).toHaveBeenCalledOnce(); + expect(registerPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/module-loader/src/__tests__/web.test.ts b/src/web/module-loader/src/__tests__/web.test.ts new file mode 100644 index 0000000..4f74e65 --- /dev/null +++ b/src/web/module-loader/src/__tests__/web.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, test, vi, type Mock } from "vitest"; +import { ModuleLoaderWebPlugin } from ".."; +import { + CHANNEL_ID, + ModuleLoaderMessageType, + WEB_ID, + type ModuleLoaderMessage, +} from "@sourceacademy/common-module-loader"; +import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit"; + +type ModuleDirectory = Record; +type ChannelSubscriber = (msg: ModuleLoaderMessage) => void; + +type TestChannel = IChannel & { + send: Mock<(msg: ModuleLoaderMessage) => void>; + subscribe: Mock<(handler: ChannelSubscriber) => void>; + emit: (msg: ModuleLoaderMessage) => void; +}; + +const moduleDirectoryURL = "/mock/modules.json"; +const moduleDirectory: ModuleDirectory = { + chart: { tabs: ["ChartTab", "SettingsTab"] }, + "!!!": { tabs: [] }, +}; + +const makeChannel = (): TestChannel => { + let subscriber: ChannelSubscriber | undefined; + const send = vi.fn<(msg: ModuleLoaderMessage) => void>(); + const subscribe = vi.fn((handler: ChannelSubscriber) => { + subscriber = handler; + }); + const emit = (msg: ModuleLoaderMessage) => subscriber?.(msg); + return { + name: CHANNEL_ID, + send, + subscribe, + unsubscribe: vi.fn(), + close: vi.fn(), + emit, + }; +}; + +const makePlugin = (channel = makeChannel()) => { + const plugin = new ModuleLoaderWebPlugin({} as IConduit, [channel]); + return { plugin, channel }; +}; + +const mockFetch = (directory = moduleDirectory) => { + const json = vi.fn().mockResolvedValue(directory); + const fetch = vi.fn().mockResolvedValue({ json }); + vi.stubGlobal("fetch", fetch); + return { fetch, json }; +}; + +const loadDirectory = async (plugin: ModuleLoaderWebPlugin) => { + plugin.onModuleDirectoryURLChange(moduleDirectoryURL); + await vi.waitFor(() => { + expect(plugin.getModuleTabLocation("ChartTab")).toBe("/mock/tabs/ChartTab.js"); + }); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + ModuleLoaderWebPlugin.instance = null; +}); + +describe("plugin identity", () => { + test("id is WEB_ID", () => { + expect(makePlugin().plugin.id).toBe(WEB_ID); + }); + + test("channelAttach declares the module loader channel", () => { + expect(ModuleLoaderWebPlugin.channelAttach).toEqual([CHANNEL_ID]); + }); + + test("constructor sets the singleton instance", () => { + const { plugin } = makePlugin(); + expect(ModuleLoaderWebPlugin.instance).toBe(plugin); + }); +}); + +describe("module directory loading", () => { + test("fetches and stores the configured module directory URL", async () => { + const { plugin } = makePlugin(); + const { fetch, json } = mockFetch(); + + await loadDirectory(plugin); + + expect(fetch).toHaveBeenCalledWith(moduleDirectoryURL); + expect(json).toHaveBeenCalledOnce(); + }); + + test("does not fetch the same loaded directory URL twice", async () => { + const { plugin } = makePlugin(); + const { fetch } = mockFetch(); + + await loadDirectory(plugin); + plugin.onModuleDirectoryURLChange(moduleDirectoryURL); + + expect(fetch).toHaveBeenCalledOnce(); + }); +}); + +describe("request handling", () => { + test("returns an error when the directory has not loaded yet", () => { + const { channel } = makePlugin(); + + channel.emit({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName: "chart", + }); + + expect(channel.send).toHaveBeenCalledWith({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: "Module directory not loaded yet", + }); + }); + + test("responds with the bundle URL and tabs for a known module", async () => { + const { plugin, channel } = makePlugin(); + mockFetch(); + await loadDirectory(plugin); + + channel.emit({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName: "chart", + }); + + expect(channel.send).toHaveBeenCalledWith({ + type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleURL: "/mock/bundles/chart.js", + tabs: ["ChartTab", "SettingsTab"], + }); + }); + + test("returns an error for an unknown module", async () => { + const { plugin, channel } = makePlugin(); + mockFetch(); + await loadDirectory(plugin); + + channel.emit({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName: "missing", + }); + + expect(channel.send).toHaveBeenCalledWith({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: "Module not found: missing", + }); + }); + + test("returns an error for an invalid module name present in the directory", async () => { + const { plugin, channel } = makePlugin(); + mockFetch(); + await loadDirectory(plugin); + + channel.emit({ + type: ModuleLoaderMessageType.REQUEST_MODULE, + moduleName: "!!!", + }); + + expect(channel.send).toHaveBeenCalledWith({ + type: ModuleLoaderMessageType.MODULE_ERROR, + error: "Invalid module name: !!!", + }); + }); + + test("ignores non-request messages", () => { + const { channel } = makePlugin(); + + channel.emit({ + type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleURL: "/mock/bundles/chart.js", + tabs: [], + }); + + expect(channel.send).not.toHaveBeenCalled(); + }); +}); + +describe("tab locations", () => { + test("returns null before the module directory has loaded", () => { + const { plugin } = makePlugin(); + + expect(plugin.getModuleTabLocation("ChartTab")).toBeNull(); + }); + + test("returns tab URLs for known tabs and null for unknown tabs", async () => { + const { plugin } = makePlugin(); + mockFetch(); + await loadDirectory(plugin); + + expect(plugin.getModuleTabLocation("ChartTab")).toBe("/mock/tabs/ChartTab.js"); + expect(plugin.getModuleTabLocation("MissingTab")).toBeNull(); + }); +}); From 9179da35b31d74a381b1aacd8b92a38186949c03 Mon Sep 17 00:00:00 2001 From: Aarav Malani Date: Thu, 2 Jul 2026 03:43:56 +0530 Subject: [PATCH 5/5] fix: fix Gemini points --- src/common/module-loader/src/index.ts | 2 + .../src/__tests__/runner.test.ts | 35 +++++++--------- src/runner/module-loader/src/index.ts | 42 +++++++++++-------- .../module-loader/src/__tests__/web.test.ts | 5 +++ src/web/module-loader/src/index.ts | 9 +++- 5 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/common/module-loader/src/index.ts b/src/common/module-loader/src/index.ts index f4a0e75..3ca4f9d 100644 --- a/src/common/module-loader/src/index.ts +++ b/src/common/module-loader/src/index.ts @@ -16,10 +16,12 @@ export type ModuleLoaderMessage = } | { type: ModuleLoaderMessageType.MODULE_RESPONSE; + moduleName: string; moduleURL: string; tabs: string[]; } | { type: ModuleLoaderMessageType.MODULE_ERROR; + moduleName: string; error: string; }; diff --git a/src/runner/module-loader/src/__tests__/runner.test.ts b/src/runner/module-loader/src/__tests__/runner.test.ts index 891fb43..d5d5c56 100644 --- a/src/runner/module-loader/src/__tests__/runner.test.ts +++ b/src/runner/module-loader/src/__tests__/runner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi, type Mock } from "vitest"; +import { afterEach, describe, expect, test, vi, type Mock, type MockedFunction } from "vitest"; import { ModuleLoaderRunnerPlugin } from ".."; import { CHANNEL_ID, @@ -8,11 +8,8 @@ import { } from "@sourceacademy/common-module-loader"; import type { IChannel, IConduit } from "@sourceacademy/conductor/conduit"; import type { IModulePlugin } from "@sourceacademy/conductor/module"; -import type { - IInterfacableEvaluator, - IRunnerPlugin, -} from "@sourceacademy/conductor/runner"; - +import type { IInterfacableEvaluator, IRunnerPlugin } from "@sourceacademy/conductor/runner"; +import type { PluginClass } from "@sourceacademy/conductor/conduit"; type ChannelSubscriber = (msg: ModuleLoaderMessage) => void | Promise; type TestChannel = IChannel & { @@ -56,7 +53,13 @@ const makeChannel = ( const makeConductor = () => { const initialise = vi.fn<() => Promise>().mockResolvedValue(undefined); const pluginObj = { initialise } as unknown as IModulePlugin; - const registerPlugin = vi.fn(() => pluginObj); + const registerPlugin: MockedFunction< + ( + pluginClass: PluginClass, + evaluator: IInterfacableEvaluator, + options: { tabs: string[]; loadTab: (tabName: string) => void }, + ) => IModulePlugin + > = vi.fn(() => pluginObj); const hostLoadPlugin = vi.fn(); const conductor = { registerPlugin, @@ -68,12 +71,7 @@ const makeConductor = () => { const makePlugin = (channel = makeChannel()) => { const evaluator = {} as IInterfacableEvaluator; const { conductor, registerPlugin, hostLoadPlugin, initialise, pluginObj } = makeConductor(); - const plugin = new ModuleLoaderRunnerPlugin( - {} as IConduit, - [channel], - conductor, - evaluator, - ); + const plugin = new ModuleLoaderRunnerPlugin({} as IConduit, [channel], conductor, evaluator); return { plugin, channel, @@ -113,6 +111,7 @@ describe("requestModule", () => { } return { type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleName: msg.moduleName, moduleURL: mockBundleURL, tabs, }; @@ -143,6 +142,7 @@ describe("requestModule", () => { } return { type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleName: msg.moduleName, moduleURL: mockBundleURL, tabs: ["ChartTab"], }; @@ -150,15 +150,11 @@ describe("requestModule", () => { const { plugin, registerPlugin, hostLoadPlugin } = makePlugin(channel); await plugin.requestModule("chart"); - const options = registerPlugin.mock.calls[0][2] as { - loadTab: (tabName: string) => void; - }; + const options = registerPlugin.mock.calls[0][2]; options.loadTab("ChartTab"); expect(hostLoadPlugin).toHaveBeenCalledWith("ChartTab"); - expect(() => options.loadTab("MissingTab")).toThrow( - "Tab MissingTab not found in module chart", - ); + expect(() => options.loadTab("MissingTab")).toThrow("Tab MissingTab not found in module chart"); }); test("rejects when the mocked web side returns a module error", async () => { @@ -168,6 +164,7 @@ describe("requestModule", () => { } return { type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: msg.moduleName, error: "Module not found: missing", }; }); diff --git a/src/runner/module-loader/src/index.ts b/src/runner/module-loader/src/index.ts index 859583a..ca573f1 100644 --- a/src/runner/module-loader/src/index.ts +++ b/src/runner/module-loader/src/index.ts @@ -35,24 +35,30 @@ export class ModuleLoaderRunnerPlugin implements IPlugin { async requestModule(moduleName: string): Promise { return new Promise((resolve, reject) => { const handleResponse = async (msg: ModuleLoaderMessage) => { - this.__moduleRequestChannel.unsubscribe(handleResponse); - if (msg.type === ModuleLoaderMessageType.MODULE_RESPONSE) { - const plugin = await import(msg.moduleURL).then(module => { - return module.default(() => {}).default; - }); - const pluginObj = this.__conductor.registerPlugin(plugin, this.__evaluator, { - tabs: msg.tabs, - loadTab: (tabName: string) => { - if (!msg.tabs.includes(tabName)) { - throw new Error(`Tab ${tabName} not found in module ${moduleName}`); - } - this.__conductor.hostLoadPlugin(tabName); - }, - }) as IModulePlugin; - await pluginObj?.initialise(); - resolve(pluginObj); - } else if (msg.type === ModuleLoaderMessageType.MODULE_ERROR) { - reject(new Error(msg.error)); + try { + if (msg.moduleName !== moduleName) return; + this.__moduleRequestChannel.unsubscribe(handleResponse); + if (msg.type === ModuleLoaderMessageType.MODULE_RESPONSE) { + const plugin = await import(msg.moduleURL).then(module => { + return module.default(() => {}).default; + }); + const pluginObj = this.__conductor.registerPlugin(plugin, this.__evaluator, { + tabs: msg.tabs, + loadTab: (tabName: string) => { + if (!msg.tabs.includes(tabName)) { + throw new Error(`Tab ${tabName} not found in module ${moduleName}`); + } + this.__conductor.hostLoadPlugin(tabName); + }, + }) as IModulePlugin; + await pluginObj?.initialise(); + resolve(pluginObj); + } else if (msg.type === ModuleLoaderMessageType.MODULE_ERROR) { + reject(new Error(msg.error)); + } + } catch (error) { + this.__moduleRequestChannel.unsubscribe(handleResponse); + reject(error); } }; this.__moduleRequestChannel.subscribe(handleResponse); diff --git a/src/web/module-loader/src/__tests__/web.test.ts b/src/web/module-loader/src/__tests__/web.test.ts index 4f74e65..a4104c4 100644 --- a/src/web/module-loader/src/__tests__/web.test.ts +++ b/src/web/module-loader/src/__tests__/web.test.ts @@ -112,6 +112,7 @@ describe("request handling", () => { expect(channel.send).toHaveBeenCalledWith({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: "chart", error: "Module directory not loaded yet", }); }); @@ -128,6 +129,7 @@ describe("request handling", () => { expect(channel.send).toHaveBeenCalledWith({ type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleName: "chart", moduleURL: "/mock/bundles/chart.js", tabs: ["ChartTab", "SettingsTab"], }); @@ -145,6 +147,7 @@ describe("request handling", () => { expect(channel.send).toHaveBeenCalledWith({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: "missing", error: "Module not found: missing", }); }); @@ -161,6 +164,7 @@ describe("request handling", () => { expect(channel.send).toHaveBeenCalledWith({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: "!!!", error: "Invalid module name: !!!", }); }); @@ -170,6 +174,7 @@ describe("request handling", () => { channel.emit({ type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleName: "chart", moduleURL: "/mock/bundles/chart.js", tabs: [], }); diff --git a/src/web/module-loader/src/index.ts b/src/web/module-loader/src/index.ts index 7adca36..4f2fdcb 100644 --- a/src/web/module-loader/src/index.ts +++ b/src/web/module-loader/src/index.ts @@ -36,18 +36,21 @@ export class ModuleLoaderWebPlugin implements IPlugin { if (this.moduleDirectory === null || this.moduleDirectoryURL === null) { return this.__moduleRequestChannel.send({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: message.moduleName, error: "Module directory not loaded yet", }); } if (!(message.moduleName in this.moduleDirectory)) { return this.__moduleRequestChannel.send({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: message.moduleName, error: `Module not found: ${message.moduleName}`, }); } - if (!/[a-zA-Z0-9_-]+/.test(message.moduleName)) { + if (!/^[a-zA-Z0-9_-]+$/.test(message.moduleName)) { return this.__moduleRequestChannel.send({ type: ModuleLoaderMessageType.MODULE_ERROR, + moduleName: message.moduleName, error: `Invalid module name: ${message.moduleName}`, }); } @@ -57,6 +60,7 @@ export class ModuleLoaderWebPlugin implements IPlugin { ); return this.__moduleRequestChannel.send({ type: ModuleLoaderMessageType.MODULE_RESPONSE, + moduleName: message.moduleName, moduleURL: moduleBaseUrl + "bundles/" + message.moduleName + ".js", tabs: this.moduleDirectory[message.moduleName].tabs, }); @@ -72,6 +76,9 @@ export class ModuleLoaderWebPlugin implements IPlugin { .then(response => response.json()) .then(data => { this.moduleDirectory = data; + }) + .catch(error => { + console.error("Failed to load module directory:", error); }); }