openclaw / src /plugins /api-facades.cli.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
253e783 verified
Raw
History Blame Contribute Delete
24.5 kB
import { EventEmitter } from "node:events";
import { Argument, Command, Option } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeStore, type PluginRuntime } from "../plugin-sdk/runtime-store.js";
import { createDeferredCore } from "../shared/deferred.js";
import { buildPluginApi } from "./api-builder.js";
import { instrumentPluginInstanceApi } from "./api-facades.js";
import { PluginInstance } from "./plugin-instance.js";
import type {
OpenClawPluginCliRegistrar,
OpenClawPluginCliRegistrationOptions,
} from "./plugin-registration.types.js";
const runtime = createPluginRuntimeStore<{ id: string }>({
pluginId: "cli-binding-test",
errorMessage: "CLI runtime missing",
});
const instances: PluginInstance[] = [];
function fixture(id = "alpha") {
const instance = new PluginInstance(id);
instances.push(instance);
const registrations: {
registrar: OpenClawPluginCliRegistrar;
options?: OpenClawPluginCliRegistrationOptions;
}[] = [];
const api = instrumentPluginInstanceApi(
buildPluginApi({
id,
name: id,
source: "test",
registrationMode: "discovery",
config: {},
runtime: {} as PluginRuntime,
resolvePath: (value) => value,
logger: { info() {}, warn() {}, error() {}, debug() {} },
handlers: {
registerCli: (registrar, options) => {
registrations.push({ registrar, options });
},
},
}),
instance,
);
instance.run(() => runtime.setRuntime({ id }));
return {
api,
instance,
registrations,
async register(program: Command, registrar: OpenClawPluginCliRegistrar) {
api.registerCli(registrar, { commands: [id] });
await registrations
.at(-1)!
.registrar({ program, parentPath: [], config: {}, logger: api.logger });
},
};
}
const current = () => runtime.getRuntime().id;
afterEach(async () => {
for (const instance of instances.splice(0)) {
await instance.dispose();
}
runtime.clearRuntime();
vi.unstubAllEnvs();
});
describe("managed CLI callbacks", () => {
it("keeps native fluent/subclass identity and scopes delayed action, hook and parser callbacks", async () => {
class NativeCommand extends Command {
#marker = "native";
marker() {
return this.#marker;
}
override createCommand(name?: string) {
return new NativeCommand(name);
}
}
const host = new NativeCommand();
const owner = fixture();
const events: string[] = [];
let leaf: Command;
await owner.register(host, async ({ program }) => {
expect(program).toBe(host);
await Promise.resolve();
const root = program.command("matrix");
expect(root).toBe(program.commands[0]);
root.hook("preSubcommand", async (parent, command) => {
expect(parent).toBe(root);
expect(command).toBe(leaf);
events.push(`sub:${current()}`);
await Promise.resolve();
expect(current()).toBe("alpha");
});
leaf = root.command("setup");
expect(leaf.option("--account <id>", "account", (value) => `${current()}:${value}`)).toBe(
leaf,
);
expect(leaf.argument("<value>", "value", (value) => `${current()}:${value}`)).toBe(leaf);
leaf.hook("preAction", async (command, action) => {
expect(command).toBe(leaf);
expect(action).toBe(leaf);
await Promise.resolve();
events.push(`pre:${current()}`);
});
expect(
leaf.action(async function (value, options, command) {
expect(this).toBe(leaf);
expect(command).toBe(leaf);
expect(this).toBeInstanceOf(NativeCommand);
if (!(this instanceof NativeCommand)) {
throw new Error("Expected native command subclass");
}
expect(this.marker()).toBe("native");
expect(value).toBe("alpha:input");
expect(options.account).toBe("alpha:target");
await Promise.resolve();
events.push(`action:${current()}`);
}),
).toBe(leaf);
leaf.hook("postAction", () => {
events.push(`post:${current()}`);
});
});
expect(events).toEqual([]);
expect(runtime.tryGetRuntime()).toBeNull();
await host.parseAsync(["matrix", "setup", "input", "--account", "target"], { from: "user" });
expect(events).toEqual(["sub:alpha", "pre:alpha", "action:alpha", "post:alpha"]);
expect(runtime.tryGetRuntime()).toBeNull();
await owner.instance.dispose();
await expect(
host.parseAsync(["matrix", "setup", "input", "--account", "target"], { from: "user" }),
).rejects.toThrow("reloaded or disabled");
});
it("binds prepared and later Option/Argument parsers without changing their native identity", async () => {
const owner = fixture();
const host = new Command();
vi.stubEnv("CLI_BINDING_ACCOUNT", "from-env");
const option = new Option("--account <id>")
.env("CLI_BINDING_ACCOUNT")
.argParser((value) => `${current()}:${value}`);
const argument = new Argument("<name>").argParser((value) => `${current()}:${value}`);
let command: Command;
await owner.register(host, ({ program }) => {
command = program.createCommand("prepared");
expect(program.addCommand(command)).toBe(program);
expect(command.addOption(option)).toBe(command);
expect(command.addArgument(argument)).toBe(command);
expect(command.options[0]).toBe(option);
expect(command.registeredArguments[0]).toBe(argument);
command.action((name, options, action) => {
expect(action).toBe(command);
expect(name).toBe("alpha:name");
expect(options.account).toBe("alpha:from-env");
});
});
await host.parseAsync(["prepared", "name"], { from: "user" });
await owner.instance.dispose();
expect(() => option.parseArg!("stale", undefined)).toThrow("reloaded or disabled");
expect(() => argument.parseArg!("stale", undefined)).toThrow("reloaded or disabled");
});
it("keeps sibling instances and async continuations on their own runtime when sharing a native root", async () => {
const host = new Command();
const alpha = fixture("alpha");
const beta = fixture("beta");
const observations: string[] = [];
const action = async function (this: Command) {
const before = current();
await Promise.resolve();
observations.push(`${this.name()}:${before}:${current()}`);
};
await alpha.register(host, ({ program }) => {
program.command("alpha").action(action);
});
await beta.register(host, ({ program }) => {
program.command("beta").action(action);
});
await host.parseAsync(["alpha"], { from: "user" });
await host.parseAsync(["beta"], { from: "user" });
await alpha.instance.dispose();
await expect(host.parseAsync(["alpha"], { from: "user" })).rejects.toThrow(
"reloaded or disabled",
);
await host.parseAsync(["beta"], { from: "user" });
expect(observations).toEqual(["alpha:alpha:alpha", "beta:beta:beta", "beta:beta:beta"]);
expect(runtime.tryGetRuntime()).toBeNull();
});
it.each([false, true])(
"drains an admitted async action and rejects a new parse while retiring (prepared: %s)",
async (prepared) => {
const owner = fixture();
const started = createDeferredCore();
const finish = createDeferredCore();
const first = new Command();
const second = new Command();
const register: OpenClawPluginCliRegistrar = ({ program }) => {
const command = prepared ? new Command("run") : program.command("run");
command.action(async () => {
started.resolve();
await finish.promise;
expect(current()).toBe("alpha");
});
if (prepared) {
program.addCommand(command);
}
};
await owner.register(first, register);
await owner.register(second, register);
const running = first.parseAsync(["run"], { from: "user" });
await started.promise;
let disposed = false;
const disposal = owner.instance.dispose().then(() => {
disposed = true;
});
try {
const next = second.parseAsync(["run"], { from: "user" });
const rejected = expect(next).rejects.toThrow("reloaded or disabled");
expect(disposed).toBe(false);
finish.resolve();
await rejected;
} finally {
finish.resolve();
await running;
await disposal;
}
expect(disposed).toBe(true);
},
);
it("preserves EventEmitter once/removal/listener identity while binding delayed option events", async () => {
const owner = fixture();
const host = new Command();
const seen: string[] = [];
if (!(host instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
const listener = function (this: Command) {
expect(this).toBe(host);
seen.push(current());
};
await owner.register(host, ({ program }) => {
if (!(program instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
expect(program.on("custom", listener)).toBe(program);
expect(program.listeners("custom")).toEqual([listener]);
expect(program.off("custom", listener)).toBe(program);
expect(program.listenerCount("custom")).toBe(0);
program.once("custom", listener);
program.prependOnceListener("custom", listener);
program.option("--flag").on("option:flag", listener);
});
expect(host.listeners("custom")).toEqual([listener, listener]);
host.emit("custom");
host.emit("custom");
expect(host.listenerCount("custom")).toBe(0);
await host.parseAsync(["--flag"], { from: "user" });
expect(seen).toEqual(["alpha", "alpha", "alpha"]);
await owner.instance.dispose();
expect(() => host.emit("option:flag")).toThrow("reloaded or disabled");
});
it("keeps help lazy and scopes configured help/output callbacks with native command arguments", async () => {
const owner = fixture();
const host = new Command("help-test");
const output: string[] = [];
const description = vi.fn((command: Command) => {
expect(command).toBe(host);
return current();
});
await owner.register(host, ({ program }) => {
expect(program.configureHelp({ commandDescription: description })).toBe(program);
expect(
program.configureOutput({
writeOut: (text) => {
expect(current()).toBe("alpha");
output.push(text);
},
}),
).toBe(program);
expect(
program.addHelpText("after", ({ command }) => {
expect(command).toBe(host);
return `extra:${current()}`;
}),
).toBe(program);
});
expect(description).not.toHaveBeenCalled();
expect(output).toEqual([]);
host.outputHelp();
expect(output.join("")).toContain("extra:alpha");
expect(description).toHaveBeenCalled();
await owner.instance.dispose();
expect(() => host.outputHelp()).toThrow("reloaded or disabled");
});
it("binds callbacks installed by an async lazy subcommand hook", async () => {
const owner = fixture();
const host = new Command();
const installed = vi.fn();
const action = vi.fn((options) => {
expect(current()).toBe("alpha");
expect(options.account).toBe("alpha:target");
});
await owner.register(host, ({ program }) => {
const root = program.command("lazy");
const leaf = root.command("run");
root.hook("preSubcommand", async () => {
await Promise.resolve();
expect(current()).toBe("alpha");
leaf.option("--account <id>", "account", (value) => `${current()}:${value}`).action(action);
installed();
});
});
expect(installed).not.toHaveBeenCalled();
await host.parseAsync(["lazy", "run", "--account", "target"], { from: "user" });
expect(installed).toHaveBeenCalledOnce();
expect(action).toHaveBeenCalledOnce();
});
it("scopes exit overrides without replacing native Commander errors", async () => {
const owner = fixture();
const host = new Command();
const exit = vi.fn((error: Error) => {
expect(current()).toBe("alpha");
throw error;
});
await owner.register(host, ({ program }) => {
program.configureOutput({ writeErr() {} }).exitOverride(exit);
});
expect(() => host.error("synthetic failure", { code: "cli.fixture", exitCode: 9 })).toThrow(
"synthetic failure",
);
expect(exit).toHaveBeenCalledOnce();
expect(exit.mock.calls[0]?.[0]).toMatchObject({ code: "cli.fixture", exitCode: 9 });
});
it("binds the node-feature alias through the same CLI owner", async () => {
const owner = fixture();
const program = new Command("nodes");
const action = vi.fn(() => {
expect(current()).toBe("alpha");
});
owner.api.registerNodeCliFeature(
({ program: parent }) => {
parent.command("camera").action(action);
},
{ commands: ["camera"] },
);
const { registrar, options } = owner.registrations[0]!;
expect(options?.parentPath).toEqual(["nodes"]);
await registrar({ program, parentPath: ["nodes"], config: {}, logger: owner.api.logger });
await program.parseAsync(["camera"], { from: "user" });
expect(action).toHaveBeenCalledOnce();
});
it("preserves function-valued parser defaults and results as native caller data", async () => {
const owner = fixture();
const host = new Command();
const value = () => "data";
const action = vi.fn((argument, options) => {
expect(current()).toBe("alpha");
expect(argument).toBe(value);
expect(options.transform).toBe(value);
});
await owner.register(host, ({ program }) => {
program
.command("data")
.option("--transform <value>", "transform", () => value, value)
.argument("[value]", "value", () => value, value)
.action(action);
});
await host.parseAsync(["data"], { from: "user" });
await host.parseAsync(["data", "input", "--transform", "input"], { from: "user" });
expect(action).toHaveBeenCalledTimes(2);
});
it("adopts preconfigured command callbacks when the registrar adds their native tree", async () => {
const owner = fixture();
const host = new Command();
const events: string[] = [];
let child!: Command;
const option = new Option("--account <id>").argParser((value) => `${current()}:${value}`);
const argument = new Argument("<value>").argParser((value) => `${current()}:${value}`);
await owner.register(host, ({ program }) => {
child = new Command("prepared");
child.addOption(option).addArgument(argument);
child.hook("preAction", (command) => {
expect(command).toBe(child);
events.push(`pre:${current()}`);
});
child.action(async function (value, options, command) {
expect(this).toBe(child);
expect(command).toBe(child);
expect(value).toBe("alpha:input");
expect(options.account).toBe("alpha:target");
await Promise.resolve();
events.push(`action:${current()}`);
});
child.hook("postAction", () => {
events.push(`post:${current()}`);
});
expect(program.addCommand(child)).toBe(program);
});
expect(host.commands[0]).toBe(child);
expect(child.options[0]).toBe(option);
expect(child.registeredArguments[0]).toBe(argument);
expect(runtime.tryGetRuntime()).toBeNull();
await host.parseAsync(["prepared", "input", "--account", "target"], { from: "user" });
expect(events).toEqual(["pre:alpha", "action:alpha", "post:alpha"]);
expect(runtime.tryGetRuntime()).toBeNull();
await owner.instance.dispose();
await expect(
host.parseAsync(["prepared", "input", "--account", "target"], { from: "user" }),
).rejects.toThrow("reloaded or disabled");
});
it("adopts prepared descendant help and listeners without replaying listener meta-events", async () => {
const owner = fixture();
const host = new Command();
const branch = new Command("prepared");
const leaf = new Command("leaf");
if (!(leaf instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
const output: string[] = [];
const seen: string[] = [];
const listener = function (this: Command) {
expect(this).toBe(leaf);
seen.push(current());
};
const newListener = vi.fn();
const removedListener = vi.fn();
leaf.on("newListener", newListener).on("removeListener", removedListener);
leaf.on("custom", listener).once("custom", listener);
leaf.configureHelp({ commandDescription: () => current() });
leaf.configureOutput({ writeOut: (text) => output.push(`${current()}:${text}`) });
leaf.addHelpText("after", () => `help:${current()}`);
leaf.action(() => {
seen.push(`action:${current()}`);
});
branch.addCommand(leaf);
const addedBefore = newListener.mock.calls.length;
const removedBefore = removedListener.mock.calls.length;
await owner.register(host, ({ program }) => {
program.addCommand(branch);
});
expect(newListener.mock.calls).toHaveLength(addedBefore);
expect(removedListener.mock.calls).toHaveLength(removedBefore);
expect(host.commands[0]).toBe(branch);
expect(branch.commands[0]).toBe(leaf);
expect(leaf.listeners("custom")).toEqual([listener, listener]);
leaf.emit("custom");
leaf.off("custom", listener);
leaf.emit("custom");
expect(seen).toEqual(["alpha", "alpha"]);
expect(leaf.listenerCount("custom")).toBe(0);
leaf.outputHelp();
expect(output.join("")).toContain("help:alpha");
await host.parseAsync(["prepared", "leaf"], { from: "user" });
expect(seen).toEqual(["alpha", "alpha", "action:alpha"]);
await owner.instance.dispose();
expect(() => leaf.outputHelp()).toThrow("reloaded or disabled");
await expect(host.parseAsync(["prepared", "leaf"], { from: "user" })).rejects.toThrow(
"reloaded or disabled",
);
});
it("preserves native listener-array descriptors without invoking metadata accessors", async () => {
const owner = fixture();
const child = new Command("prepared");
if (!(child instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
const seen: string[] = [];
child.on("custom", () => seen.push(`on:${current()}`));
child.once("custom", () => seen.push(`once:${current()}`));
const events: unknown = Reflect.get(child, "_events");
if (!events || typeof events !== "object") {
throw new Error("Expected the real EventEmitter event table");
}
const stored: unknown = Reflect.get(events, "custom");
if (!Array.isArray(stored)) {
throw new Error("Expected the real EventEmitter listener array");
}
const metadata = Symbol("native-array-metadata");
const marker = {};
const readMetadata = vi.fn(() => marker);
const prototype = Object.create(Object.getPrototypeOf(stored));
Object.setPrototypeOf(stored, prototype);
Object.defineProperty(stored, metadata, { get: readMetadata, configurable: true });
Object.defineProperty(stored, "__proto__", { value: marker, configurable: true });
const length = Object.getOwnPropertyDescriptor(stored, "length");
await owner.register(new Command(), ({ program }) => {
program.addCommand(child);
});
const reboundEvents: unknown = Reflect.get(child, "_events");
if (!reboundEvents || typeof reboundEvents !== "object") {
throw new Error("Expected the adopted EventEmitter event table");
}
const rebound: unknown = Reflect.get(reboundEvents, "custom");
if (!Array.isArray(rebound)) {
throw new Error("Expected an adopted native listener array");
}
expect(rebound).not.toBe(stored);
expect(Object.getPrototypeOf(rebound)).toBe(prototype);
expect(Reflect.ownKeys(rebound)).toEqual(Reflect.ownKeys(stored));
expect(Object.getOwnPropertyDescriptor(rebound, "length")).toEqual(length);
for (const key of [metadata, "__proto__"]) {
expect(Object.getOwnPropertyDescriptor(rebound, key)).toEqual(
Object.getOwnPropertyDescriptor(stored, key),
);
}
expect(readMetadata).not.toHaveBeenCalled();
child.emit("custom");
child.emit("custom");
expect(seen).toEqual(["on:alpha", "once:alpha", "on:alpha"]);
expect(readMetadata).not.toHaveBeenCalled();
});
it("removes the latest matching prepared on/once registration before emission", async () => {
const owner = fixture();
const host = new Command();
const child = new Command("prepared");
if (!(child instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
const seen: string[] = [];
const listener = () => seen.push(current());
child.on("custom", listener).once("custom", listener);
await owner.register(host, ({ program }) => {
program.addCommand(child);
});
child.off("custom", listener);
child.emit("custom");
child.emit("custom");
expect(seen).toEqual(["alpha", "alpha"]);
expect(child.listenerCount("custom")).toBe(1);
child.off("custom", listener);
await owner.instance.dispose();
expect(child.emit("custom")).toBe(false);
});
it.each(["add", "remove"] as const)(
"preserves native in-flight listener snapshots when prepared listeners %s during emission",
async (mutation) => {
const run = async (managed: boolean) => {
const command = new Command("prepared");
if (!(command instanceof EventEmitter)) {
throw new Error("Expected native EventEmitter");
}
const seen: string[] = [];
let changed = false;
const second = () => seen.push("second");
const added = () => seen.push("added");
command.on("custom", () => {
if (managed) {
expect(current()).toBe("alpha");
}
seen.push("first");
if (!changed) {
changed = true;
if (mutation === "add") {
command.on("custom", added);
} else {
command.off("custom", second);
}
}
});
command.on("custom", second);
if (managed) {
const owner = fixture();
await owner.register(new Command(), ({ program }) => {
program.addCommand(command);
});
}
command.emit("custom");
const firstEmission = [...seen];
command.emit("custom");
return { firstEmission, seen };
};
const native = await run(false);
expect(native.firstEmission).toEqual(["first", "second"]);
expect(await run(true)).toEqual(native);
},
);
it("keeps callbacks owned when native addCommand attaches and then throws", async () => {
const owner = fixture();
const host = new Command();
const seen: string[] = [];
let child!: Command;
await owner.register(host, ({ program }) => {
child = new Command("partial").passThroughOptions().action(() => {
seen.push(current());
});
expect(() => program.addCommand(child)).toThrow("enablePositionalOptions");
});
expect(host.commands).toContain(child);
expect(child.parent).toBe(host);
await child.parseAsync([], { from: "user" });
expect(seen).toEqual(["alpha"]);
await owner.instance.dispose();
await expect(child.parseAsync([], { from: "user" })).rejects.toThrow("reloaded or disabled");
});
it("does not adopt preexisting host callbacks or change a caller-owned command's identity", async () => {
const host = new Command();
const foreign = new Command("host");
const hostAction = vi.fn(function (this: Command, _options, command) {
expect(this).toBe(foreign);
expect(command).toBe(foreign);
expect(runtime.tryGetRuntime()).toBeNull();
});
foreign.action(hostAction);
host.addCommand(foreign);
const owner = fixture();
await owner.register(host, ({ program }) => {
program.command("plugin").action(() => {
current();
});
});
expect(host.commands[0]).toBe(foreign);
await owner.instance.dispose();
await host.parseAsync(["host"], { from: "user" });
expect(hostAction).toHaveBeenCalledOnce();
});
});