diff --git a/src/engine/MacroChoiceEngine.malformed.test.ts b/src/engine/MacroChoiceEngine.malformed.test.ts new file mode 100644 index 000000000..23c06e305 --- /dev/null +++ b/src/engine/MacroChoiceEngine.malformed.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../formatters/completeFormatter", () => ({ + CompleteFormatter: class CompleteFormatterMock {}, +})); +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); +vi.mock("../main", () => ({ default: class QuickAddMock {} })); + +import type { App } from "obsidian"; +import type IMacroChoice from "../types/choices/IMacroChoice"; +import type { IChoiceExecutor } from "../IChoiceExecutor"; +import { CommandType } from "../types/macros/CommandType"; +import { MacroChoiceEngine } from "./MacroChoiceEngine"; +import { log } from "../logger/logManager"; +import { settingsStore } from "../settingsStore"; + +function runMacro(macro: unknown) { + const choice = { + id: "choice-id", + name: "Test choice", + type: "Macro", + command: false, + runOnStartup: false, + macro, + } as unknown as IMacroChoice; + const choiceExecutor: IChoiceExecutor = { + execute: vi.fn(), + variables: new Map(), + }; + return new MacroChoiceEngine( + {} as App, + { settings: settingsStore.getState() } as never, + choice, + choiceExecutor, + new Map(), + ).run(); +} + +const wait = (id: string, time = 1) => ({ + id, + name: "Wait", + type: CommandType.Wait, + time, +}); + +/** + * The run path, over the same malformed shapes the macro EDITOR handles + * (#1593). Two of them were broken outside the UI entirely, because the old + * guard was a truthiness test. + */ +describe("MacroChoiceEngine.run over a malformed command list (#1593)", () => { + let errors: string[]; + + beforeEach(() => { + errors = []; + vi.spyOn(log, "logError").mockImplementation((msg: string) => { + errors.push(msg); + }); + }); + + it.each([ + ["an array-turned-object", { "0": wait("hidden") }], + ["a string", "not a list"], + ["a number", 7], + ])("FAILS, with an actionable message, for %s", async (_l, commands) => { + // Before: `{"0":...}` reached `for..of` and surfaced a bare + // "i is not iterable"; "not a list" reached it INTACT (strings are + // iterable) so the macro reported success having run nothing at all. + // + // It throws rather than logging and returning, so `quickadd:run` reports + // ok:false and automation cannot carry on as if the macro had run. + await expect( + runMacro({ id: "m", name: "M", commands }), + ).rejects.toThrow(/Could not read the commands for macro 'Test choice'/); + }); + + it("names data.json, so the message is actionable", async () => { + await expect( + runMacro({ id: "m", name: "M", commands: "not a list" }), + ).rejects.toThrow(/data\.json/); + }); + + // `commands: []` is what QuickAddMacro's constructor produces, so every + // freshly created macro - and every launch with an unpopulated run-on-startup + // macro - would otherwise raise a 15-second red notice. + it("stays silent for an empty list, which is the healthy default", async () => { + await runMacro({ id: "m", name: "M", commands: [] }); + expect(errors).toEqual([]); + }); + + it.each([ + ["commands is null", { id: "m", name: "M", commands: null }], + ["commands is absent", { id: "m", name: "M" }], + ["the macro is missing", null], + ])("reports a missing macro when %s", async (_label, macro) => { + await runMacro(macro); + expect(errors).toEqual([ + "No commands in the macro for choice 'Test choice'", + ]); + }); + + it("runs the real commands either side of a hole", async () => { + await expect( + runMacro({ id: "m", name: "M", commands: [wait("a"), null, wait("b")] }), + ).resolves.toBeUndefined(); + expect(errors).toEqual([]); + }); +}); + +describe("MacroChoiceEngine conditional branches over a malformed value (#1593)", () => { + let errors: string[]; + + beforeEach(() => { + errors = []; + vi.spyOn(log, "logError").mockImplementation((msg: string) => { + errors.push(msg); + }); + vi.spyOn(log, "logWarning").mockImplementation(() => {}); + }); + + const conditional = (thenCommands: unknown, elseCommands: unknown) => ({ + id: "cond", + name: "If", + type: CommandType.Conditional, + // An undefined variable evaluates false, so the ELSE branch is taken. + condition: { + mode: "variable", + variableName: "undefinedVar", + operator: "isTruthy", + valueType: "string", + }, + thenCommands, + elseCommands, + }); + + it("fails instead of skipping an unreadable branch silently", async () => { + await expect( + runMacro({ + id: "m", + name: "M", + commands: [conditional([], { "0": wait("hidden") })], + }), + ).rejects.toThrow(/Could not read the else commands/); + }); + + // Returning would only exit executeConditional: the outer loop would run + // every command AFTER the conditional, which were only ever meant to follow + // a branch that never ran. + it("does not run the commands after an unreadable conditional", async () => { + const after = { id: "after", name: "Wait", type: CommandType.Wait, time: 1 }; + await expect( + runMacro({ + id: "m", + name: "M", + commands: [conditional([], "not a list"), after], + }), + ).rejects.toThrow(/Could not read the else commands/); + }); + + // A conditional with no else branch is entirely normal, and must stay quiet. + it.each([ + ["an empty array", []], + ["undefined", undefined], + ["null", null], + ])("stays silent for a branch that is %s", async (_label, elseCommands) => { + await runMacro({ + id: "m", + name: "M", + commands: [conditional([], elseCommands)], + }); + expect(errors).toEqual([]); + }); +}); diff --git a/src/engine/MacroChoiceEngine.ts b/src/engine/MacroChoiceEngine.ts index e9984ba96..177bf5c43 100644 --- a/src/engine/MacroChoiceEngine.ts +++ b/src/engine/MacroChoiceEngine.ts @@ -59,6 +59,11 @@ import { evaluateCondition } from "./helpers/conditionalEvaluator"; import { handleMacroAbort } from "../utils/macroAbortHandler"; import { buildOpenFileOptions } from "./helpers/openFileOptions"; import { createVariablesProxy } from "../utils/variablesProxy"; +import { + commandListOf, + hasCommandList, + isUnreadableCommandList, +} from "../utils/macroUtils"; type ConditionalScriptRunner = () => Promise; type UserScriptFunction = ( @@ -263,14 +268,39 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { } async run(): Promise { - if (!this.macro || !this.macro.commands) { - log.logError( - `No commands in the macro for choice '${this.choice.name}'` + // `!this.macro.commands` is truthiness, so it used to wave through the two + // shapes that then failed outside the UI (#1593): an array-turned-object + // reached `for..of` and threw a bare "i is not iterable" at the user, and a + // string reached it INTACT (strings are iterable), so the macro reported + // success having walked its own characters and done nothing at all. + // + // THROWS rather than returning: none of the macro ran, so this is a + // failure and every caller has to see it as one. Returning quietly would + // let `quickadd:run` answer `ok: true` and automation carry on as if the + // macro had done its job (#1606's contract). The throw surfaces as one + // Notice through the executor's error path. + if (isUnreadableCommandList(this.macro?.commands)) { + throw new Error( + `Could not read the commands for macro '${this.choice.name}'. The saved value is not a list of commands - QuickAdd has not changed it. It is in .obsidian/plugins/quickadd/data.json.` ); + } + + const commands = commandListOf(this.macro?.commands); + if (commands.length === 0) { + // `commands: []` is the HEALTHY default (QuickAddMacro's constructor), + // so an empty list stays as quiet as it was before this guard existed - + // otherwise every freshly created macro, and every launch with an + // unpopulated run-on-startup macro, would raise a 15s error notice. + // Only a MISSING macro is worth saying anything about. + if (!hasCommandList(this.macro?.commands)) { + log.logError( + `No commands in the macro for choice '${this.choice.name}'` + ); + } return; } - await this.executeCommands(this.macro.commands); + await this.executeCommands(commands); } public getOutput(): unknown { @@ -755,6 +785,21 @@ export class MacroChoiceEngine extends QuickAddChoiceEngine { ? command.thenCommands : command.elseCommands; + // An absent branch is normal (a conditional with no else), so it stays + // silent. A branch we could not read is not: say so rather than skipping + // it as if the user had left it empty (#1593). + // + // THROWS rather than returning, for the same reason run() does, and one + // more: returning here only exits executeConditional, so the outer loop + // would carry on with every command AFTER the conditional - running + // file-writing and script commands that were only ever meant to follow a + // branch that never ran. + if (isUnreadableCommandList(branch)) { + throw new Error( + `Could not read the ${shouldRunThenBranch ? "then" : "else"} commands for '${command.name}'. The saved value is not a list of commands - QuickAdd has not changed it. It is in .obsidian/plugins/quickadd/data.json.` + ); + } + if (!Array.isArray(branch) || branch.length === 0) { return; } diff --git a/src/engine/SingleMacroEngine.member-access.test.ts b/src/engine/SingleMacroEngine.member-access.test.ts index cd0669258..c8ded6369 100644 --- a/src/engine/SingleMacroEngine.member-access.test.ts +++ b/src/engine/SingleMacroEngine.member-access.test.ts @@ -143,6 +143,89 @@ describe("SingleMacroEngine member access", () => { }); }); + // #1593: `commandListOf` converts the NON-ARRAY shapes, but a `null` hole + // INSIDE a real array sails straight through .map/.filter. This is the one + // entrypoint that was asymmetric: the same macro run WITHOUT `::member` was + // fine, because MacroChoiceEngine.executeCommands is record-guarded. + describe("over a malformed command list (#1593)", () => { + it("resolves the member through a list with a hole in it", async () => { + const userScript = createUserScript("user-script", "script.js"); + const macroChoice = baseMacroChoice([ + null as unknown as ICommand, + userScript, + ]); + + const engineInstance = macroEngineFactory(); + macroEngineFactory = () => engineInstance; + mockGetUserScript.mockResolvedValue({ f: vi.fn().mockReturnValue("ok") }); + + const engine = new SingleMacroEngine( + app, + plugin, + [macroChoice], + choiceExecutor, + ); + + await expect(engine.runAndGetOutput("My Macro::f")).resolves.toBe("ok"); + }); + + it.each([ + ["a string", "not a list"], + ["an array-turned-object", { "0": {} }], + ["null", null], + ])("falls back to running the whole macro when commands is %s", async ( + _label, + commands, + ) => { + const macroChoice = baseMacroChoice(commands as unknown as ICommand[]); + + const engineInstance = macroEngineFactory(); + engineInstance.run = vi.fn().mockResolvedValue(undefined); + engineInstance.getOutput = vi.fn().mockReturnValue(undefined); + macroEngineFactory = () => engineInstance; + vi.spyOn(log, "logWarning").mockImplementation(() => {}); + + const engine = new SingleMacroEngine( + app, + plugin, + [macroChoice], + choiceExecutor, + ); + + // No throw: it cannot find a user-script command, so it runs the macro. + await expect(engine.runAndGetOutput("My Macro::f")).resolves.toBe(""); + expect(engineInstance.run).toHaveBeenCalledTimes(1); + }); + + it("re-resolves past a hole a pre-command introduced", async () => { + const pre = { id: "pre", name: "Wait", type: CommandType.Wait } as ICommand; + const userScript = createUserScript("user-script", "script.js"); + const macroChoice = baseMacroChoice([pre, userScript]); + + const engineInstance = macroEngineFactory(); + engineInstance.runSubset = vi.fn().mockImplementation(() => { + // A pre-command rewrote the list and left a hole in it. + macroChoice.macro.commands = [ + pre, + null as unknown as ICommand, + userScript, + ]; + return Promise.resolve(undefined); + }); + macroEngineFactory = () => engineInstance; + mockGetUserScript.mockResolvedValue({ f: vi.fn().mockReturnValue("ok") }); + + const engine = new SingleMacroEngine( + app, + plugin, + [macroChoice], + choiceExecutor, + ); + + await expect(engine.runAndGetOutput("My Macro::f")).resolves.toBe("ok"); + }); + }); + it("runs the macro when no member access is requested", async () => { const userScript = createUserScript("user-script", "script.js"); const macroChoice = baseMacroChoice([userScript]); diff --git a/src/engine/SingleMacroEngine.ts b/src/engine/SingleMacroEngine.ts index 973c5178e..a8c408f1a 100644 --- a/src/engine/SingleMacroEngine.ts +++ b/src/engine/SingleMacroEngine.ts @@ -15,6 +15,11 @@ import { } from "../utils/userScriptSecrets"; import { MacroChoiceEngine } from "./MacroChoiceEngine"; import { handleMacroAbort } from "../utils/macroAbortHandler"; +import { + commandListOf, + hasCommandList, + isCommandLike, +} from "../utils/macroUtils"; import { MacroAbortError } from "../errors/MacroAbortError"; // Member names that QuickAdd itself treats as conventions/metadata rather than entrypoints: @@ -206,8 +211,10 @@ export class SingleMacroEngine { macroChoice: IMacroChoice, memberAccess: string[], ): Promise<{ executed: boolean; result?: unknown }> { - const originalCommands = macroChoice.macro?.commands; - if (!originalCommands?.length) { + // commandListOf, not `?.length`: a string `commands` reports a length and + // then throws on `.map` below (#1593). + const originalCommands = commandListOf(macroChoice.macro?.commands); + if (!originalCommands.length) { return { executed: false }; } @@ -215,6 +222,12 @@ export class SingleMacroEngine { .map((command, index) => ({ command, index })) .filter( (entry): entry is { command: IUserScript; index: number } => + // isCommandLike, not just the type check: commandListOf converts the + // NON-ARRAY shapes, but a `null` hole INSIDE a real array sails + // straight through .map/.filter. Without this, `[ok, null, ok]` threw + // here on the `{{MACRO:Name::member}}` path while the same macro ran + // fine without member access (#1593). + isCommandLike(entry.command) && entry.command.type === CommandType.UserScript, ); @@ -236,7 +249,13 @@ export class SingleMacroEngine { this.ensureNotAborted(); } - const updatedCommands = macroChoice.macro?.commands ?? originalCommands; + // Fall back to the list we started from if a pre-command replaced + // `commands` with something unreadable, rather than carrying that value + // into the index arithmetic below. + const refreshed = macroChoice.macro?.commands; + const updatedCommands = hasCommandList(refreshed) + ? (refreshed as typeof originalCommands) + : originalCommands; // Pre-commands may have mutated the commands array, so re-resolve the selected // command by its stable id. Both the candidate and the post-command slice are // derived from this refreshed index so they cannot drift apart. The original @@ -248,6 +267,9 @@ export class SingleMacroEngine { if (candidateId !== undefined) { refreshedIndex = updatedCommands.findIndex( (command) => + // A pre-command can rewrite `commands` to include a hole; the + // hasCommandList guard above only rejects a non-array refresh. + isCommandLike(command) && command.id === candidateId && command.type === CommandType.UserScript, ); diff --git a/src/gui/MacroGUIs/CommandList.svelte b/src/gui/MacroGUIs/CommandList.svelte index 00298ae85..44395a71b 100644 --- a/src/gui/MacroGUIs/CommandList.svelte +++ b/src/gui/MacroGUIs/CommandList.svelte @@ -43,6 +43,32 @@ let { onEditElseBranch, }: CommandListProps = $props(); +// Everything rendered, handed to the dnd zone, or reordered is filtered to +// entries that can actually be keyed: an object with a unique, non-empty string +// id. svelte-dnd-action reads `.id` on every item and the keyed {#each} throws +// `each_key_duplicate` on a repeat (#1451, #1593) — and on a POST-mount update +// that throw escapes mountComponent's try entirely (see its doc comment), so +// this has to be a $derived, not a one-off check at setup. +// +// CommandSequenceEditor normalizes the list before it ever gets here, so in +// practice this filter is a no-op: an id-less or duplicate-id command has +// already been given a fresh uuid and KEPT. That matters, because the persist +// path below writes back the list this zone was seeded with — a filter that +// silently dropped a real command would delete it from data.json on the first +// reorder. The only thing it can still drop is a `null`/primitive hole, which +// carries nothing. +const renderable = $derived.by(() => { + const seen = new Set(); + if (!Array.isArray(commands)) return []; + return commands.filter((command) => { + if (typeof command !== "object" || command === null) return false; + const id = command.id; + if (typeof id !== "string" || id === "" || seen.has(id)) return false; + seen.add(id); + return true; + }); +}); + const isMobile = Platform.isMobile; // Desktop: drag is armed by grabbing the handle (shared with the choices list; see // createDragArming for the click-swallow failsafe). Mobile: no handle — the whole row @@ -94,7 +120,9 @@ let startDrag = () => { // Keyboard reorder (ArrowUp/ArrowDown on a row's drag handle): move the command one // step and persist via the same snapshot path as a pointer drag's finalize. function moveCommand(id: string, direction: -1 | 1) { - const list = stripShadow(commands); + // `renderable`, not `commands`: stripShadow reads `item.id`, so the raw list + // would throw on the very hole the render filter exists to hide. + const list = stripShadow(renderable); const index = list.findIndex((c) => c.id === id); if (index === -1) return; const target = index + direction; @@ -112,7 +140,9 @@ function moveCommand(id: string, direction: -1 | 1) { } function updateCommand(command: ICommand) { - commands = replaceById(commands, command); + // `renderable` for the same reason as moveCommand: replaceById maps over + // `item.id`. + commands = replaceById(renderable, command); persist(); } @@ -198,7 +228,7 @@ async function configureOpenFile(command: IOpenFileCommand) {
    - {#each stripShadow(commands) as command (command.id)} + {#each stripShadow(renderable) as command (command.id)} {#if command.type === CommandType.Wait} Promise; @@ -65,7 +70,11 @@ export interface CommandSequenceEditorConditionalHandlers { export interface CommandSequenceEditorOptions { app: App; plugin: QuickAdd; - commands: ICommand[]; + /** + * Typed `unknown` because it is not: this is `macro.commands` straight out of + * `data.json` (see commandListOf). The editor normalizes it at construction. + */ + commands: unknown; choices: IChoice[]; onCommandsChange?: (commands: ICommand[]) => void; conditionalHandlers?: CommandSequenceEditorConditionalHandlers; @@ -79,16 +88,32 @@ export class CommandSequenceEditor { private readonly conditionalHandlers?: CommandSequenceEditorConditionalHandlers; private commandsRef: ICommand[]; + /** + * True when the value handed to us was not a list we could read AND could + * still be carrying commands (see isUnreadableCommandList). The editor is + * read-only in that state: `commandsRef` is an empty array that must never + * reach disk over the real value. + */ + private readonly unreadable: boolean; private obsidianCommands: IObsidianCommand[] = []; private scriptCandidates: ScriptCandidate[] = []; private commandListHandle: MountHandle | null = null; private commandListProps: CommandListProps | null = null; private containerEl: HTMLElement | null = null; + private unreadableCardHandle: MountHandle | null = null; constructor(options: CommandSequenceEditorOptions) { this.app = options.app; this.plugin = options.plugin; - this.commandsRef = options.commands; + // data.json is untrusted, so the value arrives raw and is made editable + // here — the one seam every host that shows a command list goes through + // (MacroBuilder, ConditionalBranchEditorModal). Normalizing keeps a + // duplicate-id or id-less command under a fresh uuid instead of letting + // the keyed {#each} throw and cost the user the whole editor (#1593). + // Nothing is persisted by this: the repair reaches disk only with the + // user's first ordinary edit, so opening and closing changes nothing. + this.unreadable = isUnreadableCommandList(options.commands); + this.commandsRef = normalizeCommandList(options.commands).commands; this.choices = options.choices; this.onCommandsChange = options.onCommandsChange; this.conditionalHandlers = options.conditionalHandlers; @@ -97,34 +122,58 @@ export class CommandSequenceEditor { this.loadScriptCandidates(); } - public render(containerEl: HTMLElement) { + /** + * @returns whether the editor is fully usable. False means the command list is + * showing a card instead, and the host must not commit `commandsRef` anywhere + * (see ConditionalBranchEditorModal's Save button). + */ + public render(containerEl: HTMLElement): boolean { this.destroy(); this.containerEl = containerEl; containerEl.empty(); containerEl.addClass("quickAddCommandEditor"); - // A list we could not draw must not be edited blind. Every control below - // appends to `commandsRef` and persists through onCommandsChange, so with the - // list invisible the user would be adding commands they cannot see, reorder or - // delete — and with a `null`/non-iterable `commands` the same controls throw - // silently instead. Before #1584 this was unreachable (the throw escaped and - // the modal never opened at all); now that the editor survives, it has to stop - // offering the affordances the failed view was the only way to review. - // The card explains what happened; the macro's name, "run on startup" and icon - // stay editable around it. - if (!this.renderCommandList(containerEl)) return; + // A list we could not read must not be edited blind. Every control below + // appends to `commandsRef` and persists through onCommandsChange, so an + // editor offered over a value we could not read would overwrite it with the + // `[]` we read it as — destroying the only copy the user has. Say so + // instead, and offer nothing that writes. The macro's name, "run on + // startup" and icon stay editable around it. + if (this.unreadable) { + this.renderUnreadableCard(containerEl); + return false; + } + + // Belt and braces: the list is a readable array and every entry has been + // given a unique id, so a mount failure here is a genuine bug rather than + // bad data. Same reasoning as above though — with the list invisible the + // user would be adding commands they cannot see, reorder or delete. + if (!this.renderCommandList(containerEl)) return false; this.renderCommandBar(containerEl); this.renderAddObsidianCommandSetting(containerEl); this.renderAddEditorCommandSetting(containerEl); this.renderAddUserScriptSetting(containerEl); this.renderAddChoiceSetting(containerEl); + return true; + } + + private renderUnreadableCard(parent: HTMLElement) { + const cardEl = parent.createDiv("commandList"); + this.unreadableCardHandle = mountComponent( + cardEl, + DataUnreadable, + { what: "this macro's commands" }, + { what: "this macro's commands" }, + ); } public destroy() { this.commandListHandle?.destroy(); this.commandListHandle = null; this.commandListProps = null; + this.unreadableCardHandle?.destroy(); + this.unreadableCardHandle = null; } private loadObsidianCommands(): void { diff --git a/src/gui/MacroGUIs/CommandSequenceEditor.unrenderable.test.ts b/src/gui/MacroGUIs/CommandSequenceEditor.unrenderable.test.ts index 05abf6663..8c28cdbff 100644 --- a/src/gui/MacroGUIs/CommandSequenceEditor.unrenderable.test.ts +++ b/src/gui/MacroGUIs/CommandSequenceEditor.unrenderable.test.ts @@ -4,7 +4,6 @@ vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); import { App } from "obsidian"; import type QuickAdd from "../../main"; -import type { ICommand } from "../../types/macros/ICommand"; import { CommandSequenceEditor } from "./CommandSequenceEditor"; import { log } from "../../logger/logManager"; @@ -24,76 +23,164 @@ function renderEditor(commands: unknown, onCommandsChange = vi.fn()) { const editor = new CommandSequenceEditor({ app: testApp(), plugin: {} as unknown as QuickAdd, - commands: commands as ICommand[], + commands, choices: [], onCommandsChange, }); - editor.render(container); - return { container, editor, onCommandsChange }; + const editable = editor.render(container); + return { container, editor, onCommandsChange, editable }; } +const addControlCount = (container: HTMLElement) => + container.querySelectorAll("button").length; +const rowCount = (container: HTMLElement) => + container.querySelectorAll(".quickAddCommandList > li").length; + +const wait = (id: string, time = 100) => ({ id, name: "Wait", type: "Wait", time }); + /** - * #1584 follow-through. Once mountComponent stopped letting a broken CommandList - * take the macro builder down with it, the builder started OPENING over a list it - * could not draw - and every add control was still live. Two ways that goes wrong: - * - * - `commands: null` (the issue's own live repro): every add control throws - * `[...null]` inside its click handler. Dead buttons, silently. - * - a valid array with duplicate ids: `{#each ... (command.id)}` throws - * `each_key_duplicate`, so the list is invisible - but the add controls WORK, - * appending commands the user cannot see, review or delete, and persisting - * them on close. + * The macro editor's three states over `macro.commands` from data.json (#1593). * - * A list we could not draw must not be edited blind. + * #1584 stopped a broken command list from taking the macro builder down with + * it, but every malformed shape then landed in the same place: an error card + * with no controls, i.e. a macro that could only be repaired by hand-editing + * data.json. That is honest for a value we genuinely cannot read; it is far too + * blunt for the two shapes that are a perfectly good array with one bad entry, + * and for the shapes that carry nothing at all. */ -describe("CommandSequenceEditor over an unrenderable command list (#1584)", () => { - const addControlCount = (container: HTMLElement) => - container.querySelectorAll("button").length; - - it("shows the error card instead of the list", () => { - vi.spyOn(log, "logError").mockImplementation(() => {}); - const { container } = renderEditor(null); - - const card = container.querySelector(".qaMountFailed"); - expect(card?.textContent).toContain( - "QuickAdd couldn't display this macro's commands", - ); - vi.restoreAllMocks(); +describe("CommandSequenceEditor over a malformed command list (#1593)", () => { + describe("a readable array with a bad entry stays fully editable", () => { + it("renders both commands when two share an id, keeping the second", () => { + const { container, editable } = renderEditor([ + wait("dup", 100), + wait("dup", 200), + ]); + + expect(editable).toBe(true); + expect(container.querySelector(".qaMountFailed")).toBeNull(); + expect(rowCount(container)).toBe(2); + expect(addControlCount(container)).toBeGreaterThan(0); + }); + + it("renders a command that has no id at all", () => { + const { container, editable } = renderEditor([ + { name: "Readwise sync", type: "UserScript", path: "s.js" }, + wait("ok"), + ]); + + expect(editable).toBe(true); + expect(rowCount(container)).toBe(2); + }); + + it("renders the commands either side of a null hole", () => { + const { container, editable } = renderEditor([wait("a"), null, wait("b")]); + + expect(editable).toBe(true); + expect(rowCount(container)).toBe(2); + }); + + it("does not persist the repair until the user actually edits something", () => { + const { onCommandsChange } = renderEditor([wait("dup"), wait("dup")]); + + // Opening a macro must not write to data.json. The re-id rides along with + // the user's first ordinary edit instead. + expect(onCommandsChange).not.toHaveBeenCalled(); + }); + }); + + describe("a value that carries nothing opens an empty, usable editor", () => { + it.each([ + ["null", null], + ["undefined", undefined], + ["an empty object", {}], + ["an empty string", ""], + ["an empty array", []], + ])("offers the full editor for %s", (_label, commands) => { + const { container, editable } = renderEditor(commands); + + expect(editable).toBe(true); + expect(container.querySelector(".qaDataUnreadable")).toBeNull(); + expect(container.querySelector(".quickCommandContainer")).not.toBeNull(); + expect(addControlCount(container)).toBeGreaterThan(0); + expect(rowCount(container)).toBe(0); + }); }); - it("offers no control that would edit the list it could not draw", () => { - vi.spyOn(log, "logError").mockImplementation(() => {}); - const { container } = renderEditor(null); + describe("a value that could be carrying commands is read-only", () => { + it.each([ + ["an array-turned-object", { "0": wait("hidden") }], + ["a string", "not a list"], + ["a JSON string", '[{"id":"x","type":"Wait","name":"Wait"}]'], + ["a number", 7], + ])("shows the unreadable card and no edit controls for %s", (_label, commands) => { + const { container, editable } = renderEditor(commands); - // The quick-command bar and the four "Add …" rows are all gone. - expect(addControlCount(container)).toBe(0); - expect(container.querySelector(".quickCommandContainer")).toBeNull(); - expect(container.textContent).not.toContain("Obsidian command"); - expect(container.textContent).not.toContain("User scripts"); - vi.restoreAllMocks(); + expect(editable).toBe(false); + expect(container.querySelector(".qaDataUnreadable")?.textContent).toContain( + "QuickAdd couldn't read this macro's commands", + ); + // Nothing that could write the `[]` we read the value as. + expect(addControlCount(container)).toBe(0); + expect(container.querySelector(".quickCommandContainer")).toBeNull(); + expect(container.textContent).not.toContain("Obsidian command"); + expect(container.textContent).not.toContain("User scripts"); + }); + + it("says the value has not been touched, and does not ask for a bug report", () => { + const { container } = renderEditor("not a list"); + const text = (container.textContent ?? "").replace(/\s+/g, " "); + + expect(text).toContain("will not overwrite it"); + expect(text).toContain("data.json"); + // This is the user's data, not a QuickAdd bug: MountFailed's copy would + // send them to file an issue with no error message to put in it. + expect(text).not.toContain("report this"); + }); + + it("never emits a change for a value it could not read", () => { + const { onCommandsChange } = renderEditor({ "0": wait("hidden") }); + expect(onCommandsChange).not.toHaveBeenCalled(); + }); }); - it("cannot append to a list the user cannot see", () => { - vi.spyOn(log, "logError").mockImplementation(() => {}); - // A valid array, so nothing throws on append - only the RENDER fails. This is - // the shape where an ungated editor silently persists invisible commands. - const duplicateIds = [ - { id: "dup", name: "One", type: "Wait" }, - { id: "dup", name: "Two", type: "Wait" }, - ]; - const { container, onCommandsChange } = renderEditor(duplicateIds); - - expect(container.querySelector(".qaMountFailed")).not.toBeNull(); - expect(addControlCount(container)).toBe(0); - expect(onCommandsChange).not.toHaveBeenCalled(); - vi.restoreAllMocks(); + describe("a genuine mount failure still withholds the controls (#1584)", () => { + it("shows the error card instead of the list", () => { + vi.spyOn(log, "logError").mockImplementation(() => {}); + // A component that throws on mount is the only way left to reach this arm + // now that data shapes are handled above; simulate it by breaking the + // props the list needs. + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = new CommandSequenceEditor({ + app: testApp(), + plugin: {} as unknown as QuickAdd, + // A frozen array whose entries are getters that throw reproduces a + // render-time explosion without depending on Svelte internals. + commands: [ + Object.defineProperty({ id: "boom" }, "type", { + get() { + throw new Error("boom"); + }, + enumerable: true, + }), + ], + choices: [], + }); + const editable = editor.render(container); + + expect(editable).toBe(false); + expect(container.querySelector(".qaMountFailed")?.textContent).toContain( + "QuickAdd couldn't display this macro's commands", + ); + expect(addControlCount(container)).toBe(0); + vi.restoreAllMocks(); + }); }); - it("still builds the whole editor when the list renders", () => { - const { container } = renderEditor([ - { id: "wait-1", name: "Wait", type: "Wait", time: 100 }, - ]); + it("still builds the whole editor when the list is healthy", () => { + const { container, editable } = renderEditor([wait("wait-1")]); + expect(editable).toBe(true); expect(container.querySelector(".qaMountFailed")).toBeNull(); expect(container.querySelector(".quickCommandContainer")).not.toBeNull(); expect(addControlCount(container)).toBeGreaterThan(0); diff --git a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte index 0cb89f5f0..b11a029eb 100644 --- a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte +++ b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte @@ -3,6 +3,7 @@ import DragHandle from "../../components/DragHandle.svelte"; import type { IConditionalCommand } from "../../../types/macros/Conditional/IConditionalCommand"; import { getConditionSummary } from "../../../utils/conditionalHelpers"; + import { commandListOf } from "../../../utils/macroUtils"; let { command, @@ -27,8 +28,12 @@ } = $props(); const summary = $derived(getConditionSummary(command.condition)); - const thenCount = $derived(command.thenCommands?.length ?? 0); - const elseCount = $derived(command.elseCommands?.length ?? 0); + // `?.length ?? 0` reads a LENGTH off whatever is there: a branch saved as + // "not a list" rendered "Then: 10" (its character count) and an + // array-turned-object rendered "Then: 0" as if the branch were empty. Count + // only what is actually a list of commands (#1593). + const thenCount = $derived(commandListOf(command.thenCommands).length); + const elseCount = $derived(commandListOf(command.elseCommands).length);
  1. diff --git a/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts b/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts index 29f8cd883..f55ef7dbb 100644 --- a/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts +++ b/src/gui/MacroGUIs/ConditionalBranchEditorModal.ts @@ -4,6 +4,7 @@ import type QuickAdd from "../../main"; import type IChoice from "../../types/choices/IChoice"; import type { ICommand } from "../../types/macros/ICommand"; import { deepClone } from "../../utils/deepClone"; +import { commandListOf } from "../../utils/macroUtils"; import { CommandSequenceEditor, type CommandSequenceEditorConditionalHandlers, @@ -14,7 +15,8 @@ interface ConditionalBranchEditorModalOptions { plugin: QuickAdd; choices: IChoice[]; title: string; - commands: ICommand[]; + /** Raw `thenCommands`/`elseCommands` out of data.json — see commandListOf. */ + commands: unknown; conditionalHandlers: CommandSequenceEditorConditionalHandlers; } @@ -22,7 +24,7 @@ export class ConditionalBranchEditorModal extends Modal { public waitForClose: Promise; private resolvePromise!: (commands: ICommand[] | null) => void; private commandEditor: CommandSequenceEditor | null = null; - private workingCommands: ICommand[]; + private workingCommands: unknown; private readonly plugin: QuickAdd; private readonly choices: IChoice[]; private readonly conditionalHandlers: CommandSequenceEditorConditionalHandlers; @@ -75,16 +77,35 @@ export class ConditionalBranchEditorModal extends Modal { }, conditionalHandlers: this.conditionalHandlers, }); - this.commandEditor.render(editorContainer); + const editable = this.commandEditor.render(editorContainer); - this.renderButtonBar(); + this.renderButtonBar(editable); } - private renderButtonBar() { + /** + * The Save button lives OUTSIDE the command editor, so suppressing the + * editor's own controls is not enough: Save resolves `workingCommands`, which + * MacroBuilder writes onto the conditional's `thenCommands`/`elseCommands`. If + * the branch held a value we could not read, that one click would replace it + * with the empty list we read it as. There is nothing to save in that state, + * so the button bar offers only a way out (#1593). + */ + private renderButtonBar(editable: boolean) { const buttonContainer = this.contentEl.createDiv({ cls: "qa-command-button-row", }); + if (!editable) { + new ButtonComponent(buttonContainer) + .setCta() + .setButtonText("Close") + .onClick(() => { + this.resolve(null); + this.close(); + }); + return; + } + new ButtonComponent(buttonContainer) .setButtonText("Cancel") .onClick(() => { @@ -96,7 +117,7 @@ export class ConditionalBranchEditorModal extends Modal { .setCta() .setButtonText("Save") .onClick(() => { - this.resolve(this.workingCommands); + this.resolve(commandListOf(this.workingCommands)); this.close(); }); } diff --git a/src/gui/MacroGUIs/MacroBuilder.malformed.test.ts b/src/gui/MacroGUIs/MacroBuilder.malformed.test.ts new file mode 100644 index 000000000..783158f37 --- /dev/null +++ b/src/gui/MacroGUIs/MacroBuilder.malformed.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { App } from "obsidian"; +import type QuickAdd from "../../main"; +import type IMacroChoice from "../../types/choices/IMacroChoice"; +import { MacroBuilder } from "./MacroBuilder"; + +function testApp(): App { + const app = new App() as App & { + dom: { appContainerEl: HTMLElement }; + keymap: { pushScope: () => void; popScope: () => void }; + }; + app.dom = { appContainerEl: document.body }; + app.keymap = { pushScope: vi.fn(), popScope: vi.fn() }; + return app; +} + +/** The REAL CommandSequenceEditor, unlike MacroBuilder.test.ts which mocks it. */ +function openBuilder(macro: unknown) { + const choice = { + id: "c1", + name: "Macro under test", + type: "Macro", + command: false, + runOnStartup: false, + macro, + } as unknown as IMacroChoice; + + const modal = new MacroBuilder( + testApp(), + { settings: { choices: [] } } as unknown as QuickAdd, + choice, + [], + ); + return { modal, choice, el: modal.contentEl }; +} + +const addControls = (el: HTMLElement) => el.querySelectorAll("button").length; +const rows = (el: HTMLElement) => + el.querySelectorAll(".quickAddCommandList > li").length; + +/** + * `choice.macro` is as untrusted as `macro.commands` (#1593). The shape that + * mattered most was `macro: null`: `display()` runs from the CONSTRUCTOR, before + * `open()`, so a throw there took the modal with it and clicking "Configure" did + * nothing whatsoever - no card, no notice, nothing. + */ +describe("MacroBuilder over a malformed macro object (#1593)", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["an empty object", {}], + ])("opens a usable, empty editor when macro is %s", (_label, macro) => { + const { el } = openBuilder(macro); + + expect(el.querySelector(".qaDataUnreadable")).toBeNull(); + expect(el.querySelector(".qaMountFailed")).toBeNull(); + expect(addControls(el)).toBeGreaterThan(0); + expect(rows(el)).toBe(0); + // The rest of the modal is there too. + expect(el.textContent).toContain("Run on startup"); + }); + + it("materializes a real macro object on the first edit, and it survives JSON", () => { + const { modal, choice } = openBuilder(null); + + // What an "Add …" click does. + (modal as unknown as { setMacroCommands(c: unknown[]): void }).setMacroCommands([ + { id: "new-1", name: "Wait", type: "Wait", time: 100 }, + ]); + + const persisted = JSON.parse(JSON.stringify(choice)) as IMacroChoice; + expect(persisted.macro.commands).toHaveLength(1); + expect(persisted.macro.commands[0].id).toBe("new-1"); + expect(persisted.macro.name).toBe("Macro under test"); + expect(typeof persisted.macro.id).toBe("string"); + }); + + // An ARRAY passes `typeof === "object"`, so a looser guard let the builder + // treat it as a macro object and write `macro.commands = [...]` onto it - a + // non-index property that JSON.stringify drops. The user saw their commands + // and every save silently discarded them. + describe("an array-valued macro", () => { + it("renders the array's entries as the commands they probably are", () => { + const { el } = openBuilder([ + { id: "a", name: "Alpha", type: "Wait", time: 1 }, + { id: "b", name: "Beta", type: "Wait", time: 2 }, + ]); + + expect(rows(el)).toBe(2); + expect(addControls(el)).toBeGreaterThan(0); + }); + + it("survives the JSON round-trip after an edit, losing nothing", () => { + const { modal, choice } = openBuilder([ + { id: "a", name: "Alpha", type: "Wait", time: 1 }, + ]); + + (modal as unknown as { setMacroCommands(c: unknown[]): void }).setMacroCommands([ + { id: "a", name: "Alpha", type: "Wait", time: 1 }, + { id: "b", name: "Beta", type: "Wait", time: 2 }, + ]); + + const persisted = JSON.parse(JSON.stringify(choice)) as IMacroChoice; + expect(persisted.macro.commands.map((c) => c.id)).toEqual(["a", "b"]); + }); + + it("treats an empty array as carrying nothing", () => { + const { modal, choice, el } = openBuilder([]); + + expect(rows(el)).toBe(0); + expect(addControls(el)).toBeGreaterThan(0); + + (modal as unknown as { setMacroCommands(c: unknown[]): void }).setMacroCommands([ + { id: "x", name: "Wait", type: "Wait", time: 1 }, + ]); + const persisted = JSON.parse(JSON.stringify(choice)) as IMacroChoice; + expect(persisted.macro.commands).toHaveLength(1); + }); + }); + + it.each([ + ["a string", "not a macro"], + ["a number", 7], + ])("shows the unreadable card and no edit controls for %s", (_label, macro) => { + const { el } = openBuilder(macro); + + expect(el.querySelector(".qaDataUnreadable")?.textContent).toContain( + "QuickAdd couldn't read this macro's commands", + ); + expect(rows(el)).toBe(0); + // Only the rename button in the header; nothing that writes commands. + expect(el.querySelector(".quickCommandContainer")).toBeNull(); + }); + + it("leaves an unreadable macro byte-identical after open and close", () => { + const macro = "not a macro"; + const { modal, choice } = openBuilder(macro); + const before = JSON.stringify(choice); + + modal.onClose(); + + expect(JSON.stringify(choice)).toBe(before); + }); + + it("still renames the choice when the macro object is missing", () => { + const { modal, choice } = openBuilder(null); + const button = modal.contentEl.querySelector( + ".qa-rename-title-button", + ); + + expect(button).not.toBeNull(); + expect(choice.name).toBe("Macro under test"); + }); +}); diff --git a/src/gui/MacroGUIs/MacroBuilder.ts b/src/gui/MacroGUIs/MacroBuilder.ts index df82e6fa7..5d236251e 100644 --- a/src/gui/MacroGUIs/MacroBuilder.ts +++ b/src/gui/MacroGUIs/MacroBuilder.ts @@ -19,6 +19,12 @@ import { isChoiceLike, rootChoicesOf, } from "../../utils/choiceUtils"; +import { + isMacroObject, + macroCommandsValueOf, +} from "../../utils/macroUtils"; +import type { ICommand } from "../../types/macros/ICommand"; +import { v4 as uuidv4 } from "uuid"; /** Exported for the malformed-tree sweep (src/utils/malformedChoices.entrypoints.test.ts). */ export function getChoicesAsList(nestedChoices: IChoice[]): IChoice[] { @@ -108,9 +114,11 @@ export class MacroBuilder extends Modal { ); if (!newName) return; - // Keep choice name and macro name in sync + // Keep choice name and macro name in sync. The macro object can + // be missing from a hand-edited data.json; renaming the choice + // still has to work, so only sync a macro that is there. this.choice.name = newName; - this.macro.name = newName; + if (isMacroObject(this.macro)) this.macro.name = newName; this.reload(); } catch { // Prompt cancelled (Esc/Cancel) — keep the current name. @@ -143,15 +151,49 @@ export class MacroBuilder extends Modal { this.display(); } + /** + * The value to show as this macro's command list. + * + * `choice.macro` is untrusted too, and a Macro choice whose `macro` key is + * missing entirely used to make "Configure" do nothing at all: `display()` + * runs from the constructor, before `open()`, so the throw took the modal with + * it. + * + * Three cases, and `macro` being an ARRAY is the one worth naming: `[]` and + * `[{...}]` are both objects, but writing `macro.commands` onto an Array sets + * a non-index property that `JSON.stringify` drops, so the user's edits would + * vanish on every save while the editor happily showed them. Handing the array + * itself over as the command list instead means its entries render as the + * commands they probably are, and `setMacroCommands` materializes a real macro + * object around them on the first edit - nothing lost either way. + */ + private macroCommandsValue(): unknown { + return macroCommandsValueOf(this.macro); + } + + /** + * Commit an edit back onto the choice, materializing the macro object if it + * was missing. Only reachable when the editor is usable, which + * `macroCommandsValue` guarantees means nothing readable is being replaced. + */ + private setMacroCommands(commands: ICommand[]) { + if (!isMacroObject(this.macro)) { + this.macro = { id: uuidv4(), name: this.choice.name, commands }; + this.choice.macro = this.macro; + return; + } + this.macro.commands = commands; + } + private addCommandEditor() { const editorContainer = this.contentEl.createDiv("macroBuilder__editor"); this.commandEditor = new CommandSequenceEditor({ app: this.app, plugin: this.plugin, - commands: this.macro.commands, + commands: this.macroCommandsValue(), choices: this.choices, onCommandsChange: (commands) => { - this.macro.commands = commands; + this.setMacroCommands(commands); }, conditionalHandlers: this.buildConditionalHandlers(), }); diff --git a/src/gui/svelte/DataUnreadable.svelte b/src/gui/svelte/DataUnreadable.svelte new file mode 100644 index 000000000..62e609235 --- /dev/null +++ b/src/gui/svelte/DataUnreadable.svelte @@ -0,0 +1,72 @@ + + + +
    +
    + + QuickAdd couldn't read {what} +
    +

    + The saved value isn't in a shape QuickAdd understands, so it can't be shown + or edited here. Nothing has been changed or deleted, and QuickAdd will not + overwrite it. +

    +

    + It's stored in .obsidian/plugins/quickadd/data.json inside this + vault. Make a copy of that file before editing it. +

    +
    + + diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 7d72b9f59..ea4ecf60e 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -13,6 +13,7 @@ import type IChoice from "src/types/choices/IChoice"; import type IMacroChoice from "src/types/choices/IMacroChoice"; import type ITemplateChoice from "src/types/choices/ITemplateChoice"; import { CommandType } from "src/types/macros/CommandType"; +import { commandListOf } from "src/utils/macroUtils"; import type { ICommand } from "src/types/macros/ICommand"; import type { IUserScript } from "src/types/macros/IUserScript"; import { @@ -506,7 +507,8 @@ async function collectMacroScriptRequirements( preloadedUserScripts?: Map, ): Promise { const requirements: FieldRequirement[] = []; - const commands: ICommand[] = choice?.macro?.commands ?? []; + // `?? []` would pass an array-turned-object straight into the loop below. + const commands: ICommand[] = commandListOf(choice?.macro?.commands); for (const command of commands) { if (command?.type !== CommandType.UserScript) continue; diff --git a/src/services/packageExportService.ts b/src/services/packageExportService.ts index 341a62cab..26c6fd6a6 100644 --- a/src/services/packageExportService.ts +++ b/src/services/packageExportService.ts @@ -18,6 +18,7 @@ import { log } from "../logger/logManager"; import GenericYesNoPrompt from "../gui/GenericYesNoPrompt/GenericYesNoPrompt"; import { decodeFromBase64, encodeToBase64 } from "../utils/base64"; import { deepClone } from "../utils/deepClone"; +import { isChoiceLike } from "../utils/choiceUtils"; import { ensureParentFolders } from "../utils/ensureParentFolders"; import { detectUserScriptSecretOptions, @@ -232,7 +233,9 @@ function pruneChoiceTree( } multi.choices = multi.choices - .filter((child) => includedIds.has(child.id)) + // Same hole as the import side: a `null` in the folder's list is not a + // choice and must not be dereferenced (#1566). + .filter((child) => isChoiceLike(child) && includedIds.has(child.id)) .map((child) => { pruneChoiceTree(child, includedIds); return child; diff --git a/src/services/packageImportService.malformed.test.ts b/src/services/packageImportService.malformed.test.ts index d26206242..2f8be67cf 100644 --- a/src/services/packageImportService.malformed.test.ts +++ b/src/services/packageImportService.malformed.test.ts @@ -111,3 +111,158 @@ describe("package import over a malformed tree (#1566)", () => { expect(find(result.updatedChoices, "imported-1")).toBeDefined(); }); }); + +/** + * #1593. The importer's macro writers dereferenced `macro` / `macro.commands` + * raw, and a PACKAGED folder's children list was dereferenced raw too. The + * second one only became reachable when this change guarded the preview walker: + * before that the package died at preview, so the Import button never lit. + */ +const importPackaged = async ( + choices: IChoice[], + mode: "import" | "duplicate" = "import", + existing: IChoice[] = [], +) => + applyPackageImport({ + app, + existingChoices: existing, + pkg: { + schemaVersion: QUICKADD_PACKAGE_SCHEMA_VERSION, + name: "Fixture", + description: "", + choices: choices.map((choice) => ({ + choice, + parentChoiceId: null, + pathHint: [choice.name], + })), + assets: [], + } as never, + choiceDecisions: choices.map((c) => ({ choiceId: c.id, mode })), + assetDecisions: [], + }); + +const macroChoice = (id: string, macro: unknown): IChoice => + ({ + id, + name: `Macro ${id}`, + type: "Macro", + command: false, + runOnStartup: false, + macro, + }) as unknown as IChoice; + +const choiceCommand = (id: string, choiceId: string) => ({ + id, + name: "Run choice", + type: "Choice", + choiceId, +}); + +describe("package import over a malformed macro (#1593)", () => { + it.each([ + ["null", null], + ["a string", "not a macro"], + ["an array-turned-object", { "0": {} }], + ["commands as a string", { id: "m", name: "M", commands: "not a list" }], + ["commands with a hole", { id: "m", name: "M", commands: [null] }], + ])("imports a macro whose macro value is %s without throwing", async (_l, macro) => { + const result = await importPackaged([macroChoice("m1", macro)]); + expect(find(result.updatedChoices, "m1")).toBeDefined(); + }); + + it.each([ + ["null", null], + ["a string", "not a macro"], + ])("leaves a %s macro value exactly as it found it", async (_l, macro) => { + const result = await importPackaged([macroChoice("m1", macro)]); + const imported = find(result.updatedChoices, "m1") as unknown as { + macro: unknown; + }; + expect(imported.macro).toEqual(macro); + }); + + // An array-valued `macro` IS the command list (MacroBuilder's recovery path), + // so its entries have to be remapped like any other - otherwise a duplicated + // choice keeps the ORIGINAL id and invokes the pre-existing local choice + // instead of the imported copy. + it("remaps choice references inside an array-valued macro", async () => { + const target = leaf("Target", "target-1"); + const macro = macroChoice("m1", [choiceCommand("cmd-1", "target-1")]); + + const result = await importPackaged([target, macro], "duplicate", [ + leaf("Target", "target-1"), + ]); + + const imported = result.updatedChoices.find( + (c) => c.type === "Macro", + ) as unknown as { macro: { choiceId: string }[] }; + const importedTarget = result.updatedChoices.find( + (c) => c.type === "Template" && c.id !== "target-1", + ); + + expect(importedTarget).toBeDefined(); + // The reference follows the duplicate, not the pre-existing local choice. + expect(imported.macro[0].choiceId).toBe(importedTarget?.id); + expect(imported.macro[0].choiceId).not.toBe("target-1"); + }); + + it("does not silently no-op the macro id when duplicating an array-valued macro", async () => { + const macro = macroChoice("m1", [choiceCommand("cmd-1", "nope")]); + const result = await importPackaged([macro], "duplicate", [ + macroChoice("m1", [choiceCommand("cmd-1", "nope")]), + ]); + + // The array survives the JSON round-trip whole; nothing was written onto it + // as a non-index property (which JSON.stringify would have dropped). + const persisted = JSON.parse(JSON.stringify(result.updatedChoices)); + const imported = persisted.filter( + (c: IChoice) => c.type === "Macro", + ) as unknown as { macro: unknown[] }[]; + expect(imported).toHaveLength(2); + for (const m of imported) { + expect(Array.isArray(m.macro)).toBe(true); + expect(m.macro).toHaveLength(1); + } + }); + + // Before this change the deref here threw and aborted the WHOLE import, and + // guarding the preview walker is what made it reachable: the package used to + // die at preview, so the Import button never lit. + it("imports a packaged folder whose children list holds a hole", async () => { + const kid = leaf("Kid", "kid-1"); + const packagedFolder = { + id: "f1", + name: "Packaged folder", + type: "Multi", + command: false, + collapsed: false, + choices: [null, kid], + } as unknown as IChoice; + + const result = await applyPackageImport({ + app, + existingChoices: [], + pkg: { + schemaVersion: QUICKADD_PACKAGE_SCHEMA_VERSION, + name: "Fixture", + description: "", + choices: [ + { choice: packagedFolder, parentChoiceId: null, pathHint: ["F"] }, + { choice: kid, parentChoiceId: "f1", pathHint: ["F", "Kid"] }, + ], + assets: [], + } as never, + choiceDecisions: [ + { choiceId: "f1", mode: "import" }, + { choiceId: "kid-1", mode: "import" }, + ], + assetDecisions: [], + }); + + const imported = find(result.updatedChoices, "f1") as IMultiChoice; + expect(imported).toBeDefined(); + // The hole carried nothing and is gone; the real child survived, and the + // import completed rather than aborting on the deref. + expect(imported.choices?.map((c) => c.id)).toEqual(["kid-1"]); + }); +}); diff --git a/src/services/packageImportService.ts b/src/services/packageImportService.ts index 99a434686..9c201ae85 100644 --- a/src/services/packageImportService.ts +++ b/src/services/packageImportService.ts @@ -17,6 +17,12 @@ import { hasUnreadableChildren, isChoiceLike, } from "../utils/choiceUtils"; +import { + commandListOf, + isCommandLike, + isMacroObject, + macroCommandsValueOf, +} from "../utils/macroUtils"; import type { ICommand } from "../types/macros/ICommand"; import type { IChoiceCommand } from "../types/macros/IChoiceCommand"; import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; @@ -903,11 +909,20 @@ function remapChoiceTree( if (choice.type === "Macro") { const macroChoice = choice as IMacroChoice; - if (isDuplicated) { + // `macro` is untrusted too: an imported package can omit it entirely, or + // carry a primitive where the object belongs. isMacroObject, not the + // looser isCommandLike: an Array passes `typeof === "object"`, and + // `macro.id = ...` on one sets a non-index property that JSON.stringify + // drops - a silent no-op that would leave the duplicate sharing an id. + if (isDuplicated && isMacroObject(macroChoice.macro)) { macroChoice.macro.id = uuidv4(); } + // macroCommandsValueOf, not `macro?.commands`: an array-valued `macro` IS + // the command list (see MacroBuilder's recovery path), and reading + // `.commands` off it would silently skip remapping every choice reference + // and secret ref it holds. remapCommands( - macroChoice.macro.commands, + macroCommandsValueOf(macroChoice.macro), idMap, importableChoiceIds, isDuplicated, @@ -919,7 +934,10 @@ function remapChoiceTree( const multi = choice as IMultiChoice; if (Array.isArray(multi.choices)) { multi.choices = multi.choices - .filter((child) => importableChoiceIds.has(child.id)) + // A packaged folder's list can hold a `null` hole like any other + // (#1566); dereferencing it here aborted the whole import, taking + // healthy siblings with it. + .filter((child) => isChoiceLike(child) && importableChoiceIds.has(child.id)) .map((child) => remapChoiceTree( child, @@ -935,14 +953,17 @@ function remapChoiceTree( } function remapCommands( - commands: ICommand[], + // `unknown`: raw `macro.commands` / branch values out of an imported package. + commands: unknown, idMap: Map, importableChoiceIds: Set, shouldRegenerateIds: boolean, secretSanitizerOptions: UserScriptSecretSanitizerOptions, ): void { - for (const command of commands) { - if (!command) continue; + // Mutates each command in place, so a value we cannot read is simply left + // alone rather than replaced with the [] we read it as. + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; stripUserScriptSecretRefsFromCommand(command, secretSanitizerOptions); if (shouldRegenerateIds) { @@ -1076,7 +1097,10 @@ function applyAssetPathOverrides( switch (choice.type) { case "Macro": { const macroChoice = choice as IMacroChoice; - applyOverridesToCommands(macroChoice.macro.commands, pathOverrides); + applyOverridesToCommands( + macroCommandsValueOf(macroChoice.macro), + pathOverrides, + ); break; } case "Template": { @@ -1114,11 +1138,11 @@ function applyAssetPathOverrides( } function applyOverridesToCommands( - commands: ICommand[], + commands: unknown, pathOverrides: Map, ): void { - for (const command of commands) { - if (!command) continue; + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; switch (command.type) { case CommandType.UserScript: { diff --git a/src/services/packagePreview.ts b/src/services/packagePreview.ts index 310d285ff..8e5f575c2 100644 --- a/src/services/packagePreview.ts +++ b/src/services/packagePreview.ts @@ -14,7 +14,8 @@ import type { QuickAddPackage, QuickAddPackageAssetKind, } from "../types/packages/QuickAddPackage"; -import { flattenChoices } from "../utils/choiceUtils"; +import { flattenChoices, isChoiceLike } from "../utils/choiceUtils"; +import { commandListOf, isCommandLike } from "../utils/macroUtils"; import { decodeFromBase64 } from "../utils/base64"; import { extractScriptFromMarkdown } from "../utils/extractScriptFromMarkdown"; import { MARKDOWN_FILE_EXTENSION_REGEX } from "../constants"; @@ -468,7 +469,7 @@ function collectChoice( detail: joinCrumb(crumbs), }); } - collectCommands(choice.macro?.commands ?? [], walk, crumbs, entryIds, depthLevel); + collectCommands(choice.macro?.commands, walk, crumbs, entryIds, depthLevel); } if (isTemplateChoice(choice)) { @@ -506,6 +507,9 @@ function collectChoice( if (isMultiChoice(choice) && Array.isArray(choice.choices)) { for (const child of choice.choices) { + // A packaged folder's list can hold a `null` hole like any other + // (#1566); it carries nothing, so step over it rather than deref it. + if (!isChoiceLike(child)) continue; // Skip children that have their own top-level row (avoids double // counting); recurse inline-only children so they can't hide. if (entryIds.has(child.id)) continue; @@ -515,14 +519,15 @@ function collectChoice( } function collectCommands( - commands: ICommand[], + // `unknown`: raw `macro.commands` / branch values, straight from data.json. + commands: unknown, walk: ChoiceWalk, crumbs: string[], entryIds: ReadonlySet, depth: number, ): void { - for (const command of commands) { - if (!command) continue; + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; const label = commandLabel(command); const commandCrumbs = [...crumbs, label]; diff --git a/src/utils/macroUtils.test.ts b/src/utils/macroUtils.test.ts new file mode 100644 index 000000000..e9d1ff51b --- /dev/null +++ b/src/utils/macroUtils.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; + +import type { ICommand } from "../types/macros/ICommand"; +import type { IMacro } from "../types/macros/IMacro"; +import { + commandListOf, + hasCommandList, + isCommandLike, + isMacroObject, + isUnreadableCommandList, + isUnreadableMacro, + normalizeCommandList, + regenerateIds, +} from "./macroUtils"; + +const wait = (id: string, time = 100): ICommand => + ({ id, name: "Wait", type: "Wait", time }) as unknown as ICommand; + +/** + * The `commands` values `data.json` actually shows up with, and whether each one + * could still be CARRYING commands we cannot read. Shared by every case below so + * a new shape is added in one place. Mirrors MALFORMED_CHILDREN_SHAPES. + */ +const MALFORMED_COMMAND_SHAPES: { key: string; value: unknown; carrying: boolean }[] = [ + { key: "missing", value: undefined, carrying: false }, + { key: "null", value: null, carrying: false }, + { key: "emptyObject", value: {}, carrying: false }, + { key: "emptyString", value: "", carrying: false }, + { key: "zero", value: 0, carrying: false }, + { key: "false", value: false, carrying: false }, + { key: "arrayLikeObject", value: { "0": wait("hidden") }, carrying: true }, + { key: "string", value: "not a list", carrying: true }, + { key: "jsonString", value: '[{"id":"x","type":"Wait"}]', carrying: true }, + { key: "number", value: 7, carrying: true }, +]; + +describe("commandListOf", () => { + it("hands back the live array when there is one", () => { + const commands = [wait("a")]; + expect(commandListOf(commands)).toBe(commands); + }); + + it.each(MALFORMED_COMMAND_SHAPES)("reads $key as no commands", ({ value }) => { + expect(commandListOf(value)).toEqual([]); + }); + + it("never hands back the same [] twice, so no caller can leak a shared array", () => { + expect(commandListOf(null)).not.toBe(commandListOf(null)); + }); +}); + +describe("hasCommandList", () => { + it("is true only for a real array", () => { + expect(hasCommandList([])).toBe(true); + expect(hasCommandList([wait("a")])).toBe(true); + }); + + it.each(MALFORMED_COMMAND_SHAPES)("refuses to let a write rebuild $key", ({ value }) => { + expect(hasCommandList(value)).toBe(false); + }); +}); + +describe("isUnreadableCommandList", () => { + it("is false for a readable array", () => { + expect(isUnreadableCommandList([])).toBe(false); + expect(isUnreadableCommandList([wait("a")])).toBe(false); + }); + + it.each(MALFORMED_COMMAND_SHAPES)( + "says whether $key could be carrying commands", + ({ value, carrying }) => { + expect(isUnreadableCommandList(value)).toBe(carrying); + }, + ); +}); + +describe("isCommandLike", () => { + it("accepts objects and rejects the holes a truncated write leaves", () => { + expect(isCommandLike(wait("a"))).toBe(true); + expect(isCommandLike(null)).toBe(false); + expect(isCommandLike(undefined)).toBe(false); + expect(isCommandLike("stray")).toBe(false); + expect(isCommandLike(3)).toBe(false); + }); +}); + +describe("normalizeCommandList", () => { + it("is identity for a healthy list - same array, nothing changed", () => { + const commands = [wait("a"), wait("b")]; + const result = normalizeCommandList(commands); + expect(result.changed).toBe(false); + expect(result.commands).toBe(commands); + }); + + it("keeps a duplicate-id command under a fresh id instead of dropping it", () => { + const first = wait("dup", 100); + const second = wait("dup", 200); + const { commands, changed } = normalizeCommandList([first, second]); + + expect(changed).toBe(true); + expect(commands).toHaveLength(2); + // The first occurrence keeps its identity object and id. + expect(commands[0]).toBe(first); + // The second survives whole - only its id moved. + expect(commands[1]).toMatchObject({ name: "Wait", type: "Wait", time: 200 }); + expect(commands[1].id).not.toBe("dup"); + expect(commands[1].id).not.toBe(commands[0].id); + }); + + it("mints an id for a command that has none, rather than hiding it", () => { + const idless = { name: "Readwise sync", type: "UserScript", path: "s.js" }; + const { commands } = normalizeCommandList([idless]); + + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ name: "Readwise sync", path: "s.js" }); + expect(typeof commands[0].id).toBe("string"); + expect(commands[0].id).not.toBe(""); + }); + + it.each([ + ["empty string", ""], + ["number", 3], + ["null", null], + ])("mints an id for a command whose id is a %s", (_label, id) => { + const { commands } = normalizeCommandList([{ name: "X", type: "Wait", id }]); + expect(commands).toHaveLength(1); + expect(typeof commands[0].id).toBe("string"); + expect(commands[0].id).not.toBe(""); + }); + + it("gives two id-less commands DIFFERENT ids (the each_key_duplicate case)", () => { + const { commands } = normalizeCommandList([ + { name: "A", type: "Wait" }, + { name: "B", type: "Wait" }, + ]); + expect(commands[0].id).not.toBe(commands[1].id); + }); + + it("drops holes, which carry nothing, and keeps everything around them", () => { + const a = wait("a"); + const b = wait("b"); + const { commands, changed } = normalizeCommandList([a, null, "stray", b, 7]); + + expect(changed).toBe(true); + expect(commands).toEqual([a, b]); + }); + + it("never mutates its input", () => { + const input = [wait("dup"), wait("dup"), null]; + const before = JSON.stringify(input); + normalizeCommandList(input); + expect(JSON.stringify(input)).toBe(before); + }); + + it.each(MALFORMED_COMMAND_SHAPES)("reads $key as an empty list", ({ value }) => { + expect(normalizeCommandList(value).commands).toEqual([]); + }); + + it("is idempotent", () => { + const first = normalizeCommandList([wait("dup"), wait("dup"), null]); + const second = normalizeCommandList(first.commands); + expect(second.changed).toBe(false); + expect(second.commands).toBe(first.commands); + }); +}); + +describe("regenerateIds", () => { + it("re-ids the macro and every command", () => { + const macro: IMacro = { + id: "m", + name: "M", + commands: [wait("a"), wait("b")], + }; + regenerateIds(macro); + + expect(macro.id).not.toBe("m"); + expect(macro.commands[0].id).not.toBe("a"); + expect(macro.commands[1].id).not.toBe("b"); + }); + + it.each(MALFORMED_COMMAND_SHAPES)( + "leaves a $key commands value exactly as it found it", + ({ key, value }) => { + const macro = { id: "m", name: "M" } as Record; + if (value !== undefined) macro.commands = value; + const before = JSON.stringify(macro.commands ?? null); + + expect(() => regenerateIds(macro as unknown as IMacro)).not.toThrow(); + + expect(JSON.stringify(macro.commands ?? null)).toBe(before); + expect("commands" in macro).toBe(key !== "missing"); + // The macro id is still refreshed: only the unreadable value is preserved. + expect(macro.id).not.toBe("m"); + }, + ); + + it("steps over a hole in the list instead of dereferencing it", () => { + const macro = { + id: "m", + name: "M", + commands: [wait("a"), null, wait("b")], + } as unknown as IMacro; + + expect(() => regenerateIds(macro)).not.toThrow(); + expect(macro.commands[0].id).not.toBe("a"); + expect(macro.commands[1]).toBeNull(); + expect(macro.commands[2].id).not.toBe("b"); + }); + + it("tolerates a macro that is not an object at all", () => { + expect(() => regenerateIds(null as unknown as IMacro)).not.toThrow(); + expect(() => regenerateIds("x" as unknown as IMacro)).not.toThrow(); + }); +}); + +describe("isMacroObject / isUnreadableMacro", () => { + // An ARRAY is `typeof === "object"`, so the looser isCommandLike accepts one. + // That mattered: writing `macro.commands = [...]` onto an Array sets a + // non-index property, which JSON.stringify (i.e. saveData) silently drops - + // so the editor showed the user's new commands and every save discarded them. + it("rejects arrays, which cannot carry a macro's fields through JSON", () => { + expect(isMacroObject({})).toBe(true); + expect(isMacroObject({ id: "m", name: "M", commands: [] })).toBe(true); + expect(isMacroObject([])).toBe(false); + expect(isMacroObject([wait("a")])).toBe(false); + expect(isMacroObject(null)).toBe(false); + expect(isMacroObject("x")).toBe(false); + }); + + it("proves the JSON hazard the predicate exists for", () => { + const asArray: unknown = []; + (asArray as Record).commands = [wait("new")]; + expect(JSON.parse(JSON.stringify(asArray))).toEqual([]); + }); + + it.each([ + ["a macro object", {}, false], + ["a populated macro object", { id: "m", commands: [] }, false], + ["undefined", undefined, false], + ["null", null, false], + ["an empty array", [], false], + ["an empty string", "", false], + ["a populated array", [wait("a")], true], + ["a string", "not a macro", true], + ["a number", 7, true], + ])("says whether %s could be carrying a macro", (_label, value, expected) => { + expect(isUnreadableMacro(value)).toBe(expected); + }); +}); diff --git a/src/utils/macroUtils.ts b/src/utils/macroUtils.ts index d35b54ea5..81cd5cc46 100644 --- a/src/utils/macroUtils.ts +++ b/src/utils/macroUtils.ts @@ -1,12 +1,208 @@ import { v4 as uuidv4 } from "uuid"; import type { IMacro } from "../types/macros/IMacro"; +import type { ICommand } from "../types/macros/ICommand"; /** - * Regenerates all IDs in a macro to prevent collisions after duplication + * Whether `value` is shaped enough like a command to be walked: a non-null + * object. `data.json` is untrusted, so a command list can hold a `null` (a + * truncated write), a string, or a number. + * + * The sibling of `isChoiceLike` in choiceUtils.ts, and the same argument applies. + */ +export function isCommandLike(value: unknown): value is ICommand { + return typeof value === "object" && value !== null; +} + +/** + * Whether `value` is a macro OBJECT we can read and write through. + * + * `isCommandLike` is not enough here, because `typeof [] === "object"`: an + * array-valued `macro` would pass it, and `macro.commands = [...]` on an Array + * writes a non-index property that `JSON.stringify` (i.e. `saveData`) and + * `$state.snapshot` both discard - so the user's edits would vanish on every + * save while the editor showed them happily. + */ +export function isMacroObject( + value: unknown, +): value is Record { + return isCommandLike(value) && !Array.isArray(value); +} + +/** + * Whether a `macro` value is something we cannot read that could still be + * carrying a macro. Same "degrade quietly vs tell the user" line as + * {@link isUnreadableCommandList}, one level up: an empty array carries nothing + * (so the editor may replace it), a non-empty one might. + */ +export function isUnreadableMacro(value: unknown): boolean { + if (isMacroObject(value)) return false; + if (Array.isArray(value)) return value.length > 0; + return isUnreadableCommandList(value); +} + +/** + * Where a macro's command list actually lives, given an untrusted `macro`. + * + * Normally `macro.commands`. But an ARRAY-valued `macro` is treated as the + * command list itself: writing `macro.commands` onto an Array is dropped by + * `JSON.stringify`, so the recoverable reading is that the array IS the + * commands (someone saved `macro: commands`). Every consumer has to agree on + * that, or the macro builder repairs one list while the package importer + * remaps another - hence one function rather than a convention. + * + * Returns `undefined` for a value that carries nothing, so the caller can tell + * "no commands" from "commands we could not read" via + * {@link isUnreadableCommandList} on the result. + */ +export function macroCommandsValueOf(macro: unknown): unknown { + if (isMacroObject(macro)) return macro.commands; + return isUnreadableMacro(macro) ? macro : undefined; +} + +/** + * A command list that is always safe to iterate, map or spread. + * + * `IMacro.commands` is declared `ICommand[]` and nothing enforces it. This is + * the same untrusted-input problem `childChoicesOf` solves one type over, with + * the same two guards that look right and are not: + * + * `macro.commands ?? []` passes `{}` straight through (not nullish) + * `if (macro.commands)` passes `{}` AND `"not a list"` through (truthy) + * + * Both shapes are real: `{"0": {...}, "1": {...}}` is the classic + * array-turned-object JSON artefact, and a string reaches `for..of` intact + * because strings are iterable - which is how a malformed macro used to "run" + * successfully while doing nothing at all (#1593). + * + * Takes `unknown` rather than `IMacro` on purpose (mirroring `rootChoicesOf`): + * `choice.macro` is untrusted too, so the callers that need this most cannot + * produce an `IMacro` to pass. Read `commandListOf(choice.macro?.commands)`. + * + * This is a READ view, never a repair: nothing here persists the `[]` it hands + * back for a malformed value. WRITE paths must leave a malformed `commands` + * exactly as they found it - guard them with `hasCommandList` - so the original + * survives on disk to be recovered by hand. + */ +export function commandListOf(value: unknown): ICommand[] { + return Array.isArray(value) ? value : []; +} + +/** + * Whether `value` is a real command array, i.e. whether a WRITE path may rebuild + * it. Guards the `{ ...macro, commands: ... }` rebuilds so a malformed list is + * passed through untouched instead of being silently rewritten to the `[]` that + * `commandListOf` reads it as. + */ +export function hasCommandList(value: unknown): boolean { + return Array.isArray(value); +} + +/** + * Whether a command list value holds something we cannot read, as opposed to + * nothing at all. True only for the malformed shapes that can still CARRY + * commands: a non-empty object where an array belongs, or a non-empty primitive + * (a double-encoded `"[{\"id\":...}]"` is a routine scripted-edit artefact, and + * in general we cannot prove that an unreadable non-empty value is empty). + * + * `undefined`, `null`, `{}`, `""`, `0` and `false` carry nothing, so for those + * the macro really has no commands and an empty editor is the honest thing to + * show. + * + * This is the line between "degrade quietly" and "tell the user": a macro whose + * commands we cannot read must not claim to be empty, and must offer no + * affordance that would overwrite the value. The editor's card and its + * suppressed controls both read this one predicate so they cannot disagree. + */ +export function isUnreadableCommandList(value: unknown): boolean { + if (Array.isArray(value)) return false; + if (value === undefined || value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + // A non-empty primitive was never empty; an empty one carries nothing. + return Boolean(value); +} + +export interface NormalizedCommandList { + commands: ICommand[]; + /** False when `commands` is the input array itself, unchanged. */ + changed: boolean; +} + +/** + * The command list an EDITOR should work over: every entry an object with an id + * that is unique within the list. + * + * Two things go wrong in a list that is otherwise a perfectly good array, and + * both used to cost the user the whole macro editor (#1593). Svelte's keyed + * `{#each ... (command.id)}` throws `each_key_duplicate` on a repeated key and + * `Cannot read properties of null` on a hole, and svelte-dnd-action reads `.id` + * on every item it is handed. Either throw aborts the mount, which since #1584 + * means an honest error card - and a macro that can only be repaired by hand. + * + * The two cases are NOT the same and are deliberately not treated the same: + * + * - A duplicate or missing id is a REAL command that merely cannot be keyed. + * It is given a fresh uuid and kept. Never dropped: dropping it would hide a + * working command from the one screen that could delete it, and the editor + * persists the list it rendered, so the next reorder would erase it from + * disk with no prompt and no undo. + * - A `null` or a stray primitive carries nothing: there is no command to + * re-id, the engine already steps over it, and it is dropped. + * + * Re-iding is safe for stored user-script secrets: a secret lives in the + * command's own `settings` as a `{secretRef}` object and travels with it + * (userScriptSecrets.ts). Only the stable-id RE-ADOPTION path + * (`buildUserScriptSecretId`) keys on `command.id`, and it only runs for a + * setting that has no ref yet. + * + * Applied once at the editor seam (CommandSequenceEditor's constructor), never + * at load: `loadSettings` runs before the migrations and never saves, so a heal + * there would mint fresh uuids on every launch until an unrelated save landed. + * Here it is idempotent per session and is persisted by the user's first + * ordinary edit. + * + * Returns the input array itself when there was nothing to change, so a healthy + * macro is provably untouched. + */ +export function normalizeCommandList(value: unknown): NormalizedCommandList { + const input = commandListOf(value); + const seen = new Set(); + let changed = false; + + const commands: ICommand[] = []; + for (const entry of input) { + if (!isCommandLike(entry)) { + changed = true; + continue; + } + const id = entry.id; + if (typeof id === "string" && id !== "" && !seen.has(id)) { + seen.add(id); + commands.push(entry); + continue; + } + const replacement = { ...entry, id: uuidv4() }; + seen.add(replacement.id); + commands.push(replacement); + changed = true; + } + + return changed ? { commands, changed } : { commands: input, changed: false }; +} + +/** + * Regenerates all IDs in a macro to prevent collisions after duplication. + * + * Total over a malformed macro: a missing or non-array `commands` is left + * exactly as it is (this is a WRITE path - see `hasCommandList`), and a hole in + * the list is stepped over rather than dereferenced. Reached from + * `duplicateChoice`, which runs over whatever the user's data.json holds. */ export function regenerateIds(macro: IMacro): void { + if (!isCommandLike(macro)) return; macro.id = uuidv4(); - macro.commands.forEach(command => { + if (!hasCommandList(macro.commands)) return; + macro.commands.forEach((command) => { + if (!isCommandLike(command)) return; command.id = uuidv4(); }); } diff --git a/src/utils/malformedChoices.entrypoints.test.ts b/src/utils/malformedChoices.entrypoints.test.ts index 3f7bf083a..cec171198 100644 --- a/src/utils/malformedChoices.entrypoints.test.ts +++ b/src/utils/malformedChoices.entrypoints.test.ts @@ -39,6 +39,19 @@ import { isEmptyFolderChoice, } from "../gui/suggesters/choiceSuggester"; import { getChoicesAsList as macroBuilderChoiceList } from "../gui/MacroGUIs/MacroBuilder"; +import { + commandListOf, + hasCommandList, + isUnreadableCommandList, + normalizeCommandList, + regenerateIds, +} from "./macroUtils"; +import type { IMacro } from "../types/macros/IMacro"; +import { buildPackagePreview } from "../services/packagePreview"; +import { + QUICKADD_PACKAGE_SCHEMA_VERSION, + type QuickAddPackage, +} from "../types/packages/QuickAddPackage"; import { HEALTHY_IDS, leaf, @@ -135,8 +148,87 @@ const sweeps: Sweep[] = [ // --- macro builder ["getChoicesAsList", (t) => macroBuilderChoiceList(t)], + + // --- macro command lists (#1593). Same argument as the folder walkers above, + // one type over: `macro.commands` is declared ICommand[] and read raw. + [ + "commandListOf (every macro)", + (t) => macrosIn(t).map((m) => commandListOf(m?.commands)), + ], + [ + "hasCommandList (every macro)", + (t) => macrosIn(t).map((m) => hasCommandList(m?.commands)), + ], + [ + "isUnreadableCommandList (every macro)", + (t) => macrosIn(t).map((m) => isUnreadableCommandList(m?.commands)), + ], + [ + "normalizeCommandList (every macro)", + (t) => macrosIn(t).map((m) => normalizeCommandList(m?.commands)), + ], + [ + "regenerateIds (every macro)", + // A WRITE path (duplicateChoice). Runs over a CLONE so the shared fixture + // tree keeps its ids; the point is that it neither throws nor rewrites an + // unreadable value. + (t) => macrosIn(structuredCloneTree(t)).map((m) => regenerateIds(m as unknown as IMacro)), + ], + [ + "buildPackagePreview (whole tree as a package)", + (t) => buildPackagePreview([], packageOf(t), new Set()), + ], ]; +/** + * The malformed tree wrapped as an importable package, for the preview walker. + * Structurally typed, with NO cast: a cast here would let the fixture drift out + * of the shape the walker is handed in production, which is the one thing this + * sweep is for. + */ +function packageOf(tree: IChoice[]): QuickAddPackage { + const roots = tree.filter(isChoiceLike); + return { + schemaVersion: QUICKADD_PACKAGE_SCHEMA_VERSION, + quickAddVersion: "1.18.0", + createdAt: "2026-06-01T00:00:00.000Z", + rootChoiceIds: roots.map((c) => c.id), + choices: roots.map((choice) => ({ + choice, + pathHint: [choice.name], + parentChoiceId: null, + })), + assets: [], + }; +} + +/** Every `macro` object in the tree, including the unreadable ones. */ +function macrosIn(tree: IChoice[]): (Record | null)[] { + const out: (Record | null)[] = []; + const walk = (list: unknown) => { + if (!Array.isArray(list)) return; + for (const entry of list) { + if (typeof entry !== "object" || entry === null) continue; + const node = entry as Record; + if (node.type === "Macro") { + out.push( + typeof node.macro === "object" + ? (node.macro as Record | null) + : null, + ); + } + if (node.type === "Multi") walk(node.choices); + } + }; + walk(tree); + return out; +} + +function structuredCloneTree(tree: IChoice[]): IChoice[] { + return JSON.parse(JSON.stringify(tree)) as IChoice[]; +} + + describe("every choice-tree entry point over a malformed tree (#1566)", () => { it.each(sweeps)("%s does not throw", (_name, run) => { expect(() => run(malformedTree())).not.toThrow(); diff --git a/src/utils/malformedChoices.fixture.ts b/src/utils/malformedChoices.fixture.ts index 52544b273..2910e0a43 100644 --- a/src/utils/malformedChoices.fixture.ts +++ b/src/utils/malformedChoices.fixture.ts @@ -57,6 +57,82 @@ export const MALFORMED_CHILDREN_SHAPES: { { key: "number", value: 7, lossy: true }, ]; +/** + * The same question for `macro.commands`, which has exactly the same problem + * (#1593): declared `ICommand[]`, read straight out of an untrusted data.json. + * Kept as its own list because a command list has two failure modes a choice + * list does not - it is also reachable as a conditional's `thenCommands` / + * `elseCommands`, and a perfectly good ARRAY can still be unrenderable. + */ +export const MALFORMED_COMMANDS_SHAPES: { + key: string; + value: unknown; + /** Whether the value could still be holding commands we cannot read. */ + lossy: boolean; +}[] = [ + { key: "missing", value: undefined, lossy: false }, + { key: "null", value: null, lossy: false }, + { key: "emptyObject", value: {}, lossy: false }, + { key: "arrayLikeObject", value: { "0": waitCommand("hidden-cmd") }, lossy: true }, + { key: "string", value: "not a list", lossy: true }, + { key: "number", value: 7, lossy: true }, +]; + +export function waitCommand(id: string, name = "Wait"): unknown { + return { id, name, type: "Wait", time: 100 }; +} + +/** + * A Macro choice whose `macro.commands` is whatever `commands` is, however wrong + * that is. `macro` itself is untrusted too, so passing `macro: null` builds a + * choice with no macro object at all - the shape that used to make "Configure" + * do nothing whatsoever. + */ +export function macroChoice( + name: string, + id: string, + commands: unknown, + options: { noMacro?: boolean } = {}, +): IChoice { + const node: Record = { + id, + name, + type: "Macro", + command: false, + runOnStartup: false, + }; + if (options.noMacro) { + node.macro = null; + return node as unknown as IChoice; + } + const macro: Record = { id: `${id}-macro`, name }; + // `undefined` must leave the key ABSENT (same reasoning as malformedFolder). + if (commands !== undefined) macro.commands = commands; + node.macro = macro; + return node as unknown as IChoice; +} + +/** A Conditional command whose branches are whatever they are handed. */ +export function conditionalCommand( + id: string, + thenCommands: unknown, + elseCommands: unknown, +): unknown { + return { + id, + name: "If", + type: "Conditional", + condition: { + mode: "variable", + variableName: "x", + operator: "isTruthy", + valueType: "string", + }, + thenCommands, + elseCommands, + }; +} + /** * One tree carrying every malformed shape plus healthy neighbours on both sides, * so a walker that dies partway through is caught by a missing TAIL choice and @@ -77,6 +153,47 @@ export function malformedTree(): IChoice[] { ), ); + // The same, for macros. Every shape appears at every depth for the same + // reason the folder shapes do: a walker that guards `macro.commands` at its + // entry point but not inside a conditional's branches passes the top-level + // cases happily. + const brokenMacros = (suffix = "") => [ + ...MALFORMED_COMMANDS_SHAPES.map((shape) => + macroChoice( + `Broken macro ${shape.key}${suffix}`, + `broken-macro-${shape.key}${suffix}`, + shape.value, + ), + ), + // A macro object that is missing entirely. + macroChoice(`No macro${suffix}`, `no-macro${suffix}`, undefined, { + noMacro: true, + }), + // A readable ARRAY that is still unrenderable: a hole, an id-less command + // and two commands sharing an id. Nothing here may be dropped or rewritten + // by a walker that is only passing through. + macroChoice(`Unkeyable macro${suffix}`, `unkeyable-macro${suffix}`, [ + waitCommand("dup-cmd", "First"), + waitCommand("dup-cmd", "Second"), + { name: "No id", type: "Wait", time: 1 }, + null, + "stray", + ]), + // Corruption inside a conditional's branches - one level below every guard + // that only looks at `macro.commands`. + macroChoice(`Conditional macro${suffix}`, `conditional-macro${suffix}`, [ + conditionalCommand( + `cond-both${suffix}`, + { "0": waitCommand(`hidden-then${suffix}`) }, + "not a list", + ), + conditionalCommand(`cond-holes${suffix}`, [null, waitCommand(`t${suffix}`)], [ + waitCommand(`dup-branch${suffix}`), + waitCommand(`dup-branch${suffix}`), + ]), + ]), + ]; + return [ leaf("Head template", "head"), folder("Healthy folder", "healthy", [leaf("Child note", "child")]), @@ -85,13 +202,18 @@ export function malformedTree(): IChoice[] { folder("Nesting folder", "nesting", [ leaf("Nested note", "nested-note"), ...broken("-nested"), + ...brokenMacros("-nested"), null as unknown as IChoice, folder("Deep folder", "deep", [ leaf("Deep note", "deep-note"), ...broken("-deep"), + ...brokenMacros("-deep"), ]), ]), ...broken(), + ...brokenMacros(), + // A healthy macro, so a walker that skips the type entirely is caught too. + macroChoice("Healthy macro", "healthy-macro", [waitCommand("healthy-cmd")]), // A hole in the list itself: `null` survives a truncated write, and it used // to throw inside dedupeChoicesById during loadSettings. null as unknown as IChoice, @@ -109,25 +231,64 @@ export const HEALTHY_IDS = [ "nested-note", "deep", "deep-note", + "healthy-macro", "tail", ]; /** - * Every unreadable `choices` value still in the tree, keyed by folder id, as a - * comparable string. This is what the preservation assertions compare: an edit - * elsewhere in the tree must not rewrite, drop, or "repair" any of them. + * Every unreadable `choices` / `macro.commands` / branch value still in the + * tree, keyed by the node that owns it, as a comparable string. This is what the + * preservation assertions compare: an edit elsewhere in the tree must not + * rewrite, drop, or "repair" any of them. * * List holes (`null`, a stray primitive) are deliberately NOT tracked. They hold * nothing recoverable, so a walker is free to step over one or drop it; only a - * folder's children value can be hiding real choices. + * container's value can be hiding real choices or commands. + * + * Command IDS are not tracked either, for the same reason at one remove: the + * macro EDITOR is allowed to re-key an unkeyable command (see + * normalizeCommandList), and pinning ids here would forbid the very repair + * #1593 added. What is pinned is that no walker LOSES a command. */ export function malformedSnapshot(choices: IChoice[]): string { const seen: unknown[] = []; + + const walkCommands = (owner: string, list: unknown) => { + if (!Array.isArray(list)) { + seen.push([owner, "commands", list]); + return; + } + // Arity is the invariant for a readable list: a walker may re-key an entry + // but must never drop one. + seen.push([owner, "commandCount", list.length]); + list.forEach((command, index) => { + if (typeof command !== "object" || command === null) return; + const node = command as Record; + if (node.type !== "Conditional") return; + walkCommands(`${owner}/${index}/then`, node.thenCommands); + walkCommands(`${owner}/${index}/else`, node.elseCommands); + }); + }; + const walk = (list: unknown) => { if (!Array.isArray(list)) return; for (const entry of list) { if (typeof entry !== "object" || entry === null) continue; const node = entry as Record; + if (node.type === "Macro") { + const macro = node.macro; + if (typeof macro !== "object" || macro === null) { + seen.push([node.id, "macro", macro]); + continue; + } + const macroNode = macro as Record; + if ("commands" in macroNode) { + walkCommands(String(node.id), macroNode.commands); + } else { + seen.push([node.id, "commandsAbsent"]); + } + continue; + } if (node.type !== "Multi") continue; if (Array.isArray(node.choices)) { walk(node.choices); diff --git a/src/utils/packageTraversal.ts b/src/utils/packageTraversal.ts index 005564cc5..b81df8d15 100644 --- a/src/utils/packageTraversal.ts +++ b/src/utils/packageTraversal.ts @@ -9,6 +9,7 @@ import type { IUserScript } from "../types/macros/IUserScript"; import type { IConditionalCommand } from "../types/macros/Conditional/IConditionalCommand"; import type { INestedChoiceCommand } from "../types/macros/QuickCommands/INestedChoiceCommand"; import { childChoicesOf, isChoiceLike, rootChoicesOf } from "./choiceUtils"; +import { commandListOf, isCommandLike } from "./macroUtils"; import { CommandType } from "../types/macros/CommandType"; export interface ChoiceCatalogEntry { @@ -147,18 +148,20 @@ function collectChoiceDependencies(choice: IChoice): Set { } if (isMacroChoice(choice)) { - collectDependenciesFromCommands(choice.macro.commands, dependencies); + collectDependenciesFromCommands(choice.macro?.commands, dependencies); } return dependencies; } function collectDependenciesFromCommands( - commands: ICommand[], + // `unknown`: this is `macro.commands` / a conditional's branch out of + // data.json, and the recursion below feeds itself (see commandListOf). + commands: unknown, accumulator: Set, ): void { - for (const command of commands) { - if (!command) continue; + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; switch (command.type) { case CommandType.Choice: { @@ -277,13 +280,13 @@ export function collectScriptDependencies( } if (isMacroChoice(choice)) { - visitCommands(choice.macro.commands); + visitCommands(choice.macro?.commands); } }; - const visitCommands = (commands: ICommand[]) => { - for (const command of commands) { - if (!command) continue; + const visitCommands = (commands: unknown) => { + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; if ( visitReferencedChoiceFromCommand( command, @@ -364,13 +367,13 @@ export function collectFileDependencies( } if (isMacroChoice(choice)) { - visitCommands(choice.macro.commands); + visitCommands(choice.macro?.commands); } }; - const visitCommands = (commands: ICommand[]) => { - for (const command of commands) { - if (!command) continue; + const visitCommands = (commands: unknown) => { + for (const command of commandListOf(commands)) { + if (!isCommandLike(command)) continue; if ( visitReferencedChoiceFromCommand( command,