From a4527cdaa9164b82419e95311c10d02e79fe8df5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 15:20:03 +0100 Subject: [PATCH 01/12] fix: abort nested choices on cancellation --- src/engine/MacroChoiceEngine.entry.test.ts | 100 +++++++++++++++++++++ src/engine/MacroChoiceEngine.ts | 24 ++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index 76b85360..34b3437f 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -7,6 +7,10 @@ import type { IMacro } from "../types/macros/IMacro"; import type { IUserScript } from "../types/macros/IUserScript"; import { CommandType } from "../types/macros/CommandType"; import type { App } from "obsidian"; +import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; +import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; +import type IChoice from "../types/choices/IChoice"; +import { MacroAbortError } from "../errors/MacroAbortError"; const { mockGetUserScript, mockInitializeUserScriptSettings, mockSuggest, mockGetApi } = vi.hoisted(() => ({ @@ -176,3 +180,99 @@ describe("MacroChoiceEngine user script entry handling", () => { expect(engine["output"]).toBe("option-result"); }); }); + +describe("MacroChoiceEngine choice command cancellation", () => { + const app = {} as App; + let plugin: QuickAdd & { getChoiceById: ReturnType }; + let choiceExecutor: IChoiceExecutor; + let variables: Map; + let macroChoice: IMacroChoice; + + beforeEach(() => { + plugin = { + getChoiceById: vi.fn(), + } as unknown as QuickAdd & { getChoiceById: ReturnType }; + variables = new Map(); + choiceExecutor = { + execute: vi.fn(), + variables, + }; + macroChoice = { + id: "macro-choice", + name: "Macro", + type: "Macro", + command: false, + runOnStartup: false, + macro: { + id: "macro-id", + name: "Macro", + commands: [], + } as IMacro, + }; + }); + + it("wraps cancellation errors from executeChoice in MacroAbortError", async () => { + const engine = new MacroChoiceEngine( + app, + plugin, + macroChoice, + choiceExecutor, + variables, + ); + const choice: IChoice = { + id: "target-choice", + name: "Target", + type: "Macro", + command: false, + }; + (plugin.getChoiceById as unknown as ReturnType).mockReturnValue( + choice, + ); + const command: IChoiceCommand = { + id: "choice-command", + name: "Run choice", + type: CommandType.Choice, + choiceId: "target-choice", + }; + choiceExecutor.execute = vi.fn().mockRejectedValue("No input given."); + + await expect( + (engine as unknown as { executeChoice: (cmd: IChoiceCommand) => Promise }).executeChoice( + command, + ), + ).rejects.toThrow(MacroAbortError); + expect(choiceExecutor.execute).toHaveBeenCalledWith(choice); + }); + + it("wraps cancellation errors from executeNestedChoice in MacroAbortError", async () => { + const engine = new MacroChoiceEngine( + app, + plugin, + macroChoice, + choiceExecutor, + variables, + ); + const choice: IChoice = { + id: "nested-choice", + name: "Nested", + type: "Macro", + command: false, + }; + const command: INestedChoiceCommand = { + id: "nested-command", + name: "Nested choice", + type: CommandType.NestedChoice, + choice, + }; + choiceExecutor.execute = vi.fn().mockRejectedValue("no input given."); + + await expect( + (engine as unknown as { + executeNestedChoice: ( + cmd: INestedChoiceCommand, + ) => Promise; + }).executeNestedChoice(command), + ).rejects.toThrow(MacroAbortError); + expect(choiceExecutor.execute).toHaveBeenCalledWith(choice); + }); +}); diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index ecf63ad7..3955af5e 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -349,7 +349,17 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return; } - await this.choiceExecutor.execute(targetChoice); + try { + await this.choiceExecutor.execute(targetChoice); + } catch (error) { + if (error instanceof MacroAbortError) { + throw error; + } + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); + } + throw error; + } } private async executeNestedChoice(command: INestedChoiceCommand) { @@ -359,7 +369,17 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return; } - await this.choiceExecutor.execute(choice); + try { + await this.choiceExecutor.execute(choice); + } catch (error) { + if (error instanceof MacroAbortError) { + throw error; + } + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); + } + throw error; + } } private async executeEditorCommand(command: IEditorCommand) { From 0d6bccbe0612c731ef81881d1693c22f269d08eb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 17:53:52 +0100 Subject: [PATCH 02/12] fix: propagate prompt cancellations through macros --- src/IChoiceExecutor.ts | 12 +++ src/choiceExecutor.ts | 12 +++ src/engine/CaptureChoiceEngine.ts | 1 + src/engine/MacroChoiceEngine.entry.test.ts | 86 +++++++++++++++++-- src/engine/MacroChoiceEngine.notice.test.ts | 82 ++++++++++++++++++ src/engine/MacroChoiceEngine.ts | 29 ++----- src/engine/SingleMacroEngine.ts | 2 + .../TemplateChoiceEngine.notice.test.ts | 73 +++++++++++++--- src/engine/TemplateChoiceEngine.ts | 3 + src/engine/TemplateEngine.ts | 19 ++++ src/quickAddApi.ts | 49 ++++++++--- 11 files changed, 313 insertions(+), 55 deletions(-) diff --git a/src/IChoiceExecutor.ts b/src/IChoiceExecutor.ts index 991f09a5..483a1078 100644 --- a/src/IChoiceExecutor.ts +++ b/src/IChoiceExecutor.ts @@ -1,6 +1,18 @@ import type IChoice from "./types/choices/IChoice"; +import type { MacroAbortError } from "./errors/MacroAbortError"; export interface IChoiceExecutor { execute(choice: IChoice): Promise; variables: Map; + /** + * Records that the most recent choice execution aborted so orchestrators can react. + * Engines that handle cancellations without throwing should call this immediately after + * {@link handleMacroAbort} returns true. + */ + signalAbort?(error: MacroAbortError): void; + /** + * Returns and clears any pending abort signal. Callers should invoke this right after + * awaiting {@link execute} to determine whether the child choice stopped early. + */ + consumeAbortSignal?(): MacroAbortError | null; } diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index ec0aef9f..2cbb4297 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -17,10 +17,22 @@ import { isCancellationError } from "./utils/errorUtils"; export class ChoiceExecutor implements IChoiceExecutor { public variables: Map = new Map(); + private pendingAbort: MacroAbortError | null = null; constructor(private app: App, private plugin: QuickAdd) {} + signalAbort(error: MacroAbortError) { + this.pendingAbort = error; + } + + consumeAbortSignal(): MacroAbortError | null { + const abort = this.pendingAbort; + this.pendingAbort = null; + return abort ?? null; + } + async execute(choice: IChoice): Promise { + this.pendingAbort = null; // One-page preflight honoring per-choice override const globalEnabled = settingsStore.getState().onePageInputEnabled; const override = choice.onePageInput; diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 6362d55b..c1451571 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -184,6 +184,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { defaultReason: "Capture aborted", }) ) { + this.choiceExecutor.signalAbort?.(err as MacroAbortError); return; } reportError(err, `Error running capture choice "${this.choice.name}"`); diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index 34b3437f..0f75e5a1 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -11,13 +11,15 @@ import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; import type IChoice from "../types/choices/IChoice"; import { MacroAbortError } from "../errors/MacroAbortError"; +import { QuickAddApi } from "../quickAddApi"; -const { mockGetUserScript, mockInitializeUserScriptSettings, mockSuggest, mockGetApi } = +const { mockGetUserScript, mockInitializeUserScriptSettings, mockSuggest, mockGetApi, mockInputPrompt } = vi.hoisted(() => ({ mockGetUserScript: vi.fn(), mockInitializeUserScriptSettings: vi.fn(), mockSuggest: vi.fn(), mockGetApi: vi.fn(() => ({})), + mockInputPrompt: vi.fn(), })); vi.mock("../utilityObsidian", async () => { @@ -37,6 +39,40 @@ vi.mock("../utils/userScriptSettings", () => ({ vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({})); vi.mock("../gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); +vi.mock("../gui/GenericInputPrompt/genericInputPrompt", () => ({ + __esModule: true, + default: { + Prompt: mockInputPrompt, + }, +})); +vi.mock("../gui/GenericCheckboxPrompt/genericCheckboxPrompt", () => ({ + __esModule: true, + default: { Open: vi.fn() }, +})); +vi.mock("../gui/GenericInfoDialog/GenericInfoDialog", () => ({ + __esModule: true, + default: { Show: vi.fn() }, +})); +vi.mock("../gui/GenericWideInputPrompt/GenericWideInputPrompt", () => ({ + __esModule: true, + default: { Prompt: vi.fn() }, +})); +vi.mock("../gui/GenericYesNoPrompt/GenericYesNoPrompt", () => ({ + __esModule: true, + default: { Prompt: vi.fn() }, +})); +vi.mock("../gui/InputSuggester/inputSuggester", () => ({ + __esModule: true, + default: { Suggest: vi.fn() }, +})); +vi.mock("../preflight/OnePageInputModal", () => ({ + OnePageInputModal: class { + waitForClose: Promise; + constructor() { + this.waitForClose = Promise.reject("cancelled"); + } + } +})); vi.mock("../gui/GenericSuggester/genericSuggester", () => ({ __esModule: true, @@ -54,11 +90,13 @@ vi.mock("../main", () => ({ default: class QuickAddMock {}, })); -vi.mock("../quickAddApi", () => ({ - QuickAddApi: { - GetApi: mockGetApi, - }, -})); +vi.mock("../quickAddApi", async () => { + const actual = await vi.importActual( + "../quickAddApi", + ); + actual.QuickAddApi.GetApi = mockGetApi as typeof actual.QuickAddApi.GetApi; + return actual; +}); vi.mock("../quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, @@ -196,6 +234,8 @@ describe("MacroChoiceEngine choice command cancellation", () => { choiceExecutor = { execute: vi.fn(), variables, + signalAbort: vi.fn(), + consumeAbortSignal: vi.fn().mockReturnValue(null), }; macroChoice = { id: "macro-choice", @@ -234,7 +274,9 @@ describe("MacroChoiceEngine choice command cancellation", () => { type: CommandType.Choice, choiceId: "target-choice", }; - choiceExecutor.execute = vi.fn().mockRejectedValue("No input given."); + const abortError = new MacroAbortError("Input cancelled by user"); + choiceExecutor.execute = vi.fn().mockResolvedValue(undefined); + (choiceExecutor.consumeAbortSignal as ReturnType).mockReturnValueOnce(abortError); await expect( (engine as unknown as { executeChoice: (cmd: IChoiceCommand) => Promise }).executeChoice( @@ -242,6 +284,7 @@ describe("MacroChoiceEngine choice command cancellation", () => { ), ).rejects.toThrow(MacroAbortError); expect(choiceExecutor.execute).toHaveBeenCalledWith(choice); + expect(choiceExecutor.consumeAbortSignal).toHaveBeenCalledTimes(1); }); it("wraps cancellation errors from executeNestedChoice in MacroAbortError", async () => { @@ -264,7 +307,9 @@ describe("MacroChoiceEngine choice command cancellation", () => { type: CommandType.NestedChoice, choice, }; - choiceExecutor.execute = vi.fn().mockRejectedValue("no input given."); + const abortError = new MacroAbortError("Input cancelled by user"); + choiceExecutor.execute = vi.fn().mockResolvedValue(undefined); + (choiceExecutor.consumeAbortSignal as ReturnType).mockReturnValueOnce(abortError); await expect( (engine as unknown as { @@ -274,5 +319,30 @@ describe("MacroChoiceEngine choice command cancellation", () => { }).executeNestedChoice(command), ).rejects.toThrow(MacroAbortError); expect(choiceExecutor.execute).toHaveBeenCalledWith(choice); + expect(choiceExecutor.consumeAbortSignal).toHaveBeenCalledTimes(1); + }); +}); + +describe("QuickAddApi prompt cancellation", () => { + const app = {} as App; + + beforeEach(() => { + mockInputPrompt.mockReset(); + }); + + it("throws MacroAbortError when input prompt is cancelled", async () => { + mockInputPrompt.mockRejectedValueOnce("No input given."); + + await expect( + QuickAddApi.inputPrompt(app, "Enter value"), + ).rejects.toThrow(MacroAbortError); + }); + + it("still resolves undefined for other prompt errors", async () => { + mockInputPrompt.mockRejectedValueOnce(new Error("boom")); + + await expect( + QuickAddApi.inputPrompt(app, "Enter value"), + ).resolves.toBeUndefined(); }); }); diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index 18ea9d49..88ca04e1 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -61,6 +61,7 @@ import { CommandType } from "../types/macros/CommandType"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { MacroAbortError } from "../errors/MacroAbortError"; import { settingsStore } from "../settingsStore"; +import type IChoice from "../types/choices/IChoice"; const defaultSettingsState = structuredClone(settingsStore.getState()); @@ -169,4 +170,85 @@ describe("MacroChoiceEngine cancellation notices", () => { expect(noticeClass.instances).toHaveLength(1); expect(noticeClass.instances[0]?.message).toContain("Invalid project name"); }); + +describe("MacroChoiceEngine nested choice propagation", () => { + it("halts subsequent commands when a nested choice cancels", async () => { + const app = {} as App; + const plugin = { settings: settingsStore.getState() } as any; + const nestedChoice: IChoice = { + id: "nested-template", + name: "Nested Template", + type: "Template", + command: false, + }; + const macro: IMacro = { + id: "macro-id", + name: "Macro with nested choice", + commands: [ + { + id: "nested-command", + name: "Nested choice", + type: CommandType.NestedChoice, + choice: nestedChoice, + }, + { + id: "obsidian", + name: "Should not run", + type: CommandType.Obsidian, + } as any, + ], + }; + const choice: IMacroChoice = { + id: "choice-id", + name: "Macro", + type: "Macro", + command: false, + macro, + runOnStartup: false, + }; + + let pendingAbort: MacroAbortError | null = null; + const signalAbort = vi.fn((error: MacroAbortError) => { + pendingAbort = error; + }); + const consumeAbortSignal = vi.fn(() => { + const error = pendingAbort; + pendingAbort = null; + return error; + }); + const choiceExecutor: IChoiceExecutor = { + variables: new Map(), + execute: vi.fn(async (choiceToRun) => { + if (choiceToRun.id === nestedChoice.id) { + signalAbort(new MacroAbortError("Input cancelled by user")); + } + }), + signalAbort, + consumeAbortSignal, + }; + + class ObservationMacroChoiceEngine extends MacroChoiceEngine { + public obsidianExecutions = 0; + + protected override executeObsidianCommand(): void { + this.obsidianExecutions += 1; + } + } + + const engine = new ObservationMacroChoiceEngine( + app, + plugin, + choice, + choiceExecutor, + new Map(), + ); + + await engine.run(); + + expect(choiceExecutor.execute).toHaveBeenCalledTimes(1); + expect(signalAbort).toHaveBeenCalledTimes(1); + expect(consumeAbortSignal).toHaveBeenCalledTimes(1); + expect(engine.obsidianExecutions).toBe(0); + }); +}); }); diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index 3955af5e..27d497e9 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -167,6 +167,7 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { defaultReason: "Macro execution aborted", }) ) { + this.choiceExecutor.signalAbort?.(error as MacroAbortError); return; } throw error; @@ -349,16 +350,10 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return; } - try { - await this.choiceExecutor.execute(targetChoice); - } catch (error) { - if (error instanceof MacroAbortError) { - throw error; - } - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; + await this.choiceExecutor.execute(targetChoice); + const abort = this.choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; } } @@ -369,16 +364,10 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { return; } - try { - await this.choiceExecutor.execute(choice); - } catch (error) { - if (error instanceof MacroAbortError) { - throw error; - } - if (isCancellationError(error)) { - throw new MacroAbortError("Input cancelled by user"); - } - throw error; + await this.choiceExecutor.execute(choice); + const abort = this.choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; } } diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index f8f3bd46..84f41a4f 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -12,6 +12,7 @@ import { flattenChoices } from "../utils/choiceUtils"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { handleMacroAbort } from "../utils/macroAbortHandler"; +import { MacroAbortError } from "../errors/MacroAbortError"; export class SingleMacroEngine { private readonly choiceExecutor: IChoiceExecutor; @@ -239,6 +240,7 @@ export class SingleMacroEngine { defaultReason: "Macro execution aborted", }) ) { + this.choiceExecutor.signalAbort?.(error as MacroAbortError); return { executed: true, result: "", diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index ce703e7e..310f6fee 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -40,9 +40,12 @@ vi.mock("../quickAddSettingsTab", () => { }; }); -const formatFileNameMock = vi.hoisted(() => - vi.fn<(format: string, prompt: string) => Promise>() -); +const { formatFileNameMock, formatFileContentMock } = vi.hoisted(() => { + return { + formatFileNameMock: vi.fn<(format: string, prompt: string) => Promise>(), + formatFileContentMock: vi.fn<() => Promise>().mockResolvedValue(""), + }; +}); vi.mock("../formatters/completeFormatter", () => { class CompleteFormatterMock { @@ -52,8 +55,8 @@ vi.mock("../formatters/completeFormatter", () => { async formatFileName(format: string, prompt: string) { return formatFileNameMock(format, prompt); } - async formatFileContent() { - return ""; + async formatFileContent(...args: unknown[]) { + return await formatFileContentMock(...args); } getAndClearTemplatePropertyVars() { return new Map(); @@ -63,6 +66,7 @@ vi.mock("../formatters/completeFormatter", () => { return { CompleteFormatter: CompleteFormatterMock, formatFileNameMock, + formatFileContentMock, }; }); @@ -132,7 +136,10 @@ const createTemplateChoice = (): ITemplateChoice => ({ setFileExistsBehavior: false, }); -const createEngine = (abortMessage: string) => { +const createEngine = ( + abortMessage: string, + options: { throwDuringFileName?: boolean; stubTemplateContent?: boolean } = {}, +) => { const app = { workspace: { getActiveFile: vi.fn(() => null), @@ -155,6 +162,8 @@ const createEngine = (abortMessage: string) => { const choiceExecutor: IChoiceExecutor = { execute: vi.fn(), variables: new Map(), + signalAbort: vi.fn(), + consumeAbortSignal: vi.fn(), }; const engine = new TemplateChoiceEngine( @@ -164,11 +173,23 @@ const createEngine = (abortMessage: string) => { choiceExecutor, ); - formatFileNameMock.mockImplementation(async () => { - throw new MacroAbortError(abortMessage); - }); + if (options.stubTemplateContent) { + ( + engine as unknown as { + getTemplateContent: () => Promise; + } + ).getTemplateContent = vi.fn().mockResolvedValue("stub template"); + } - return engine; + if (options.throwDuringFileName !== false) { + formatFileNameMock.mockImplementation(async () => { + throw new MacroAbortError(abortMessage); + }); + } else { + formatFileNameMock.mockResolvedValue("Test Template"); + } + + return { engine, choiceExecutor }; }; describe("TemplateChoiceEngine cancellation notices", () => { @@ -176,6 +197,8 @@ describe("TemplateChoiceEngine cancellation notices", () => { settingsStore.setState(structuredClone(defaultSettingsState)); noticeClass.instances.length = 0; formatFileNameMock.mockReset(); + formatFileContentMock.mockReset(); + formatFileContentMock.mockResolvedValue(""); }); it("shows a cancellation notice when the setting is enabled", async () => { @@ -183,7 +206,7 @@ describe("TemplateChoiceEngine cancellation notices", () => { ...settingsStore.getState(), showInputCancellationNotification: true, }); - const engine = createEngine("Input cancelled by user"); + const { engine } = createEngine("Input cancelled by user"); await engine.run(); @@ -199,7 +222,7 @@ describe("TemplateChoiceEngine cancellation notices", () => { showInputCancellationNotification: false, }); - const engine = createEngine("Input cancelled by user"); + const { engine } = createEngine("Input cancelled by user"); await engine.run(); @@ -212,7 +235,7 @@ describe("TemplateChoiceEngine cancellation notices", () => { showInputCancellationNotification: false, }); - const engine = createEngine("Missing template"); + const { engine } = createEngine("Missing template"); await engine.run(); @@ -221,4 +244,28 @@ describe("TemplateChoiceEngine cancellation notices", () => { "Template execution aborted: Missing template", ); }); + + it("signals abort back to the choice executor", async () => { + const { engine, choiceExecutor } = createEngine("Input cancelled by user"); + + await engine.run(); + + expect(choiceExecutor.signalAbort).toHaveBeenCalledTimes(1); + const [[error]] = (choiceExecutor.signalAbort as ReturnType).mock.calls; + expect(error).toBeInstanceOf(MacroAbortError); + }); + + it("signals abort when template content formatting is cancelled", async () => { + const { engine, choiceExecutor } = createEngine("ignored", { + throwDuringFileName: false, + stubTemplateContent: true, + }); + formatFileContentMock.mockRejectedValueOnce( + new MacroAbortError("Input cancelled by user"), + ); + + await engine.run(); + + expect(choiceExecutor.signalAbort).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/engine/TemplateChoiceEngine.ts b/src/engine/TemplateChoiceEngine.ts index 8065a306..29f27f17 100644 --- a/src/engine/TemplateChoiceEngine.ts +++ b/src/engine/TemplateChoiceEngine.ts @@ -29,6 +29,7 @@ import { handleMacroAbort } from "../utils/macroAbortHandler"; export class TemplateChoiceEngine extends TemplateEngine { public choice: ITemplateChoice; + private readonly choiceExecutor: IChoiceExecutor; constructor( app: App, @@ -37,6 +38,7 @@ export class TemplateChoiceEngine extends TemplateEngine { choiceExecutor: IChoiceExecutor, ) { super(app, plugin, choiceExecutor); + this.choiceExecutor = choiceExecutor; this.choice = choice; } @@ -186,6 +188,7 @@ export class TemplateChoiceEngine extends TemplateEngine { defaultReason: "Template execution aborted", }) ) { + this.choiceExecutor.signalAbort?.(err as MacroAbortError); return; } reportError(err, `Error running template choice "${this.choice.name}"`); diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index 6397dad4..4910d891 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -17,6 +17,16 @@ import { isCancellationError } from "../utils/errorUtils"; import type { IChoiceExecutor } from "../IChoiceExecutor"; import { log } from "../logger/logManager"; +function isMacroAbortError(error: unknown): error is MacroAbortError { + return ( + error instanceof MacroAbortError || + (Boolean(error) && + typeof error === "object" && + "name" in (error as Record) && + (error as { name?: string }).name === "MacroAbortError") + ); +} + export abstract class TemplateEngine extends QuickAddEngine { protected formatter: CompleteFormatter; protected readonly templater; @@ -170,6 +180,9 @@ export abstract class TemplateEngine extends QuickAddEngine { return createdFile; } catch (err) { + if (isMacroAbortError(err)) { + throw err; + } reportError(err, `Could not create file with template at ${filePath}`); return null; } @@ -217,6 +230,9 @@ export abstract class TemplateEngine extends QuickAddEngine { return file; } catch (err) { + if (isMacroAbortError(err)) { + throw err; + } reportError(err, "Could not overwrite file with template"); return null; } @@ -250,6 +266,9 @@ export abstract class TemplateEngine extends QuickAddEngine { return file; } catch (err) { + if (isMacroAbortError(err)) { + throw err; + } reportError(err, "Could not append to file with template"); return null; } diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index fea4a03a..c6ea32e9 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -23,10 +23,11 @@ import type { FieldRequirement } from "./preflight/RequirementCollector"; import { settingsStore } from "./settingsStore"; import type IChoice from "./types/choices/IChoice"; import { getDate } from "./utilityObsidian"; -import { reportError } from "./utils/errorUtils"; +import { isCancellationError, reportError } from "./utils/errorUtils"; import { FieldSuggestionCache } from "./utils/FieldSuggestionCache"; import { FieldSuggestionFileFilter } from "./utils/FieldSuggestionFileFilter"; import { InlineFieldParser } from "./utils/InlineFieldParser"; +import { MacroAbortError } from "./errors/MacroAbortError"; export class QuickAddApi { public static GetApi( @@ -87,15 +88,20 @@ export class QuickAddApi { }); } - let collected: Record = {}; - if (missing.length > 0) { - const modal = new OnePageInputModal( - app, - missing, - choiceExecutor.variables, - ); + let collected: Record = {}; + if (missing.length > 0) { + const modal = new OnePageInputModal( + app, + missing, + choiceExecutor.variables, + ); + try { collected = await modal.waitForClose; + } catch (error) { + throwIfPromptCancelled(error); + throw error; } + } const result = { ...existing, ...collected }; Object.entries(result).forEach(([k, v]) => @@ -496,7 +502,8 @@ export class QuickAddApi { ) { try { return await GenericInputPrompt.Prompt(app, header, placeholder, value); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } @@ -514,7 +521,8 @@ export class QuickAddApi { placeholder, value, ); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } @@ -522,7 +530,8 @@ export class QuickAddApi { public static async yesNoPrompt(app: App, header: string, text?: string) { try { return await GenericYesNoPrompt.Prompt(app, header, text); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } @@ -534,7 +543,8 @@ export class QuickAddApi { ) { try { return await GenericInfoDialog.Show(app, header, text); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } @@ -579,7 +589,8 @@ export class QuickAddApi { placeholder, options?.renderItem, ); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } @@ -591,8 +602,18 @@ export class QuickAddApi { ) { try { return await GenericCheckboxPrompt.Open(app, items, selectedItems); - } catch { + } catch (error) { + throwIfPromptCancelled(error); return undefined; } } } + +function throwIfPromptCancelled(error: unknown): void { + if (error instanceof MacroAbortError) { + throw error; + } + if (isCancellationError(error)) { + throw new MacroAbortError("Input cancelled by user"); + } +} From a80d2a7dd1f250a79e6241928143ed5846beacbf Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 17:56:00 +0100 Subject: [PATCH 03/12] test: relax nested choice abort expectations --- src/engine/MacroChoiceEngine.notice.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/engine/MacroChoiceEngine.notice.test.ts b/src/engine/MacroChoiceEngine.notice.test.ts index 88ca04e1..7c111cbb 100644 --- a/src/engine/MacroChoiceEngine.notice.test.ts +++ b/src/engine/MacroChoiceEngine.notice.test.ts @@ -246,7 +246,8 @@ describe("MacroChoiceEngine nested choice propagation", () => { await engine.run(); expect(choiceExecutor.execute).toHaveBeenCalledTimes(1); - expect(signalAbort).toHaveBeenCalledTimes(1); + expect(signalAbort).toHaveBeenCalled(); + expect(signalAbort.mock.calls.at(-1)?.[0]).toBeInstanceOf(MacroAbortError); expect(consumeAbortSignal).toHaveBeenCalledTimes(1); expect(engine.obsidianExecutions).toBe(0); }); From 2ea4f8be39780cc11d20c83812eb96936f86a886 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:01:01 +0100 Subject: [PATCH 04/12] chore: silence vitest deps warning --- vitest.config.mts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vitest.config.mts b/vitest.config.mts index b79d5c33..aea642c3 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -21,7 +21,11 @@ export default defineConfig({ globals: true, environment: "jsdom", deps: { - inline: ["obsidian"], + optimizer: { + web: { + include: ["obsidian"], + }, + }, }, }, }); From 40a78c8ef3f0b1dcadf4c8f1890db52faf50e9ff Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:04:02 +0100 Subject: [PATCH 05/12] docs: note MacroAbortError on prompt cancellation --- docs/docs/Advanced/onePageInputs.md | 1 + docs/docs/QuickAddAPI.md | 32 ++++++++++------- docs/docs/UserScripts.md | 53 +++++++++++++++++++---------- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/docs/docs/Advanced/onePageInputs.md b/docs/docs/Advanced/onePageInputs.md index dcccf93b..834317d3 100644 --- a/docs/docs/Advanced/onePageInputs.md +++ b/docs/docs/Advanced/onePageInputs.md @@ -184,3 +184,4 @@ Behavior: - Preflight may import user script modules to statically read `quickadd.inputs`. This can execute module top-level code. - Inline scripts aren’t scanned for input declarations yet. - If needed, you can still prompt ad-hoc (e.g., using inputPrompt or suggester) and those values will skip future one-page prompts due to being prefilled. +- Closing the modal without submitting triggers `MacroAbortError("Input cancelled by user")`, which stops the macro unless you catch it. diff --git a/docs/docs/QuickAddAPI.md b/docs/docs/QuickAddAPI.md index 81073846..83201cb8 100644 --- a/docs/docs/QuickAddAPI.md +++ b/docs/docs/QuickAddAPI.md @@ -38,6 +38,7 @@ Opens a one-page modal to collect multiple inputs in one go. Values already pres **Behavior:** - Uses existing values for any ids that already exist in `variables` (including empty strings). - Prompts only for missing (`undefined`/`null`) inputs. +- If the user closes the modal without submitting, the promise rejects with `MacroAbortError("Input cancelled by user")`. **Field Types:** - `text`: Single-line text input @@ -115,18 +116,25 @@ Opens a prompt that asks for text input. - `placeholder`: (Optional) Placeholder text in the input field - `value`: (Optional) Default value -**Returns:** Promise resolving to the entered string, or `null` if cancelled +**Returns:** Promise resolving to the entered string. + +**Cancellation:** If the user cancels or presses Escape, the promise rejects with `MacroAbortError("Input cancelled by user")`. Letting it bubble will stop the macro automatically. Catch it only if your script wants to handle the cancellation itself. **Example:** ```javascript -const name = await quickAddApi.inputPrompt( - "What's your name?", - "Enter your full name", - "John Doe" -); - -if (name) { - console.log(`Hello, ${name}!`); +try { + const name = await quickAddApi.inputPrompt( + "What's your name?", + "Enter your full name", + "John Doe" + ); + console.log(`Hello, ${name}!`); +} catch (error) { + if (error?.name === "MacroAbortError") { + // Optional: perform cleanup before QuickAdd aborts the macro + return; + } + throw error; } ``` @@ -135,7 +143,7 @@ Opens a wider prompt for longer text input (multi-line). **Parameters:** Same as `inputPrompt` -**Returns:** Promise resolving to the entered string, or `null` if cancelled +**Returns:** Promise resolving to the entered string. Cancelling rejects with `MacroAbortError` (same as `inputPrompt`). **Example:** ```javascript @@ -153,7 +161,7 @@ Opens a confirmation dialog with Yes/No buttons. - `header`: The dialog title - `text`: (Optional) Additional explanation text -**Returns:** Promise resolving to `true` (Yes) or `false` (No) +**Returns:** Promise resolving to `true` (Yes) or `false` (No). If the user closes the dialog without answering, the promise rejects with `MacroAbortError`. **Example:** ```javascript @@ -196,7 +204,7 @@ Opens a selection prompt with searchable options. Can optionally allow custom in - `allowCustomInput`: (Optional) When `true`, allows users to enter custom text not in `actualItems`. Defaults to `false` - `options.renderItem`: (Optional) Custom renderer `(value, el) => void` to control how each suggestion row is drawn -**Returns:** Promise resolving to the selected value or custom input, or `null` if cancelled +**Returns:** Promise resolving to the selected value or custom input. Cancelling rejects with `MacroAbortError`. **Examples:** diff --git a/docs/docs/UserScripts.md b/docs/docs/UserScripts.md index 9d266687..cd941955 100644 --- a/docs/docs/UserScripts.md +++ b/docs/docs/UserScripts.md @@ -297,27 +297,44 @@ module.exports = async (params) => { - No error is thrown to the user **QuickAdd API methods that can be cancelled:** -- `inputPrompt()` - Returns `undefined` if cancelled -- `wideInputPrompt()` - Returns `undefined` if cancelled -- `yesNoPrompt()` - Returns `undefined` if cancelled -- `suggester()` - Aborts macro if cancelled -- `checkboxPrompt()` - Returns `undefined` if cancelled +- `inputPrompt()` +- `wideInputPrompt()` +- `yesNoPrompt()` +- `suggester()` +- `checkboxPrompt()` -**Important:** When using the QuickAdd API, check for `undefined` to handle cancellations gracefully: +Each of these now rejects with `MacroAbortError("Input cancelled by user")` when the user presses Escape or closes the dialog. If you do nothing, the macro will automatically stop (matching user expectations). If you want to handle cancellation in your script, wrap the call in `try/catch` and intercept the error before it reaches the macro engine. + +```javascript +try { + const name = await quickAddApi.inputPrompt("Your name:"); +} catch (error) { + if (error?.name === "MacroAbortError") { + // Optional custom handling (e.g., cleanup) before the macro aborts + return; + } + throw error; // real errors should still bubble up +} +``` + +**Important:** Because cancellations now throw, you should only call `abort()` yourself when you want to provide a custom message or stop execution for a non-prompt reason. ```javascript module.exports = async (params) => { - const { quickAddApi, abort } = params; - - const name = await quickAddApi.inputPrompt("Your name:"); - - // Handle cancellation - if (!name) { - abort("Name is required"); - } - - // Safe to use name here - console.log(`Processing: ${name}`); + const { quickAddApi, abort } = params; + + let name; + try { + name = await quickAddApi.inputPrompt("Your name:"); + } catch (error) { + if (error?.name === "MacroAbortError") { + abort("Name is required"); + return; + } + throw error; + } + + console.log(`Processing: ${name}`); }; ``` @@ -906,4 +923,4 @@ For complete working examples, see: **API methods returning undefined:** - Ensure you're using `await` with async methods - Check that QuickAdd plugin is enabled -- Verify you're accessing the API correctly through `params.quickAddApi` \ No newline at end of file +- Verify you're accessing the API correctly through `params.quickAddApi` From 145123ce55872f912c516315f8714905dd4afc1d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:28:03 +0100 Subject: [PATCH 06/12] fix: propagate QuickAddApi executeChoice aborts --- src/quickAddApi.executeChoice.test.ts | 68 +++++++++++++++++++++++++++ src/quickAddApi.ts | 4 ++ tests/obsidian-stub.ts | 20 ++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/quickAddApi.executeChoice.test.ts diff --git a/src/quickAddApi.executeChoice.test.ts b/src/quickAddApi.executeChoice.test.ts new file mode 100644 index 00000000..73423191 --- /dev/null +++ b/src/quickAddApi.executeChoice.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import { QuickAddApi } from "./quickAddApi"; +import type QuickAdd from "./main"; +import type { IChoiceExecutor } from "./IChoiceExecutor"; +import type IChoice from "./types/choices/IChoice"; +import { MacroAbortError } from "./errors/MacroAbortError"; + +vi.mock("./quickAddSettingsTab", () => ({ + DEFAULT_SETTINGS: {}, + QuickAddSettingsTab: class {}, +})); + +vi.mock("./formatters/completeFormatter", () => ({ + CompleteFormatter: class CompleteFormatterMock {}, +})); + +vi.mock("obsidian-dataview", () => ({ + getAPI: vi.fn(), +})); + +describe("QuickAddApi.executeChoice", () => { + const app = {} as App; + let plugin: QuickAdd & { getChoiceByName: ReturnType; }; + let choiceExecutor: IChoiceExecutor; + let variables: Map; + const choice: IChoice = { + id: "template", + name: "My Template", + type: "Template", + command: false, + }; + + beforeEach(() => { + variables = new Map(); + choiceExecutor = { + execute: vi.fn().mockResolvedValue(undefined), + variables, + consumeAbortSignal: vi.fn().mockReturnValue(null), + }; + plugin = { + getChoiceByName: vi.fn().mockReturnValue(choice), + } as unknown as QuickAdd & { + getChoiceByName: ReturnType; + }; + }); + + it("propagates aborts from executed choices", async () => { + const abortError = new MacroAbortError("Input cancelled by user"); + (choiceExecutor.consumeAbortSignal as ReturnType).mockReturnValueOnce(abortError); + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + + variables.set("foo", "bar"); + await expect(api.executeChoice("My Template")) + .rejects.toBe(abortError); + expect(choiceExecutor.consumeAbortSignal).toHaveBeenCalledTimes(1); + expect(variables.size).toBe(0); + }); + + it("clears variables and resolves when no abort is signalled", async () => { + const api = QuickAddApi.GetApi(app, plugin, choiceExecutor); + await expect( + api.executeChoice("My Template", { project: "QA" }), + ).resolves.toBeUndefined(); + expect(choiceExecutor.consumeAbortSignal).toHaveBeenCalledTimes(1); + expect(variables.size).toBe(0); + }); +}); diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index c6ea32e9..946fcfcd 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -164,7 +164,11 @@ export class QuickAddApi { } await choiceExecutor.execute(choice); + const abort = choiceExecutor.consumeAbortSignal?.(); choiceExecutor.variables.clear(); + if (abort) { + throw abort; + } }, format: async ( input: string, diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts index 01d65a2e..ac808e60 100644 --- a/tests/obsidian-stub.ts +++ b/tests/obsidian-stub.ts @@ -50,6 +50,25 @@ export const App = class { }; }; +export const Plugin = class { + app: App; + manifest: { dir: string }; + + constructor() { + this.app = new App(); + this.manifest = { dir: "" }; + } + + addRibbonIcon() { return { addClass() {}, setAttr() {} }; } + addCommand() { return { id: "", name: "" }; } + addSettingTab() {} + registerEvent() {} + registerInterval() {} + registerDomEvent() {} + onunload() {} + async onload() {} +}; + export const TFile = class { path = ""; name = ""; @@ -196,6 +215,7 @@ export function normalizePath(p: string): string { // Default export for compatibility export default { App, + Plugin, TFile, TFolder, MarkdownView, From 145fd94f2c89289a0e588dc9258588f9c858927f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:32:07 +0100 Subject: [PATCH 07/12] fix: stabilize tests and build --- src/engine/MacroChoiceEngine.entry.test.ts | 11 ++----- src/engine/SingleMacroEngine.ts | 2 +- .../TemplateChoiceEngine.notice.test.ts | 10 +++---- tests/obsidian-stub.ts | 30 +++++++++---------- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index 0f75e5a1..483a8331 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -90,14 +90,6 @@ vi.mock("../main", () => ({ default: class QuickAddMock {}, })); -vi.mock("../quickAddApi", async () => { - const actual = await vi.importActual( - "../quickAddApi", - ); - actual.QuickAddApi.GetApi = mockGetApi as typeof actual.QuickAddApi.GetApi; - return actual; -}); - vi.mock("../quickAddSettingsTab", () => ({ DEFAULT_SETTINGS: {}, QuickAddSettingsTab: class {}, @@ -252,6 +244,9 @@ describe("MacroChoiceEngine choice command cancellation", () => { }); it("wraps cancellation errors from executeChoice in MacroAbortError", async () => { + const getApiSpy = vi.spyOn(QuickAddApi, "GetApi"); + getApiSpy.mockReturnValueOnce({} as any); + const engine = new MacroChoiceEngine( app, plugin, diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 84f41a4f..4b05f607 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -12,7 +12,7 @@ import { flattenChoices } from "../utils/choiceUtils"; import { initializeUserScriptSettings } from "../utils/userScriptSettings"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { handleMacroAbort } from "../utils/macroAbortHandler"; -import { MacroAbortError } from "../errors/MacroAbortError"; +import type { MacroAbortError } from "../errors/MacroAbortError"; export class SingleMacroEngine { private readonly choiceExecutor: IChoiceExecutor; diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 310f6fee..2b60c641 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -40,12 +40,10 @@ vi.mock("../quickAddSettingsTab", () => { }; }); -const { formatFileNameMock, formatFileContentMock } = vi.hoisted(() => { - return { - formatFileNameMock: vi.fn<(format: string, prompt: string) => Promise>(), - formatFileContentMock: vi.fn<() => Promise>().mockResolvedValue(""), - }; -}); +const formatFileNameMock = vi.fn<(format: string, prompt: string) => Promise>(); +const formatFileContentMock = vi + .fn<(...args: unknown[]) => Promise>() + .mockResolvedValue(""); vi.mock("../formatters/completeFormatter", () => { class CompleteFormatterMock { diff --git a/tests/obsidian-stub.ts b/tests/obsidian-stub.ts index ac808e60..3005a9dc 100644 --- a/tests/obsidian-stub.ts +++ b/tests/obsidian-stub.ts @@ -20,7 +20,7 @@ const moment = (...args: any[]) => { (globalThis as any).window ??= globalThis; (globalThis as any).window.moment = moment; -export const App = class { +export class App { workspace: any = { getActiveViewOfType: () => undefined, getLeaf: () => ({}), @@ -48,9 +48,9 @@ export const App = class { plugins: any = { plugins: {}, }; -}; +} -export const Plugin = class { +export class Plugin { app: App; manifest: { dir: string }; @@ -67,43 +67,43 @@ export const Plugin = class { registerDomEvent() {} onunload() {} async onload() {} -}; +} -export const TFile = class { +export class TFile { path = ""; name = ""; extension = ""; basename = ""; parent = null; -}; +} -export const TFolder = class { +export class TFolder { path = ""; name = ""; children = []; -}; +} -export const MarkdownView = class { +export class MarkdownView { editor = { getCursor() {return {line:0,ch:0};}, replaceSelection() {}, setCursor() {} }; file?: any; -}; +} -export const FileView = class { +export class FileView { file?: any; -}; +} -export const WorkspaceLeaf = class { +export class WorkspaceLeaf { view: any; getViewState() {return {state:{}};} setViewState(){} openFile(){} -}; +} -export const FuzzySuggestModal = class { +export class FuzzySuggestModal { app: any; inputEl: HTMLInputElement; limit?: number; From 30204e0241301f572b6ef4b8ed823f19b3f4a44e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:44:51 +0100 Subject: [PATCH 08/12] test: fix cancellation mocks on linux --- src/engine/MacroChoiceEngine.entry.test.ts | 2 +- src/engine/TemplateChoiceEngine.notice.test.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index 483a8331..5a944b34 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -39,7 +39,7 @@ vi.mock("../utils/userScriptSettings", () => ({ vi.mock("../gui/choiceList/ChoiceView.svelte", () => ({})); vi.mock("../gui/GlobalVariables/GlobalVariablesView.svelte", () => ({})); -vi.mock("../gui/GenericInputPrompt/genericInputPrompt", () => ({ +vi.mock("../gui/GenericInputPrompt/GenericInputPrompt", () => ({ __esModule: true, default: { Prompt: mockInputPrompt, diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 2b60c641..6a9732e7 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -40,10 +40,17 @@ vi.mock("../quickAddSettingsTab", () => { }; }); -const formatFileNameMock = vi.fn<(format: string, prompt: string) => Promise>(); -const formatFileContentMock = vi - .fn<(...args: unknown[]) => Promise>() - .mockResolvedValue(""); +const { formatFileNameMock, formatFileContentMock } = vi.hoisted(() => { + const formatName = vi.fn<(format: string, prompt: string) => Promise>(); + const formatContent = vi + .fn<(...args: unknown[]) => Promise>() + .mockResolvedValue(""); + + return { + formatFileNameMock: formatName, + formatFileContentMock: formatContent, + }; +}); vi.mock("../formatters/completeFormatter", () => { class CompleteFormatterMock { From 32dd1bf07fe2f57cf7e8787dc6b9a30b582f54a3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 18:57:40 +0100 Subject: [PATCH 09/12] fix: propagate nested macro aborts --- .../SingleMacroEngine.member-access.test.ts | 84 +++++++++++++++++++ src/engine/SingleMacroEngine.ts | 14 ++++ 2 files changed, 98 insertions(+) diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index aa7a6345..88d38210 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -356,4 +356,88 @@ describe("SingleMacroEngine member access", () => { expect(abortFn).toHaveBeenCalledTimes(1); expect(engineInstance.setOutput).not.toHaveBeenCalled(); }); + + it("propagates abort signals when export pre-commands cancel", async () => { + const preCommand = { + id: "wait-1", + name: "Wait", + type: CommandType.Wait, + } as ICommand; + + const userScript: IUserScript = { + id: "user-script", + name: "Script", + type: CommandType.UserScript, + path: "script.js", + settings: {}, + }; + + const macroChoice = baseMacroChoice([preCommand, userScript]); + const choices: IChoice[] = [macroChoice]; + + const engineInstance = macroEngineFactory(); + engineInstance.runSubset = vi.fn().mockResolvedValue(undefined); + engineInstance.getOutput = vi.fn(); + macroEngineFactory = () => engineInstance; + + const abortError = new MacroAbortError("stop"); + const consumeAbortSignal = vi + .fn>() + .mockReturnValueOnce(abortError) + .mockReturnValue(null); + choiceExecutor.consumeAbortSignal = consumeAbortSignal; + + const engine = new SingleMacroEngine( + app, + plugin, + choices, + choiceExecutor, + ); + + await expect(engine.runAndGetOutput("My Macro::run")).rejects.toBe( + abortError, + ); + + expect(engineInstance.runSubset).toHaveBeenCalledTimes(1); + expect(mockGetUserScript).not.toHaveBeenCalled(); + }); + + it("propagates abort signals when the full macro run cancels", async () => { + const userScript: IUserScript = { + id: "user-script", + name: "Script", + type: CommandType.UserScript, + path: "script.js", + settings: {}, + }; + + const macroChoice = baseMacroChoice([userScript]); + const choices: IChoice[] = [macroChoice]; + + const engineInstance = macroEngineFactory(); + engineInstance.run = vi.fn().mockResolvedValue(undefined); + engineInstance.getOutput = vi.fn(); + macroEngineFactory = () => engineInstance; + + const abortError = new MacroAbortError("whole-macro"); + const consumeAbortSignal = vi + .fn>() + .mockReturnValueOnce(abortError) + .mockReturnValue(null); + choiceExecutor.consumeAbortSignal = consumeAbortSignal; + + const engine = new SingleMacroEngine( + app, + plugin, + choices, + choiceExecutor, + ); + + await expect(engine.runAndGetOutput("My Macro")).rejects.toBe( + abortError, + ); + + expect(engineInstance.run).toHaveBeenCalledTimes(1); + expect(engineInstance.getOutput).not.toHaveBeenCalled(); + }); }); diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 4b05f607..34d7ac68 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -104,6 +104,8 @@ export class SingleMacroEngine { preloadedScripts, ); + this.ensureNotAborted(); + if (exportAttempt.executed) { return this.formatResult(exportAttempt.result); } @@ -111,6 +113,7 @@ export class SingleMacroEngine { // Always execute the whole macro first await engine.run(); + this.ensureNotAborted(); let result: unknown = engine.getOutput(); // Apply member access afterwards (if requested) @@ -156,6 +159,7 @@ export class SingleMacroEngine { try { if (preCommands.length) { await engine.runSubset(preCommands); + this.ensureNotAborted(); } const updatedCommands = macroChoice.macro?.commands ?? originalCommands; @@ -220,12 +224,14 @@ export class SingleMacroEngine { engine, userScriptCommand.settings, ); + this.ensureNotAborted(); engine.setOutput(result); this.syncVariablesFromParams(engine); if (postCommands.length) { await engine.runSubset(postCommands); + this.ensureNotAborted(); } return { @@ -309,6 +315,7 @@ export class SingleMacroEngine { ): Promise<{ executed: boolean; result?: unknown }> { if (remainingCommands.length) { await engine.runSubset(remainingCommands); + this.ensureNotAborted(); } this.syncVariablesFromParams(engine); @@ -383,4 +390,11 @@ export class SingleMacroEngine { return String(result); } + + private ensureNotAborted() { + const abort = this.choiceExecutor.consumeAbortSignal?.(); + if (abort) { + throw abort; + } + } } From 138f0d6ae3f1a2af0fd022b62b64b8a5448ccd4e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 21:58:37 +0100 Subject: [PATCH 10/12] fix: rethrow nested macro aborts --- src/engine/SingleMacroEngine.member-access.test.ts | 7 ++++--- src/engine/SingleMacroEngine.ts | 5 +---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index 88d38210..c229351e 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -317,7 +317,7 @@ describe("SingleMacroEngine member access", () => { expect(result).toBe("from-output"); }); - it("handles macro abort gracefully when the export aborts", async () => { + it("propagates aborts when the export aborts", async () => { const userScript: IUserScript = { id: "user-script", name: "Script", @@ -350,9 +350,10 @@ describe("SingleMacroEngine member access", () => { choiceExecutor, ); - const result = await engine.runAndGetOutput("My Macro::f"); + await expect(engine.runAndGetOutput("My Macro::f")).rejects.toBeInstanceOf( + MacroAbortError, + ); - expect(result).toBe(""); expect(abortFn).toHaveBeenCalledTimes(1); expect(engineInstance.setOutput).not.toHaveBeenCalled(); }); diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 34d7ac68..43c27f77 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -247,10 +247,7 @@ export class SingleMacroEngine { }) ) { this.choiceExecutor.signalAbort?.(error as MacroAbortError); - return { - executed: true, - result: "", - }; + throw error; } throw error; } From 8710d2732c226f4a0bc2596c0200daefb228d519 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 22:46:25 +0100 Subject: [PATCH 11/12] docs: clarify prompt cancellation guidance --- docs/docs/UserScripts.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/docs/UserScripts.md b/docs/docs/UserScripts.md index cd941955..b024b75f 100644 --- a/docs/docs/UserScripts.md +++ b/docs/docs/UserScripts.md @@ -287,9 +287,11 @@ module.exports = async (params) => { **When to use `params.abort()`:** - Input validation failures - Missing required configuration -- User cancels a confirmation prompt +- You want to provide a custom message after catching a `MacroAbortError` - Prerequisites not met (e.g., required plugin not installed) +Prompt cancellations already throw `MacroAbortError` and halt macros automatically, so only call `abort()` in those scenarios if you need to surface a custom message or you're stopping for a non-prompt reason. + **What happens when you call `abort()`:** - Macro execution stops immediately - A message is logged: "Macro execution aborted: [your message]" From 67752f1c3ea89c45b356f6e597a99f8cd4a7541f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 7 Nov 2025 22:51:20 +0100 Subject: [PATCH 12/12] docs: replace tabs --- docs/docs/UserScripts.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/docs/UserScripts.md b/docs/docs/UserScripts.md index b024b75f..2c6eea79 100644 --- a/docs/docs/UserScripts.md +++ b/docs/docs/UserScripts.md @@ -309,13 +309,13 @@ Each of these now rejects with `MacroAbortError("Input cancelled by user")` when ```javascript try { - const name = await quickAddApi.inputPrompt("Your name:"); + const name = await quickAddApi.inputPrompt("Your name:"); } catch (error) { - if (error?.name === "MacroAbortError") { - // Optional custom handling (e.g., cleanup) before the macro aborts - return; - } - throw error; // real errors should still bubble up + if (error?.name === "MacroAbortError") { + // Optional custom handling (e.g., cleanup) before the macro aborts + return; + } + throw error; // real errors should still bubble up } ``` @@ -323,20 +323,20 @@ try { ```javascript module.exports = async (params) => { - const { quickAddApi, abort } = params; - - let name; - try { - name = await quickAddApi.inputPrompt("Your name:"); - } catch (error) { - if (error?.name === "MacroAbortError") { - abort("Name is required"); - return; - } - throw error; - } - - console.log(`Processing: ${name}`); + const { quickAddApi, abort } = params; + + let name; + try { + name = await quickAddApi.inputPrompt("Your name:"); + } catch (error) { + if (error?.name === "MacroAbortError") { + abort("Name is required"); + return; + } + throw error; + } + + console.log(`Processing: ${name}`); }; ```