diff --git a/src/engine/userScriptExecution.test.ts b/src/engine/userScriptExecution.test.ts index 3dcf6aac2..66ad5407d 100644 --- a/src/engine/userScriptExecution.test.ts +++ b/src/engine/userScriptExecution.test.ts @@ -1,5 +1,5 @@ import { TFile, type App } from "obsidian"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type QuickAdd from "../main"; import { CommandType } from "../types/macros/CommandType"; @@ -27,14 +27,14 @@ module.exports = { }; `; -function createApp(secrets: Map): App { +function createApp(secrets: Map, source = SCRIPT_SOURCE): App { const file = new TFile(); file.path = "scripts/todoist.js"; file.extension = "js"; return { vault: { getAbstractFileByPath: vi.fn(() => file), - read: vi.fn(async () => SCRIPT_SOURCE), + read: vi.fn(async () => source), }, secretStorage: { getSecret: vi.fn((id: string) => secrets.get(id) ?? null), @@ -118,3 +118,42 @@ describe("executeUserScript settings with `Script::Export` member access", () => expect([...secrets.values()]).toEqual(["legacy-token"]); }); }); + +describe("executeUserScript with a script that exports nothing", () => { + afterEach(() => vi.unstubAllGlobals()); + + // #2208: all of the script's work happens at the top level while it loads. + it("runs the top-level code and finishes without output", async () => { + const ran: string[] = []; + vi.stubGlobal("window", { require: () => ran }); + const command: IUserScript = { + id: "side-effect-command", + name: "create_wp_folder", + type: CommandType.UserScript, + path: "scripts/todoist.js", + settings: {}, + }; + + const { result } = await run( + command, + createApp(new Map(), "require('ran').push('top level');"), + ); + + expect(ran).toEqual(["top level"]); + expect(result).toBeUndefined(); + }); + + it("still rejects a selected member that is an empty object", async () => { + const command: IUserScript = { + id: "empty-member-command", + name: "lib::start", + type: CommandType.UserScript, + path: "scripts/todoist.js", + settings: {}, + }; + + await expect( + run(command, createApp(new Map(), "module.exports = { start: {} };")), + ).rejects.toThrow("is an empty object"); + }); +}); diff --git a/src/engine/userScriptExecution.ts b/src/engine/userScriptExecution.ts index 4b682cca0..f6212a2d3 100644 --- a/src/engine/userScriptExecution.ts +++ b/src/engine/userScriptExecution.ts @@ -4,7 +4,7 @@ import type { QuickAddApi } from "../quickAddApi"; import type QuickAdd from "../main"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import type { IUserScript } from "../types/macros/IUserScript"; -import { getUserScriptPreloadKey, type LoadedUserScript, loadUserScript } from "../utils/userScript"; +import { getUserScriptMemberAccess, getUserScriptPreloadKey, type LoadedUserScript, loadUserScript } from "../utils/userScript"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; import { resolveScriptSettings } from "./userScriptSettings"; import { log } from "../logger/logManager"; @@ -120,6 +120,10 @@ export async function executeUserScript( } } + // Nothing exported: the script did its work at the top level while loading. + const drillsMember = (getUserScriptMemberAccess(command.name ?? "").memberAccess ?? []).length > 0; + if (!drillsMember && isRecord(userScript) && Object.keys(userScript).length === 0) return; + try { return await delegate(userScript); } catch (err) {