Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ For detailed instructions and examples on using QuickAdd, see the [QuickAdd docu

QuickAdd uses `bun` for local development tasks:

- `bun run dev` starts the esbuild watcher for local plugin development.
- `bun run lint` runs the TypeScript ESLint checks.
- `bun run test` runs the unit test suite.
- `bun run build` type-checks and bundles the plugin.
- `bun run test:e2e` runs Obsidian-backed end-to-end tests.
Expand All @@ -38,6 +40,25 @@ app, the `obsidian` CLI being available on `PATH`, and the `dev` vault being
open and reachable. Failed E2E runs may write artifacts to
`.obsidian-e2e-artifacts/`.

When validating changes against the local dev vault, rebuild first and reload
the plugin with `obsidian vault=dev plugin:reload id=quickadd`.

### Engine architecture

QuickAdd choice execution is orchestrated from `ChoiceExecutor`, which owns
shared variables, preflight, abort signaling, origin leaf capture, and choice
dispatch. Template, capture, and macro choices are implemented as flat
orchestrators (`TemplateChoiceEngine`, `CaptureChoiceEngine`, and
`MacroChoiceEngine`) that compose explicit services instead of inheriting from a
shared engine base.

File and frontmatter operations live in `VaultFileService` and
`FrontmatterPropertyService`; folder and template-file behavior lives in
`FolderSelectionService`, `TemplateFileService`, and `TemplateEvaluator`.
Formatter macro, template, and inline JavaScript tokens are delegated through
evaluator interfaces wired by `FormatterFactory`; preview and preflight paths
remain non-executing.

## Support

If you have any questions or encounter any problems while using QuickAdd, you can use the [community discussions](https://github.com/chhoumann/quickadd/discussions) for support.
241 changes: 241 additions & 0 deletions src/choiceExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { App, WorkspaceLeaf } from "obsidian";
import type QuickAdd from "./main";
import type IChoice from "./types/choices/IChoice";
import type IMultiChoice from "./types/choices/IMultiChoice";
import { MacroAbortError } from "./errors/MacroAbortError";
import { settingsStore } from "./settingsStore";

const mocks = vi.hoisted(() => {
const runOnePagePreflight = vi.fn();
const getOpenFileOriginLeaf = vi.fn();
const templateRun = vi.fn();
const captureRun = vi.fn();
const macroRun = vi.fn();
const templateConstructors: unknown[][] = [];
const captureConstructors: unknown[][] = [];
const macroConstructors: unknown[][] = [];
const choiceSuggesterOpen = vi.fn();

return {
runOnePagePreflight,
getOpenFileOriginLeaf,
templateRun,
captureRun,
macroRun,
templateConstructors,
captureConstructors,
macroConstructors,
choiceSuggesterOpen,
};
});

vi.mock("./preflight/runOnePagePreflight", () => ({
runOnePagePreflight: mocks.runOnePagePreflight,
}));

vi.mock("./utilityObsidian", () => ({
getOpenFileOriginLeaf: mocks.getOpenFileOriginLeaf,
}));

vi.mock("./engine/TemplateChoiceEngine", () => ({
TemplateChoiceEngine: vi.fn(function (...args: unknown[]) {
mocks.templateConstructors.push(args);
return { run: mocks.templateRun };
}),
}));

vi.mock("./engine/CaptureChoiceEngine", () => ({
CaptureChoiceEngine: vi.fn(function (...args: unknown[]) {
mocks.captureConstructors.push(args);
return { run: mocks.captureRun };
}),
}));

vi.mock("./engine/MacroCommandRunner", () => ({
MacroCommandRunner: vi.fn(function (...args: unknown[]) {
mocks.macroConstructors.push(args);
return {
run: mocks.macroRun,
params: { variables: {} },
};
}),
}));

vi.mock("./engine/InlineJavaScriptEvaluator", () => ({
InlineJavaScriptEvaluator: vi.fn(function () {
return { runAndGetOutput: vi.fn() };
}),
}));

vi.mock("./engine/SingleMacroEngine", () => ({
SingleMacroEngine: vi.fn(function () {
return { runAndGetOutput: vi.fn() };
}),
}));

vi.mock("./gui/suggesters/choiceSuggester", () => ({
default: {
Open: mocks.choiceSuggesterOpen,
},
}));

import { ChoiceExecutor } from "./choiceExecutor";

describe("ChoiceExecutor", () => {
const app = {} as App;
const originLeaf = { id: "origin-leaf" } as unknown as WorkspaceLeaf;
let plugin: QuickAdd;

beforeEach(() => {
plugin = {
settings: {
choices: [],
},
} as unknown as QuickAdd;
settingsStore.setState({ onePageInputEnabled: false });
mocks.runOnePagePreflight.mockReset();
mocks.getOpenFileOriginLeaf.mockReset();
mocks.getOpenFileOriginLeaf.mockReturnValue(originLeaf);
mocks.templateRun.mockReset();
mocks.templateRun.mockResolvedValue(undefined);
mocks.captureRun.mockReset();
mocks.captureRun.mockResolvedValue(undefined);
mocks.macroRun.mockReset();
mocks.macroRun.mockResolvedValue(undefined);
mocks.choiceSuggesterOpen.mockReset();
mocks.templateConstructors.length = 0;
mocks.captureConstructors.length = 0;
mocks.macroConstructors.length = 0;
});

it.each(["Template", "Capture", "Macro"] as const)(
"runs preflight before dispatching %s choices when one-page input is enabled",
async (type) => {
const executor = new ChoiceExecutor(app, plugin);
const choice = createChoice(type, { onePageInput: "always" });

await executor.execute(choice);

expect(mocks.runOnePagePreflight).toHaveBeenCalledWith(
app,
plugin,
executor,
choice,
);
expect(runtimeRunsFor(type)).toHaveBeenCalledTimes(1);
},
);

it("throws MacroAbortError and suppresses runtime construction after preflight cancellation", async () => {
const executor = new ChoiceExecutor(app, plugin);
mocks.runOnePagePreflight.mockRejectedValueOnce("cancelled");

await expect(
executor.execute(createChoice("Template", { onePageInput: "always" })),
).rejects.toMatchObject({
name: "MacroAbortError",
message: "One-page input cancelled by user",
});
expect(mocks.templateConstructors).toHaveLength(0);
expect(mocks.captureConstructors).toHaveLength(0);
expect(mocks.macroConstructors).toHaveLength(0);
});

it("skips preflight for Multi choices and opens the multi suggester", async () => {
const executor = new ChoiceExecutor(app, plugin);
const child = createChoice("Template");
const multi: IMultiChoice = {
...createChoice("Multi"),
choices: [child],
collapsed: false,
placeholder: "Pick one",
};

await executor.execute(multi);

expect(mocks.runOnePagePreflight).not.toHaveBeenCalled();
expect(mocks.choiceSuggesterOpen).toHaveBeenCalledWith(plugin, [child], {
choiceExecutor: executor,
placeholder: "Pick one",
});
});

it.each([
["Template", mocks.templateConstructors],
["Capture", mocks.captureConstructors],
] as const)(
"captures and passes the origin leaf to %s runtime construction",
async (type, constructors) => {
const executor = new ChoiceExecutor(app, plugin);
const choice = createChoice(type);

await executor.execute(choice);

expect(mocks.getOpenFileOriginLeaf).toHaveBeenCalledWith(app);
expect(constructors[0]?.at(-1)).toBe(originLeaf);
},
);

it("passes the origin leaf and shared variables map to macro runtime construction", async () => {
const executor = new ChoiceExecutor(app, plugin);
executor.variables.set("seed", "value");
const choice = createChoice("Macro");

await executor.execute(choice);

expect(mocks.macroConstructors[0]?.[4]).toBe(executor.variables);
expect(mocks.macroConstructors[0]?.at(-1)).toBe(originLeaf);
});

it("keeps the same variable map instance across preflight and dispatch", async () => {
const executor = new ChoiceExecutor(app, plugin);
const seenMaps: Map<string, unknown>[] = [];
mocks.runOnePagePreflight.mockImplementationOnce(
async (_app, _plugin, choiceExecutor: ChoiceExecutor) => {
seenMaps.push(choiceExecutor.variables);
choiceExecutor.variables.set("fromPreflight", "submitted");
},
);

await executor.execute(createChoice("Macro", { onePageInput: "always" }));

expect(seenMaps[0]).toBe(executor.variables);
expect(mocks.macroConstructors[0]?.[4]).toBe(executor.variables);
expect(executor.variables.get("fromPreflight")).toBe("submitted");
});

it("records and consumes abort signals exactly once", () => {
const executor = new ChoiceExecutor(app, plugin);
const abort = new MacroAbortError("stopped");

executor.signalAbort(abort);

expect(executor.consumeAbortSignal()).toBe(abort);
expect(executor.consumeAbortSignal()).toBeNull();
});
});

function createChoice(
type: IChoice["type"],
extras: Partial<IChoice> = {},
): IChoice {
return {
id: `${type.toLowerCase()}-choice`,
name: `${type} Choice`,
type,
command: false,
...extras,
};
}

function runtimeRunsFor(type: "Template" | "Capture" | "Macro") {
switch (type) {
case "Template":
return mocks.templateRun;
case "Capture":
return mocks.captureRun;
case "Macro":
return mocks.macroRun;
}
}
8 changes: 4 additions & 4 deletions src/choiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import type ICaptureChoice from "./types/choices/ICaptureChoice";
import type IMacroChoice from "./types/choices/IMacroChoice";
import { TemplateChoiceEngine } from "./engine/TemplateChoiceEngine";
import { CaptureChoiceEngine } from "./engine/CaptureChoiceEngine";
import { MacroChoiceEngine } from "./engine/MacroChoiceEngine";
import { SingleInlineScriptEngine } from "./engine/SingleInlineScriptEngine";
import { MacroCommandRunner } from "./engine/MacroCommandRunner";
import { InlineJavaScriptEvaluator } from "./engine/InlineJavaScriptEvaluator";
import { SingleMacroEngine } from "./engine/SingleMacroEngine";
import type { IChoiceExecutor } from "./IChoiceExecutor";
import type { FormatterEvaluatorContext } from "./formatters/formatterEvaluators";
Expand Down Expand Up @@ -56,7 +56,7 @@ export class ChoiceExecutor implements IChoiceExecutor {
code: string,
context: FormatterEvaluatorContext,
): Promise<unknown> {
const executor = new SingleInlineScriptEngine(
const executor = new InlineJavaScriptEvaluator(
this.app,
this.plugin,
this,
Expand Down Expand Up @@ -151,7 +151,7 @@ export class ChoiceExecutor implements IChoiceExecutor {
macroChoice: IMacroChoice,
originLeaf: WorkspaceLeaf | null,
) {
const macroEngine = new MacroChoiceEngine(
const macroEngine = new MacroCommandRunner(
this.app,
this.plugin,
macroChoice,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ vi.mock("src/gui/MathModal", () => ({
},
}));

vi.mock("../engine/SingleInlineScriptEngine", () => ({
vi.mock("../engine/InlineJavaScriptEvaluator", () => ({
__esModule: true,
SingleInlineScriptEngine: class {
InlineJavaScriptEvaluator: class {
public params = { variables: {} as Record<string, unknown> };
async runAndGetOutput() {
return "";
Expand Down
45 changes: 45 additions & 0 deletions src/engine/InlineJavaScriptEvaluator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { App } from "obsidian";
import type { IChoiceExecutor } from "../IChoiceExecutor";
import type QuickAdd from "../main";
import { MacroExecutionContext } from "./MacroExecutionContext";

type AsyncFunctionConstructor = new (code: string) => () => Promise<unknown>;

type LegacyInlineScriptEngineShape = {
params: MacroExecutionContext["params"];
app: App;
plugin: QuickAdd;
choiceExecutor: IChoiceExecutor;
variables: Map<string, unknown>;
};

export class InlineJavaScriptEvaluator {
constructor(
private readonly app: App,
private readonly plugin: QuickAdd,
private readonly choiceExecutor: IChoiceExecutor,
private readonly variables: Map<string, unknown>,
) {}

public async runAndGetOutput(code: string): Promise<unknown> {
const context = new MacroExecutionContext(
this.app,
this.plugin,
this.choiceExecutor,
this.variables,
);
const AsyncFunction = Object.getPrototypeOf(
async function () {},
).constructor as AsyncFunctionConstructor;
const userCode = new AsyncFunction(code);
const legacyEngine: LegacyInlineScriptEngineShape = {
params: context.params,
app: this.app,
plugin: this.plugin,
choiceExecutor: this.choiceExecutor,
variables: context.variables,
};

return await userCode.bind(context.params, legacyEngine).call();
}
}
Loading
Loading