mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00:00
test(commands): dedupe command and onboarding test cases
This commit is contained in:
@@ -6,11 +6,36 @@ const flushMicrotasks = async () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function withFakeTimers(run: () => Promise<void>) {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTypingHarness(overrides: Partial<Parameters<typeof createTypingCallbacks>[0]> = {}) {
|
||||||
|
const start = overrides.start ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const stop = overrides.stop ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onStartError = overrides.onStartError ?? vi.fn();
|
||||||
|
const onStopError = overrides.onStopError ?? vi.fn();
|
||||||
|
const callbacks = createTypingCallbacks({
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
onStartError,
|
||||||
|
...(onStopError ? { onStopError } : {}),
|
||||||
|
...(overrides.maxConsecutiveFailures !== undefined
|
||||||
|
? { maxConsecutiveFailures: overrides.maxConsecutiveFailures }
|
||||||
|
: {}),
|
||||||
|
...(overrides.maxDurationMs !== undefined ? { maxDurationMs: overrides.maxDurationMs } : {}),
|
||||||
|
});
|
||||||
|
return { start, stop, onStartError, onStopError, callbacks };
|
||||||
|
}
|
||||||
|
|
||||||
describe("createTypingCallbacks", () => {
|
describe("createTypingCallbacks", () => {
|
||||||
it("invokes start on reply start", async () => {
|
it("invokes start on reply start", async () => {
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
const { start, onStartError, callbacks } = createTypingHarness();
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
|
|
||||||
@@ -19,9 +44,9 @@ describe("createTypingCallbacks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports start errors", async () => {
|
it("reports start errors", async () => {
|
||||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
const { onStartError, callbacks } = createTypingHarness({
|
||||||
const onStartError = vi.fn();
|
start: vi.fn().mockRejectedValue(new Error("fail")),
|
||||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
});
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
|
|
||||||
@@ -29,11 +54,9 @@ describe("createTypingCallbacks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes stop on idle and reports stop errors", async () => {
|
it("invokes stop on idle and reports stop errors", async () => {
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
const { stop, onStopError, callbacks } = createTypingHarness({
|
||||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
stop: vi.fn().mockRejectedValue(new Error("stop")),
|
||||||
const onStartError = vi.fn();
|
});
|
||||||
const onStopError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
|
||||||
|
|
||||||
callbacks.onIdle?.();
|
callbacks.onIdle?.();
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -43,13 +66,8 @@ describe("createTypingCallbacks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends typing keepalive pings until idle cleanup", async () => {
|
it("sends typing keepalive pings until idle cleanup", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { start, stop, callbacks } = createTypingHarness();
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -68,18 +86,14 @@ describe("createTypingCallbacks", () => {
|
|||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(9_000);
|
await vi.advanceTimersByTimeAsync(9_000);
|
||||||
expect(start).toHaveBeenCalledTimes(3);
|
expect(start).toHaveBeenCalledTimes(3);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops keepalive after consecutive start failures", async () => {
|
it("stops keepalive after consecutive start failures", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { start, onStartError, callbacks } = createTypingHarness({
|
||||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||||
const onStartError = vi.fn();
|
});
|
||||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||||
@@ -90,19 +104,13 @@ describe("createTypingCallbacks", () => {
|
|||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(9_000);
|
await vi.advanceTimersByTimeAsync(9_000);
|
||||||
expect(start).toHaveBeenCalledTimes(2);
|
expect(start).toHaveBeenCalledTimes(2);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not restart keepalive when breaker trips on initial start", async () => {
|
it("does not restart keepalive when breaker trips on initial start", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { start, onStartError, callbacks } = createTypingHarness({
|
||||||
const start = vi.fn().mockRejectedValue(new Error("gone"));
|
start: vi.fn().mockRejectedValue(new Error("gone")),
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
onStartError,
|
|
||||||
maxConsecutiveFailures: 1,
|
maxConsecutiveFailures: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,28 +120,21 @@ describe("createTypingCallbacks", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(9_000);
|
await vi.advanceTimersByTimeAsync(9_000);
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets failure counter after a successful keepalive tick", async () => {
|
it("resets failure counter after a successful keepalive tick", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
const start = vi.fn().mockImplementation(async () => {
|
const { start, onStartError, callbacks } = createTypingHarness({
|
||||||
callCount += 1;
|
start: vi.fn().mockImplementation(async () => {
|
||||||
if (callCount % 2 === 1) {
|
callCount += 1;
|
||||||
throw new Error("flaky");
|
if (callCount % 2 === 1) {
|
||||||
}
|
throw new Error("flaky");
|
||||||
});
|
}
|
||||||
const onStartError = vi.fn();
|
}),
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
onStartError,
|
|
||||||
maxConsecutiveFailures: 2,
|
maxConsecutiveFailures: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
await callbacks.onReplyStart(); // fail
|
await callbacks.onReplyStart(); // fail
|
||||||
await vi.advanceTimersByTimeAsync(3_000); // success
|
await vi.advanceTimersByTimeAsync(3_000); // success
|
||||||
await vi.advanceTimersByTimeAsync(3_000); // fail
|
await vi.advanceTimersByTimeAsync(3_000); // fail
|
||||||
@@ -142,16 +143,11 @@ describe("createTypingCallbacks", () => {
|
|||||||
|
|
||||||
expect(start).toHaveBeenCalledTimes(5);
|
expect(start).toHaveBeenCalledTimes(5);
|
||||||
expect(onStartError).toHaveBeenCalledTimes(3);
|
expect(onStartError).toHaveBeenCalledTimes(3);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates stop across idle and cleanup", async () => {
|
it("deduplicates stop across idle and cleanup", async () => {
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
const { stop, callbacks } = createTypingHarness();
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
|
||||||
|
|
||||||
callbacks.onIdle?.();
|
callbacks.onIdle?.();
|
||||||
callbacks.onCleanup?.();
|
callbacks.onCleanup?.();
|
||||||
@@ -161,12 +157,8 @@ describe("createTypingCallbacks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not restart keepalive after idle cleanup", async () => {
|
it("does not restart keepalive after idle cleanup", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { start, stop, callbacks } = createTypingHarness();
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
@@ -179,26 +171,15 @@ describe("createTypingCallbacks", () => {
|
|||||||
|
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
expect(stop).toHaveBeenCalledTimes(1);
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== TTL Safety Tests ==========
|
// ========== TTL Safety Tests ==========
|
||||||
describe("TTL safety", () => {
|
describe("TTL safety", () => {
|
||||||
it("auto-stops typing after maxDurationMs", async () => {
|
it("auto-stops typing after maxDurationMs", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
|
||||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
const { start, stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onStartError,
|
|
||||||
maxDurationMs: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
@@ -212,24 +193,13 @@ describe("createTypingCallbacks", () => {
|
|||||||
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded"));
|
expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("TTL exceeded"));
|
||||||
|
|
||||||
consoleWarn.mockRestore();
|
consoleWarn.mockRestore();
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not auto-stop if idle is called before TTL", async () => {
|
it("does not auto-stop if idle is called before TTL", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
|
||||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onStartError,
|
|
||||||
maxDurationMs: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
|
|
||||||
@@ -249,18 +219,12 @@ describe("createTypingCallbacks", () => {
|
|||||||
expect(stop).toHaveBeenCalledTimes(1);
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
consoleWarn.mockRestore();
|
consoleWarn.mockRestore();
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default 60s TTL when not specified", async () => {
|
it("uses default 60s TTL when not specified", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { stop, callbacks } = createTypingHarness();
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
|
|
||||||
@@ -271,46 +235,24 @@ describe("createTypingCallbacks", () => {
|
|||||||
// Should stop at 60s
|
// Should stop at 60s
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
expect(stop).toHaveBeenCalledTimes(1);
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables TTL when maxDurationMs is 0", async () => {
|
it("disables TTL when maxDurationMs is 0", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 0 });
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onStartError,
|
|
||||||
maxDurationMs: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
|
|
||||||
// Should not auto-stop even after long time
|
// Should not auto-stop even after long time
|
||||||
await vi.advanceTimersByTimeAsync(300_000);
|
await vi.advanceTimersByTimeAsync(300_000);
|
||||||
expect(stop).not.toHaveBeenCalled();
|
expect(stop).not.toHaveBeenCalled();
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets TTL timer on restart after idle", async () => {
|
it("resets TTL timer on restart after idle", async () => {
|
||||||
vi.useFakeTimers();
|
await withFakeTimers(async () => {
|
||||||
try {
|
const { stop, callbacks } = createTypingHarness({ maxDurationMs: 10_000 });
|
||||||
const start = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const stop = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const onStartError = vi.fn();
|
|
||||||
const callbacks = createTypingCallbacks({
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onStartError,
|
|
||||||
maxDurationMs: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// First start
|
// First start
|
||||||
await callbacks.onReplyStart();
|
await callbacks.onReplyStart();
|
||||||
@@ -330,9 +272,7 @@ describe("createTypingCallbacks", () => {
|
|||||||
|
|
||||||
// Should not trigger stop again since it's closed
|
// Should not trigger stop again since it's closed
|
||||||
expect(stop).not.toHaveBeenCalled();
|
expect(stop).not.toHaveBeenCalled();
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,61 +156,49 @@ async function expectCronEditWithScheduleLookupExit(
|
|||||||
).rejects.toThrow("__exit__:1");
|
).rejects.toThrow("__exit__:1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCronRunAndCaptureExit(params: { ran: boolean }) {
|
||||||
|
resetGatewayMock();
|
||||||
|
callGatewayFromCli.mockImplementation(
|
||||||
|
async (method: string, _opts: unknown, callParams?: unknown) => {
|
||||||
|
if (method === "cron.status") {
|
||||||
|
return { enabled: true };
|
||||||
|
}
|
||||||
|
if (method === "cron.run") {
|
||||||
|
return { ok: true, params: callParams, ran: params.ran };
|
||||||
|
}
|
||||||
|
return { ok: true, params: callParams };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeModule = await import("../runtime.js");
|
||||||
|
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||||
|
const originalExit = runtime.exit;
|
||||||
|
const exitSpy = vi.fn();
|
||||||
|
runtime.exit = exitSpy;
|
||||||
|
try {
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
||||||
|
} finally {
|
||||||
|
runtime.exit = originalExit;
|
||||||
|
}
|
||||||
|
return exitSpy;
|
||||||
|
}
|
||||||
|
|
||||||
describe("cron cli", () => {
|
describe("cron cli", () => {
|
||||||
it("exits 0 for cron run when job executes successfully", async () => {
|
it.each([
|
||||||
resetGatewayMock();
|
{
|
||||||
callGatewayFromCli.mockImplementation(
|
name: "exits 0 for cron run when job executes successfully",
|
||||||
async (method: string, _opts: unknown, params?: unknown) => {
|
ran: true,
|
||||||
if (method === "cron.status") {
|
expectedExitCode: 0,
|
||||||
return { enabled: true };
|
},
|
||||||
}
|
{
|
||||||
if (method === "cron.run") {
|
name: "exits 1 for cron run when job does not execute",
|
||||||
return { ok: true, params, ran: true };
|
ran: false,
|
||||||
}
|
expectedExitCode: 1,
|
||||||
return { ok: true, params };
|
},
|
||||||
},
|
])("$name", async ({ ran, expectedExitCode }) => {
|
||||||
);
|
const exitSpy = await runCronRunAndCaptureExit({ ran });
|
||||||
|
expect(exitSpy).toHaveBeenCalledWith(expectedExitCode);
|
||||||
const runtimeModule = await import("../runtime.js");
|
|
||||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
|
||||||
const originalExit = runtime.exit;
|
|
||||||
const exitSpy = vi.fn();
|
|
||||||
runtime.exit = exitSpy;
|
|
||||||
try {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
|
||||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
||||||
} finally {
|
|
||||||
runtime.exit = originalExit;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exits 1 for cron run when job does not execute", async () => {
|
|
||||||
resetGatewayMock();
|
|
||||||
callGatewayFromCli.mockImplementation(
|
|
||||||
async (method: string, _opts: unknown, params?: unknown) => {
|
|
||||||
if (method === "cron.status") {
|
|
||||||
return { enabled: true };
|
|
||||||
}
|
|
||||||
if (method === "cron.run") {
|
|
||||||
return { ok: true, params, ran: false };
|
|
||||||
}
|
|
||||||
return { ok: true, params };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtimeModule = await import("../runtime.js");
|
|
||||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
|
||||||
const originalExit = runtime.exit;
|
|
||||||
const exitSpy = vi.fn();
|
|
||||||
runtime.exit = exitSpy;
|
|
||||||
try {
|
|
||||||
const program = buildProgram();
|
|
||||||
await program.parseAsync(["cron", "run", "job-1"], { from: "user" });
|
|
||||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
||||||
} finally {
|
|
||||||
runtime.exit = originalExit;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => {
|
it("trims model and thinking on cron add", { timeout: CRON_CLI_TEST_TIMEOUT_MS }, async () => {
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ function makeRuntime() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
||||||
|
writes.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
}) as typeof process.stdout.write);
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
return writes.join("");
|
||||||
|
} finally {
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("ensureConfigReady", () => {
|
describe("ensureConfigReady", () => {
|
||||||
async function loadEnsureConfigReady() {
|
async function loadEnsureConfigReady() {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -107,36 +121,22 @@ describe("ensureConfigReady", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
||||||
const stdoutWrites: string[] = [];
|
|
||||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
|
||||||
stdoutWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
}) as typeof process.stdout.write);
|
|
||||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||||
process.stdout.write("Doctor warnings\n");
|
process.stdout.write("Doctor warnings\n");
|
||||||
});
|
});
|
||||||
try {
|
const output = await withCapturedStdout(async () => {
|
||||||
await runEnsureConfigReady(["message"], true);
|
await runEnsureConfigReady(["message"], true);
|
||||||
expect(stdoutWrites.join("")).not.toContain("Doctor warnings");
|
});
|
||||||
} finally {
|
expect(output).not.toContain("Doctor warnings");
|
||||||
writeSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
||||||
const stdoutWrites: string[] = [];
|
|
||||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
|
||||||
stdoutWrites.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
}) as typeof process.stdout.write);
|
|
||||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||||
process.stdout.write("Doctor warnings\n");
|
process.stdout.write("Doctor warnings\n");
|
||||||
});
|
});
|
||||||
try {
|
const output = await withCapturedStdout(async () => {
|
||||||
await runEnsureConfigReady(["message"], false);
|
await runEnsureConfigReady(["message"], false);
|
||||||
expect(stdoutWrites.join("")).toContain("Doctor warnings");
|
});
|
||||||
} finally {
|
expect(output).toContain("Doctor warnings");
|
||||||
writeSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,31 @@ function mockAcpManager(params: {
|
|||||||
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
|
} as unknown as ReturnType<typeof acpManagerModule.getAcpSessionManager>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runAcpSessionWithPolicyOverrides(params: {
|
||||||
|
acpOverrides: Partial<NonNullable<OpenClawConfig["acp"]>>;
|
||||||
|
resolveSession?: Parameters<typeof mockAcpManager>[0]["resolveSession"];
|
||||||
|
}) {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
writeAcpSessionStore(storePath);
|
||||||
|
mockConfigWithAcpOverrides(home, storePath, params.acpOverrides);
|
||||||
|
|
||||||
|
const runTurn = vi.fn(async (_params: unknown) => {});
|
||||||
|
mockAcpManager({
|
||||||
|
runTurn: (input: unknown) => runTurn(input),
|
||||||
|
...(params.resolveSession ? { resolveSession: params.resolveSession } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "ACP_DISPATCH_DISABLED",
|
||||||
|
});
|
||||||
|
expect(runTurn).not.toHaveBeenCalled();
|
||||||
|
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("agentCommand ACP runtime routing", () => {
|
describe("agentCommand ACP runtime routing", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -221,50 +246,19 @@ describe("agentCommand ACP runtime routing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks ACP turns when ACP is disabled by policy", async () => {
|
it.each([
|
||||||
await withTempHome(async (home) => {
|
{
|
||||||
const storePath = path.join(home, "sessions.json");
|
name: "blocks ACP turns when ACP is disabled by policy",
|
||||||
writeAcpSessionStore(storePath);
|
acpOverrides: { enabled: false } satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||||
mockConfigWithAcpOverrides(home, storePath, {
|
},
|
||||||
enabled: false,
|
{
|
||||||
});
|
name: "blocks ACP turns when ACP dispatch is disabled by policy",
|
||||||
|
acpOverrides: {
|
||||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
|
||||||
mockAcpManager({
|
|
||||||
runTurn: (params: unknown) => runTurn(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: "ACP_DISPATCH_DISABLED",
|
|
||||||
});
|
|
||||||
expect(runTurn).not.toHaveBeenCalled();
|
|
||||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks ACP turns when ACP dispatch is disabled by policy", async () => {
|
|
||||||
await withTempHome(async (home) => {
|
|
||||||
const storePath = path.join(home, "sessions.json");
|
|
||||||
writeAcpSessionStore(storePath);
|
|
||||||
mockConfigWithAcpOverrides(home, storePath, {
|
|
||||||
dispatch: { enabled: false },
|
dispatch: { enabled: false },
|
||||||
});
|
} satisfies Partial<NonNullable<OpenClawConfig["acp"]>>,
|
||||||
|
},
|
||||||
const runTurn = vi.fn(async (_params: unknown) => {});
|
])("$name", async ({ acpOverrides }) => {
|
||||||
mockAcpManager({
|
await runAcpSessionWithPolicyOverrides({ acpOverrides });
|
||||||
runTurn: (params: unknown) => runTurn(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: "ACP_DISPATCH_DISABLED",
|
|
||||||
});
|
|
||||||
expect(runTurn).not.toHaveBeenCalled();
|
|
||||||
expect(runEmbeddedPiAgentSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks ACP turns when ACP agent is disallowed by policy", async () => {
|
it("blocks ACP turns when ACP agent is disallowed by policy", async () => {
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ async function runWithDefaultAgentConfig(params: {
|
|||||||
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runEmbeddedWithTempConfig(params: {
|
||||||
|
args: Parameters<typeof agentCommand>[0];
|
||||||
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||||
|
telegramOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["telegram"]>>;
|
||||||
|
agentsList?: Array<{ id: string; default?: boolean }>;
|
||||||
|
}) {
|
||||||
|
return withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store, params.agentOverrides, params.telegramOverrides, params.agentsList);
|
||||||
|
await agentCommand(params.args, runtime);
|
||||||
|
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function writeSessionStoreSeed(
|
function writeSessionStoreSeed(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
sessions: Record<string, Record<string, unknown>>,
|
sessions: Record<string, Record<string, unknown>>,
|
||||||
@@ -101,54 +115,149 @@ function writeSessionStoreSeed(
|
|||||||
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDefaultAgentResult(params?: {
|
||||||
|
payloads?: Array<Record<string, unknown>>;
|
||||||
|
durationMs?: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
payloads: params?.payloads ?? [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: params?.durationMs ?? 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastEmbeddedCall() {
|
||||||
|
return vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectLastRunProviderModel(provider: string, model: string): void {
|
||||||
|
const callArgs = getLastEmbeddedCall();
|
||||||
|
expect(callArgs?.provider).toBe(provider);
|
||||||
|
expect(callArgs?.model).toBe(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionStore<T>(storePath: string): Record<string, T> {
|
||||||
|
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withCrossAgentResumeFixture(
|
||||||
|
run: (params: {
|
||||||
|
home: string;
|
||||||
|
storePattern: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
}) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
||||||
|
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
||||||
|
const sessionId = "session-exec-hook";
|
||||||
|
const sessionKey = "agent:exec:hook:gmail:thread-1";
|
||||||
|
writeSessionStoreSeed(execStore, {
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
systemSent: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockConfig(home, storePattern, undefined, undefined, [
|
||||||
|
{ id: "dev" },
|
||||||
|
{ id: "exec", default: true },
|
||||||
|
]);
|
||||||
|
await agentCommand({ message: "resume me", sessionId }, runtime);
|
||||||
|
await run({ home, storePattern, sessionId, sessionKey });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPersistedSessionFile(params: {
|
||||||
|
seedKey: string;
|
||||||
|
sessionId: string;
|
||||||
|
expectedPathFragment: string;
|
||||||
|
}) {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
writeSessionStoreSeed(store, {
|
||||||
|
[params.seedKey]: {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockConfig(home, store);
|
||||||
|
await agentCommand({ message: "hi", sessionKey: params.seedKey }, runtime);
|
||||||
|
const saved = readSessionStore<{ sessionId?: string; sessionFile?: string }>(store);
|
||||||
|
const entry = saved[params.seedKey];
|
||||||
|
expect(entry?.sessionId).toBe(params.sessionId);
|
||||||
|
expect(entry?.sessionFile).toContain(params.expectedPathFragment);
|
||||||
|
expect(getLastEmbeddedCall()?.sessionFile).toBe(entry?.sessionFile);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgentWithSessionKey(sessionKey: string): Promise<void> {
|
||||||
|
await agentCommand({ message: "hi", sessionKey }, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectDefaultThinkLevel(params: {
|
||||||
|
agentOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>>;
|
||||||
|
catalogEntry: Record<string, unknown>;
|
||||||
|
expected: string;
|
||||||
|
}) {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store, params.agentOverrides);
|
||||||
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([params.catalogEntry as never]);
|
||||||
|
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||||
|
expect(getLastEmbeddedCall()?.thinkLevel).toBe(params.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createTelegramOutboundPlugin() {
|
function createTelegramOutboundPlugin() {
|
||||||
|
const sendWithTelegram = async (
|
||||||
|
ctx: {
|
||||||
|
deps?: {
|
||||||
|
sendTelegram?: (
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts: Record<string, unknown>,
|
||||||
|
) => Promise<{
|
||||||
|
messageId: string;
|
||||||
|
chatId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
to: string;
|
||||||
|
text: string;
|
||||||
|
accountId?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
},
|
||||||
|
mediaUrl?: string,
|
||||||
|
) => {
|
||||||
|
const sendTelegram = ctx.deps?.sendTelegram;
|
||||||
|
if (!sendTelegram) {
|
||||||
|
throw new Error("sendTelegram dependency missing");
|
||||||
|
}
|
||||||
|
const result = await sendTelegram(ctx.to, ctx.text, {
|
||||||
|
accountId: ctx.accountId ?? undefined,
|
||||||
|
...(mediaUrl ? { mediaUrl } : {}),
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
||||||
|
};
|
||||||
|
|
||||||
return createOutboundTestPlugin({
|
return createOutboundTestPlugin({
|
||||||
id: "telegram",
|
id: "telegram",
|
||||||
outbound: {
|
outbound: {
|
||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
sendText: async (ctx) => {
|
sendText: async (ctx) => sendWithTelegram(ctx),
|
||||||
const sendTelegram = ctx.deps?.sendTelegram;
|
sendMedia: async (ctx) => sendWithTelegram(ctx, ctx.mediaUrl),
|
||||||
if (!sendTelegram) {
|
|
||||||
throw new Error("sendTelegram dependency missing");
|
|
||||||
}
|
|
||||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
|
||||||
accountId: ctx.accountId ?? undefined,
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
|
||||||
},
|
|
||||||
sendMedia: async (ctx) => {
|
|
||||||
const sendTelegram = ctx.deps?.sendTelegram;
|
|
||||||
if (!sendTelegram) {
|
|
||||||
throw new Error("sendTelegram dependency missing");
|
|
||||||
}
|
|
||||||
const result = await sendTelegram(ctx.to, ctx.text, {
|
|
||||||
accountId: ctx.accountId ?? undefined,
|
|
||||||
mediaUrl: ctx.mediaUrl,
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
return { channel: "telegram", messageId: result.messageId, chatId: result.chatId };
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
runCliAgentSpy.mockResolvedValue({
|
runCliAgentSpy.mockResolvedValue(createDefaultAgentResult() as never);
|
||||||
payloads: [{ text: "ok" }],
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult());
|
||||||
meta: {
|
|
||||||
durationMs: 5,
|
|
||||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
||||||
},
|
|
||||||
} as never);
|
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
|
||||||
payloads: [{ text: "ok" }],
|
|
||||||
meta: {
|
|
||||||
durationMs: 5,
|
|
||||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||||
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
|
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
|
||||||
});
|
});
|
||||||
@@ -191,28 +300,20 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults senderIsOwner to true for local agent runs", async () => {
|
it.each([
|
||||||
await withTempHome(async (home) => {
|
{
|
||||||
const store = path.join(home, "sessions.json");
|
name: "defaults senderIsOwner to true for local agent runs",
|
||||||
mockConfig(home, store);
|
args: { message: "hi", to: "+1555" },
|
||||||
|
expected: true,
|
||||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
},
|
||||||
|
{
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
name: "honors explicit senderIsOwner override",
|
||||||
expect(callArgs?.senderIsOwner).toBe(true);
|
args: { message: "hi", to: "+1555", senderIsOwner: false },
|
||||||
});
|
expected: false,
|
||||||
});
|
},
|
||||||
|
])("$name", async ({ args, expected }) => {
|
||||||
it("honors explicit senderIsOwner override", async () => {
|
const callArgs = await runEmbeddedWithTempConfig({ args });
|
||||||
await withTempHome(async (home) => {
|
expect(callArgs?.senderIsOwner).toBe(expected);
|
||||||
const store = path.join(home, "sessions.json");
|
|
||||||
mockConfig(home, store);
|
|
||||||
|
|
||||||
await agentCommand({ message: "hi", to: "+1555", senderIsOwner: false }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.senderIsOwner).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumes when session-id is provided", async () => {
|
it("resumes when session-id is provided", async () => {
|
||||||
@@ -235,53 +336,21 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
|
it("uses the resumed session agent scope when sessionId resolves to another agent store", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
const callArgs = getLastEmbeddedCall();
|
||||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
expect(callArgs?.sessionKey).toBe(sessionKey);
|
||||||
writeSessionStoreSeed(execStore, {
|
|
||||||
"agent:exec:hook:gmail:thread-1": {
|
|
||||||
sessionId: "session-exec-hook",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
systemSent: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockConfig(home, storePattern, undefined, undefined, [
|
|
||||||
{ id: "dev" },
|
|
||||||
{ id: "exec", default: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.sessionKey).toBe("agent:exec:hook:gmail:thread-1");
|
|
||||||
expect(callArgs?.agentId).toBe("exec");
|
expect(callArgs?.agentId).toBe("exec");
|
||||||
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
|
expect(callArgs?.agentDir).toContain(`${path.sep}agents${path.sep}exec${path.sep}agent`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards resolved outbound session context when resuming by sessionId", async () => {
|
it("forwards resolved outbound session context when resuming by sessionId", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withCrossAgentResumeFixture(async ({ sessionKey }) => {
|
||||||
const storePattern = path.join(home, "sessions", "{agentId}", "sessions.json");
|
|
||||||
const execStore = path.join(home, "sessions", "exec", "sessions.json");
|
|
||||||
writeSessionStoreSeed(execStore, {
|
|
||||||
"agent:exec:hook:gmail:thread-1": {
|
|
||||||
sessionId: "session-exec-hook",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
systemSent: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockConfig(home, storePattern, undefined, undefined, [
|
|
||||||
{ id: "dev" },
|
|
||||||
{ id: "exec", default: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await agentCommand({ message: "resume me", sessionId: "session-exec-hook" }, runtime);
|
|
||||||
|
|
||||||
const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0];
|
const deliverCall = deliverAgentCommandResultSpy.mock.calls.at(-1)?.[0];
|
||||||
expect(deliverCall?.opts.sessionKey).toBeUndefined();
|
expect(deliverCall?.opts.sessionKey).toBeUndefined();
|
||||||
expect(deliverCall?.outboundSession).toEqual(
|
expect(deliverCall?.outboundSession).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
key: "agent:exec:hook:gmail:thread-1",
|
key: sessionKey,
|
||||||
agentId: "exec",
|
agentId: "exec",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -362,9 +431,7 @@ describe("agentCommand", () => {
|
|||||||
|
|
||||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||||
expect(callArgs?.provider).toBe("openai");
|
|
||||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,13 +513,7 @@ describe("agentCommand", () => {
|
|||||||
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
{ id: "claude-opus-4-5", name: "Opus", provider: "anthropic" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await agentCommand(
|
await runAgentWithSessionKey("agent:main:subagent:allow-any");
|
||||||
{
|
|
||||||
message: "hi",
|
|
||||||
sessionKey: "agent:main:subagent:allow-any",
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
expect(callArgs?.provider).toBe("openai");
|
expect(callArgs?.provider).toBe("openai");
|
||||||
@@ -497,17 +558,9 @@ describe("agentCommand", () => {
|
|||||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await agentCommand(
|
await runAgentWithSessionKey("agent:main:subagent:clear-overrides");
|
||||||
{
|
|
||||||
message: "hi",
|
|
||||||
sessionKey: "agent:main:subagent:clear-overrides",
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
expectLastRunProviderModel("openai", "gpt-4.1-mini");
|
||||||
expect(callArgs?.provider).toBe("openai");
|
|
||||||
expect(callArgs?.model).toBe("gpt-4.1-mini");
|
|
||||||
|
|
||||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
||||||
string,
|
string,
|
||||||
@@ -566,68 +619,18 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("persists resolved sessionFile for existing session keys", async () => {
|
it("persists resolved sessionFile for existing session keys", async () => {
|
||||||
await withTempHome(async (home) => {
|
await expectPersistedSessionFile({
|
||||||
const store = path.join(home, "sessions.json");
|
seedKey: "agent:main:subagent:abc",
|
||||||
writeSessionStoreSeed(store, {
|
sessionId: "sess-main",
|
||||||
"agent:main:subagent:abc": {
|
expectedPathFragment: `${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
||||||
sessionId: "sess-main",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockConfig(home, store);
|
|
||||||
|
|
||||||
await agentCommand(
|
|
||||||
{
|
|
||||||
message: "hi",
|
|
||||||
sessionKey: "agent:main:subagent:abc",
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
||||||
string,
|
|
||||||
{ sessionId?: string; sessionFile?: string }
|
|
||||||
>;
|
|
||||||
const entry = saved["agent:main:subagent:abc"];
|
|
||||||
expect(entry?.sessionId).toBe("sess-main");
|
|
||||||
expect(entry?.sessionFile).toContain(
|
|
||||||
`${path.sep}agents${path.sep}main${path.sep}sessions${path.sep}sess-main.jsonl`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
|
it("preserves topic transcript suffix when persisting missing sessionFile", async () => {
|
||||||
await withTempHome(async (home) => {
|
await expectPersistedSessionFile({
|
||||||
const store = path.join(home, "sessions.json");
|
seedKey: "agent:main:telegram:group:123:topic:456",
|
||||||
writeSessionStoreSeed(store, {
|
sessionId: "sess-topic",
|
||||||
"agent:main:telegram:group:123:topic:456": {
|
expectedPathFragment: "sess-topic-topic-456.jsonl",
|
||||||
sessionId: "sess-topic",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockConfig(home, store);
|
|
||||||
|
|
||||||
await agentCommand(
|
|
||||||
{
|
|
||||||
message: "hi",
|
|
||||||
sessionKey: "agent:main:telegram:group:123:topic:456",
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saved = JSON.parse(fs.readFileSync(store, "utf-8")) as Record<
|
|
||||||
string,
|
|
||||||
{ sessionId?: string; sessionFile?: string }
|
|
||||||
>;
|
|
||||||
const entry = saved["agent:main:telegram:group:123:topic:456"];
|
|
||||||
expect(entry?.sessionId).toBe("sess-topic");
|
|
||||||
expect(entry?.sessionFile).toContain("sess-topic-topic-456.jsonl");
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.sessionFile).toBe(entry?.sessionFile);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -715,76 +718,61 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults thinking to low for reasoning-capable models", async () => {
|
it("defaults thinking to low for reasoning-capable models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await expectDefaultThinkLevel({
|
||||||
const store = path.join(home, "sessions.json");
|
catalogEntry: {
|
||||||
mockConfig(home, store);
|
id: "claude-opus-4-5",
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
name: "Opus 4.5",
|
||||||
{
|
provider: "anthropic",
|
||||||
id: "claude-opus-4-5",
|
reasoning: true,
|
||||||
name: "Opus 4.5",
|
},
|
||||||
provider: "anthropic",
|
expected: "low",
|
||||||
reasoning: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.thinkLevel).toBe("low");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => {
|
it("defaults thinking to adaptive for Anthropic Claude 4.6 models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await expectDefaultThinkLevel({
|
||||||
const store = path.join(home, "sessions.json");
|
agentOverrides: {
|
||||||
mockConfig(home, store, {
|
|
||||||
model: { primary: "anthropic/claude-opus-4-6" },
|
model: { primary: "anthropic/claude-opus-4-6" },
|
||||||
models: { "anthropic/claude-opus-4-6": {} },
|
models: { "anthropic/claude-opus-4-6": {} },
|
||||||
});
|
},
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
catalogEntry: {
|
||||||
{
|
id: "claude-opus-4-6",
|
||||||
id: "claude-opus-4-6",
|
name: "Opus 4.6",
|
||||||
name: "Opus 4.6",
|
provider: "anthropic",
|
||||||
provider: "anthropic",
|
reasoning: true,
|
||||||
reasoning: true,
|
},
|
||||||
},
|
expected: "adaptive",
|
||||||
]);
|
|
||||||
|
|
||||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.thinkLevel).toBe("adaptive");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers per-model thinking over global thinkingDefault", async () => {
|
it("prefers per-model thinking over global thinkingDefault", async () => {
|
||||||
await withTempHome(async (home) => {
|
await expectDefaultThinkLevel({
|
||||||
const store = path.join(home, "sessions.json");
|
agentOverrides: {
|
||||||
mockConfig(home, store, {
|
|
||||||
thinkingDefault: "low",
|
thinkingDefault: "low",
|
||||||
models: {
|
models: {
|
||||||
"anthropic/claude-opus-4-5": {
|
"anthropic/claude-opus-4-5": {
|
||||||
params: { thinking: "high" },
|
params: { thinking: "high" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
catalogEntry: {
|
||||||
await agentCommand({ message: "hi", to: "+1555" }, runtime);
|
id: "claude-opus-4-5",
|
||||||
|
name: "Opus 4.5",
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
provider: "anthropic",
|
||||||
expect(callArgs?.thinkLevel).toBe("high");
|
reasoning: true,
|
||||||
|
},
|
||||||
|
expected: "high",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints JSON payload when requested", async () => {
|
it("prints JSON payload when requested", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
|
||||||
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
createDefaultAgentResult({
|
||||||
meta: {
|
payloads: [{ text: "json-reply", mediaUrl: "http://x.test/a.jpg" }],
|
||||||
durationMs: 42,
|
durationMs: 42,
|
||||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
}),
|
||||||
},
|
);
|
||||||
});
|
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
mockConfig(home, store);
|
mockConfig(home, store);
|
||||||
|
|
||||||
@@ -802,15 +790,10 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes the message through as the agent prompt", async () => {
|
it("passes the message through as the agent prompt", async () => {
|
||||||
await withTempHome(async (home) => {
|
const callArgs = await runEmbeddedWithTempConfig({
|
||||||
const store = path.join(home, "sessions.json");
|
args: { message: "ping", to: "+1333" },
|
||||||
mockConfig(home, store);
|
|
||||||
|
|
||||||
await agentCommand({ message: "ping", to: "+1333" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.prompt).toBe("ping");
|
|
||||||
});
|
});
|
||||||
|
expect(callArgs?.prompt).toBe("ping");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes through telegram accountId when delivering", async () => {
|
it("passes through telegram accountId when delivering", async () => {
|
||||||
@@ -861,48 +844,31 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses reply channel as the message channel context", async () => {
|
it("uses reply channel as the message channel context", async () => {
|
||||||
await withTempHome(async (home) => {
|
const callArgs = await runEmbeddedWithTempConfig({
|
||||||
const store = path.join(home, "sessions.json");
|
args: { message: "hi", agentId: "ops", replyChannel: "slack" },
|
||||||
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
|
agentsList: [{ id: "ops" }],
|
||||||
|
|
||||||
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.messageChannel).toBe("slack");
|
|
||||||
});
|
});
|
||||||
|
expect(callArgs?.messageChannel).toBe("slack");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers runContext for embedded routing", async () => {
|
it("prefers runContext for embedded routing", async () => {
|
||||||
await withTempHome(async (home) => {
|
const callArgs = await runEmbeddedWithTempConfig({
|
||||||
const store = path.join(home, "sessions.json");
|
args: {
|
||||||
mockConfig(home, store);
|
message: "hi",
|
||||||
|
to: "+1555",
|
||||||
await agentCommand(
|
channel: "whatsapp",
|
||||||
{
|
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||||
message: "hi",
|
},
|
||||||
to: "+1555",
|
|
||||||
channel: "whatsapp",
|
|
||||||
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
|
||||||
},
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.messageChannel).toBe("slack");
|
|
||||||
expect(callArgs?.agentAccountId).toBe("acct-2");
|
|
||||||
});
|
});
|
||||||
|
expect(callArgs?.messageChannel).toBe("slack");
|
||||||
|
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards accountId to embedded runs", async () => {
|
it("forwards accountId to embedded runs", async () => {
|
||||||
await withTempHome(async (home) => {
|
const callArgs = await runEmbeddedWithTempConfig({
|
||||||
const store = path.join(home, "sessions.json");
|
args: { message: "hi", to: "+1555", accountId: "kev" },
|
||||||
mockConfig(home, store);
|
|
||||||
|
|
||||||
await agentCommand({ message: "hi", to: "+1555", accountId: "kev" }, runtime);
|
|
||||||
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
|
||||||
expect(callArgs?.agentAccountId).toBe("kev");
|
|
||||||
});
|
});
|
||||||
|
expect(callArgs?.agentAccountId).toBe("kev");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs output when delivery is disabled", async () => {
|
it("logs output when delivery is disabled", async () => {
|
||||||
|
|||||||
@@ -53,6 +53,39 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMiniMaxChoice(params: {
|
||||||
|
authChoice: Parameters<typeof applyAuthChoiceMiniMax>[0]["authChoice"];
|
||||||
|
opts?: Parameters<typeof applyAuthChoiceMiniMax>[0]["opts"];
|
||||||
|
env?: { apiKey?: string; oauthToken?: string };
|
||||||
|
prompter?: Parameters<typeof createMinimaxPrompter>[0];
|
||||||
|
}) {
|
||||||
|
const agentDir = await setupTempState();
|
||||||
|
resetMiniMaxEnv();
|
||||||
|
if (params.env?.apiKey !== undefined) {
|
||||||
|
process.env.MINIMAX_API_KEY = params.env.apiKey;
|
||||||
|
}
|
||||||
|
if (params.env?.oauthToken !== undefined) {
|
||||||
|
process.env.MINIMAX_OAUTH_TOKEN = params.env.oauthToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = vi.fn(async () => "should-not-be-used");
|
||||||
|
const confirm = vi.fn(async () => true);
|
||||||
|
const result = await applyAuthChoiceMiniMax({
|
||||||
|
authChoice: params.authChoice,
|
||||||
|
config: {},
|
||||||
|
prompter: createMinimaxPrompter({
|
||||||
|
text,
|
||||||
|
confirm,
|
||||||
|
...params.prompter,
|
||||||
|
}),
|
||||||
|
runtime: createExitThrowingRuntime(),
|
||||||
|
setDefaultModel: true,
|
||||||
|
...(params.opts ? { opts: params.opts } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { agentDir, result, text, confirm };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await lifecycle.cleanup();
|
await lifecycle.cleanup();
|
||||||
});
|
});
|
||||||
@@ -92,18 +125,8 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||||||
])(
|
])(
|
||||||
"$caseName",
|
"$caseName",
|
||||||
async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => {
|
async ({ authChoice, tokenProvider, token, profileId, provider, expectedModel }) => {
|
||||||
const agentDir = await setupTempState();
|
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||||
resetMiniMaxEnv();
|
|
||||||
|
|
||||||
const text = vi.fn(async () => "should-not-be-used");
|
|
||||||
const confirm = vi.fn(async () => true);
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceMiniMax({
|
|
||||||
authChoice,
|
authChoice,
|
||||||
config: {},
|
|
||||||
prompter: createMinimaxPrompter({ text, confirm }),
|
|
||||||
runtime: createExitThrowingRuntime(),
|
|
||||||
setDefaultModel: true,
|
|
||||||
opts: {
|
opts: {
|
||||||
tokenProvider,
|
tokenProvider,
|
||||||
token,
|
token,
|
||||||
@@ -126,80 +149,57 @@ describe("applyAuthChoiceMiniMax", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("uses env token for minimax-api-key-cn as plaintext by default", async () => {
|
it.each([
|
||||||
const agentDir = await setupTempState();
|
{
|
||||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
name: "uses env token for minimax-api-key-cn as plaintext by default",
|
||||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
opts: undefined,
|
||||||
|
expectKey: "mm-env-token",
|
||||||
const text = vi.fn(async () => "should-not-be-used");
|
expectKeyRef: undefined,
|
||||||
const confirm = vi.fn(async () => true);
|
expectConfirmCalls: 1,
|
||||||
|
},
|
||||||
const result = await applyAuthChoiceMiniMax({
|
{
|
||||||
authChoice: "minimax-api-key-cn",
|
name: "uses env token for minimax-api-key-cn as keyRef in ref mode",
|
||||||
config: {},
|
opts: { secretInputMode: "ref" as const },
|
||||||
prompter: createMinimaxPrompter({ text, confirm }),
|
expectKey: undefined,
|
||||||
runtime: createExitThrowingRuntime(),
|
expectKeyRef: {
|
||||||
setDefaultModel: true,
|
source: "env",
|
||||||
});
|
provider: "default",
|
||||||
|
id: "MINIMAX_API_KEY",
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
|
||||||
provider: "minimax-cn",
|
|
||||||
mode: "api_key",
|
|
||||||
});
|
|
||||||
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
|
||||||
"minimax-cn/MiniMax-M2.5",
|
|
||||||
);
|
|
||||||
expect(text).not.toHaveBeenCalled();
|
|
||||||
expect(confirm).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const parsed = await readAuthProfiles(agentDir);
|
|
||||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe("mm-env-token");
|
|
||||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses env token for minimax-api-key-cn as keyRef in ref mode", async () => {
|
|
||||||
const agentDir = await setupTempState();
|
|
||||||
process.env.MINIMAX_API_KEY = "mm-env-token";
|
|
||||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
|
||||||
|
|
||||||
const text = vi.fn(async () => "should-not-be-used");
|
|
||||||
const confirm = vi.fn(async () => true);
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceMiniMax({
|
|
||||||
authChoice: "minimax-api-key-cn",
|
|
||||||
config: {},
|
|
||||||
prompter: createMinimaxPrompter({ text, confirm }),
|
|
||||||
runtime: createExitThrowingRuntime(),
|
|
||||||
setDefaultModel: true,
|
|
||||||
opts: {
|
|
||||||
secretInputMode: "ref",
|
|
||||||
},
|
},
|
||||||
|
expectConfirmCalls: 0,
|
||||||
|
},
|
||||||
|
])("$name", async ({ opts, expectKey, expectKeyRef, expectConfirmCalls }) => {
|
||||||
|
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||||
|
authChoice: "minimax-api-key-cn",
|
||||||
|
opts,
|
||||||
|
env: { apiKey: "mm-env-token" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
|
if (!opts) {
|
||||||
|
expect(result?.config.auth?.profiles?.["minimax-cn:default"]).toMatchObject({
|
||||||
|
provider: "minimax-cn",
|
||||||
|
mode: "api_key",
|
||||||
|
});
|
||||||
|
expect(resolveAgentModelPrimaryValue(result?.config.agents?.defaults?.model)).toBe(
|
||||||
|
"minimax-cn/MiniMax-M2.5",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(text).not.toHaveBeenCalled();
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(expectConfirmCalls);
|
||||||
|
|
||||||
const parsed = await readAuthProfiles(agentDir);
|
const parsed = await readAuthProfiles(agentDir);
|
||||||
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual({
|
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBe(expectKey);
|
||||||
source: "env",
|
if (expectKeyRef) {
|
||||||
provider: "default",
|
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toEqual(expectKeyRef);
|
||||||
id: "MINIMAX_API_KEY",
|
} else {
|
||||||
});
|
expect(parsed.profiles?.["minimax-cn:default"]?.keyRef).toBeUndefined();
|
||||||
expect(parsed.profiles?.["minimax-cn:default"]?.key).toBeUndefined();
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses minimax-api-lightning default model", async () => {
|
it("uses minimax-api-lightning default model", async () => {
|
||||||
const agentDir = await setupTempState();
|
const { agentDir, result, text, confirm } = await runMiniMaxChoice({
|
||||||
resetMiniMaxEnv();
|
|
||||||
|
|
||||||
const text = vi.fn(async () => "should-not-be-used");
|
|
||||||
const confirm = vi.fn(async () => true);
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceMiniMax({
|
|
||||||
authChoice: "minimax-api-lightning",
|
authChoice: "minimax-api-lightning",
|
||||||
config: {},
|
|
||||||
prompter: createMinimaxPrompter({ text, confirm }),
|
|
||||||
runtime: createExitThrowingRuntime(),
|
|
||||||
setDefaultModel: true,
|
|
||||||
opts: {
|
opts: {
|
||||||
tokenProvider: "minimax",
|
tokenProvider: "minimax",
|
||||||
token: "mm-lightning-token",
|
token: "mm-lightning-token",
|
||||||
|
|||||||
@@ -24,163 +24,117 @@ describe("volcengine/byteplus auth choice", () => {
|
|||||||
return env.agentDir;
|
return env.agentDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTestContext(defaultSelect: string, confirmResult = true, textValue = "unused") {
|
||||||
|
return {
|
||||||
|
prompter: createWizardPrompter(
|
||||||
|
{
|
||||||
|
confirm: vi.fn(async () => confirmResult),
|
||||||
|
text: vi.fn(async () => textValue),
|
||||||
|
},
|
||||||
|
{ defaultSelect },
|
||||||
|
),
|
||||||
|
runtime: createExitThrowingRuntime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderAuthCase = {
|
||||||
|
provider: "volcengine" | "byteplus";
|
||||||
|
authChoice: "volcengine-api-key" | "byteplus-api-key";
|
||||||
|
envVar: "VOLCANO_ENGINE_API_KEY" | "BYTEPLUS_API_KEY";
|
||||||
|
envValue: string;
|
||||||
|
profileId: "volcengine:default" | "byteplus:default";
|
||||||
|
applyAuthChoice: typeof applyAuthChoiceVolcengine | typeof applyAuthChoiceBytePlus;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runProviderAuthChoice(
|
||||||
|
testCase: ProviderAuthCase,
|
||||||
|
options?: {
|
||||||
|
defaultSelect?: string;
|
||||||
|
confirmResult?: boolean;
|
||||||
|
textValue?: string;
|
||||||
|
secretInputMode?: "ref";
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const agentDir = await setupTempState();
|
||||||
|
process.env[testCase.envVar] = testCase.envValue;
|
||||||
|
|
||||||
|
const { prompter, runtime } = createTestContext(
|
||||||
|
options?.defaultSelect ?? "plaintext",
|
||||||
|
options?.confirmResult ?? true,
|
||||||
|
options?.textValue ?? "unused",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await testCase.applyAuthChoice({
|
||||||
|
authChoice: testCase.authChoice,
|
||||||
|
config: {},
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
setDefaultModel: true,
|
||||||
|
...(options?.secretInputMode ? { opts: { secretInputMode: options.secretInputMode } } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = await readAuthProfilesForAgent<{
|
||||||
|
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
||||||
|
}>(agentDir);
|
||||||
|
|
||||||
|
return { result, parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerAuthCases: ProviderAuthCase[] = [
|
||||||
|
{
|
||||||
|
provider: "volcengine",
|
||||||
|
authChoice: "volcengine-api-key",
|
||||||
|
envVar: "VOLCANO_ENGINE_API_KEY",
|
||||||
|
envValue: "volc-env-key",
|
||||||
|
profileId: "volcengine:default",
|
||||||
|
applyAuthChoice: applyAuthChoiceVolcengine,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "byteplus",
|
||||||
|
authChoice: "byteplus-api-key",
|
||||||
|
envVar: "BYTEPLUS_API_KEY",
|
||||||
|
envValue: "byte-env-key",
|
||||||
|
profileId: "byteplus:default",
|
||||||
|
applyAuthChoice: applyAuthChoiceBytePlus,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await lifecycle.cleanup();
|
await lifecycle.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores volcengine env key as plaintext by default", async () => {
|
it.each(providerAuthCases)(
|
||||||
const agentDir = await setupTempState();
|
"stores $provider env key as plaintext by default",
|
||||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
async (testCase) => {
|
||||||
|
const { result, parsed } = await runProviderAuthChoice(testCase);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.config.auth?.profiles?.[testCase.profileId]).toMatchObject({
|
||||||
|
provider: testCase.provider,
|
||||||
|
mode: "api_key",
|
||||||
|
});
|
||||||
|
expect(parsed.profiles?.[testCase.profileId]?.key).toBe(testCase.envValue);
|
||||||
|
expect(parsed.profiles?.[testCase.profileId]?.keyRef).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createWizardPrompter(
|
it.each(providerAuthCases)("stores $provider env key as keyRef in ref mode", async (testCase) => {
|
||||||
{
|
const { result, parsed } = await runProviderAuthChoice(testCase, {
|
||||||
confirm: vi.fn(async () => true),
|
defaultSelect: "ref",
|
||||||
text: vi.fn(async () => "unused"),
|
|
||||||
},
|
|
||||||
{ defaultSelect: "plaintext" },
|
|
||||||
);
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceVolcengine({
|
|
||||||
authChoice: "volcengine-api-key",
|
|
||||||
config: {},
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
setDefaultModel: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.config.auth?.profiles?.["volcengine:default"]).toMatchObject({
|
expect(parsed.profiles?.[testCase.profileId]).toMatchObject({
|
||||||
provider: "volcengine",
|
keyRef: { source: "env", provider: "default", id: testCase.envVar },
|
||||||
mode: "api_key",
|
|
||||||
});
|
});
|
||||||
|
expect(parsed.profiles?.[testCase.profileId]?.key).toBeUndefined();
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
|
||||||
}>(agentDir);
|
|
||||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-env-key");
|
|
||||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores volcengine env key as keyRef in ref mode", async () => {
|
|
||||||
const agentDir = await setupTempState();
|
|
||||||
process.env.VOLCANO_ENGINE_API_KEY = "volc-env-key";
|
|
||||||
|
|
||||||
const prompter = createWizardPrompter(
|
|
||||||
{
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
text: vi.fn(async () => "unused"),
|
|
||||||
},
|
|
||||||
{ defaultSelect: "ref" },
|
|
||||||
);
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceVolcengine({
|
|
||||||
authChoice: "volcengine-api-key",
|
|
||||||
config: {},
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
setDefaultModel: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
|
||||||
}>(agentDir);
|
|
||||||
expect(parsed.profiles?.["volcengine:default"]).toMatchObject({
|
|
||||||
keyRef: { source: "env", provider: "default", id: "VOLCANO_ENGINE_API_KEY" },
|
|
||||||
});
|
|
||||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores byteplus env key as plaintext by default", async () => {
|
|
||||||
const agentDir = await setupTempState();
|
|
||||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
|
||||||
|
|
||||||
const prompter = createWizardPrompter(
|
|
||||||
{
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
text: vi.fn(async () => "unused"),
|
|
||||||
},
|
|
||||||
{ defaultSelect: "plaintext" },
|
|
||||||
);
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceBytePlus({
|
|
||||||
authChoice: "byteplus-api-key",
|
|
||||||
config: {},
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
setDefaultModel: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.config.auth?.profiles?.["byteplus:default"]).toMatchObject({
|
|
||||||
provider: "byteplus",
|
|
||||||
mode: "api_key",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
|
||||||
}>(agentDir);
|
|
||||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBe("byte-env-key");
|
|
||||||
expect(parsed.profiles?.["byteplus:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores byteplus env key as keyRef in ref mode", async () => {
|
|
||||||
const agentDir = await setupTempState();
|
|
||||||
process.env.BYTEPLUS_API_KEY = "byte-env-key";
|
|
||||||
|
|
||||||
const prompter = createWizardPrompter(
|
|
||||||
{
|
|
||||||
confirm: vi.fn(async () => true),
|
|
||||||
text: vi.fn(async () => "unused"),
|
|
||||||
},
|
|
||||||
{ defaultSelect: "ref" },
|
|
||||||
);
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceBytePlus({
|
|
||||||
authChoice: "byteplus-api-key",
|
|
||||||
config: {},
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
setDefaultModel: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
|
||||||
}>(agentDir);
|
|
||||||
expect(parsed.profiles?.["byteplus:default"]).toMatchObject({
|
|
||||||
keyRef: { source: "env", provider: "default", id: "BYTEPLUS_API_KEY" },
|
|
||||||
});
|
|
||||||
expect(parsed.profiles?.["byteplus:default"]?.key).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores explicit volcengine key when env is not used", async () => {
|
it("stores explicit volcengine key when env is not used", async () => {
|
||||||
const agentDir = await setupTempState();
|
const { result, parsed } = await runProviderAuthChoice(providerAuthCases[0], {
|
||||||
const prompter = createWizardPrompter(
|
defaultSelect: "",
|
||||||
{
|
confirmResult: false,
|
||||||
confirm: vi.fn(async () => false),
|
textValue: "volc-manual-key",
|
||||||
text: vi.fn(async () => "volc-manual-key"),
|
|
||||||
},
|
|
||||||
{ defaultSelect: "" },
|
|
||||||
);
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
const result = await applyAuthChoiceVolcengine({
|
|
||||||
authChoice: "volcengine-api-key",
|
|
||||||
config: {},
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
setDefaultModel: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
|
||||||
}>(agentDir);
|
|
||||||
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key");
|
expect(parsed.profiles?.["volcengine:default"]?.key).toBe("volc-manual-key");
|
||||||
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
expect(parsed.profiles?.["volcengine:default"]?.keyRef).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
const runtime = createTestRuntime();
|
const runtime = createTestRuntime();
|
||||||
let clackPrompterModule: typeof import("../wizard/clack-prompter.js");
|
let clackPrompterModule: typeof import("../wizard/clack-prompter.js");
|
||||||
|
|
||||||
|
function formatChannelStatusJoined(channelAccounts: Record<string, unknown>) {
|
||||||
|
return formatGatewayChannelsStatusLines({ channelAccounts }).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
describe("channels command", () => {
|
describe("channels command", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
clackPrompterModule = await import("../wizard/clack-prompter.js");
|
clackPrompterModule = await import("../wizard/clack-prompter.js");
|
||||||
@@ -45,23 +49,53 @@ describe("channels command", () => {
|
|||||||
setDefaultChannelPluginRegistryForTests();
|
setDefaultChannelPluginRegistryForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds a non-default telegram account", async () => {
|
function getWrittenConfig<T>(): T {
|
||||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
|
||||||
await channelsAddCommand(
|
|
||||||
{ channel: "telegram", account: "alerts", token: "123:abc" },
|
|
||||||
runtime,
|
|
||||||
{ hasFlags: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
return configMocks.writeConfigFile.mock.calls[0]?.[0] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRemoveWithConfirm(
|
||||||
|
args: Parameters<typeof channelsRemoveCommand>[0],
|
||||||
|
): Promise<void> {
|
||||||
|
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
||||||
|
const promptSpy = vi
|
||||||
|
.spyOn(clackPrompterModule, "createClackPrompter")
|
||||||
|
.mockReturnValue(prompt as never);
|
||||||
|
try {
|
||||||
|
await channelsRemoveCommand(args, runtime, { hasFlags: true });
|
||||||
|
} finally {
|
||||||
|
promptSpy.mockRestore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTelegramAccount(account: string, token: string): Promise<void> {
|
||||||
|
await channelsAddCommand({ channel: "telegram", account, token }, runtime, {
|
||||||
|
hasFlags: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAlertsTelegramAccount(token: string): Promise<{
|
||||||
|
channels?: {
|
||||||
|
telegram?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
accounts?: Record<string, { botToken?: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
await addTelegramAccount("alerts", token);
|
||||||
|
return getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
telegram?: {
|
telegram?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
accounts?: Record<string, { botToken?: string }>;
|
accounts?: Record<string, { botToken?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds a non-default telegram account", async () => {
|
||||||
|
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||||
|
const next = await addAlertsTelegramAccount("123:abc");
|
||||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
|
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
|
||||||
});
|
});
|
||||||
@@ -83,13 +117,9 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await channelsAddCommand(
|
await addTelegramAccount("alerts", "alerts-token");
|
||||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
|
||||||
runtime,
|
|
||||||
{ hasFlags: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
const next = getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
telegram?: {
|
telegram?: {
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
@@ -109,7 +139,7 @@ describe("channels command", () => {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.telegram?.accounts?.default).toEqual({
|
expect(next.channels?.telegram?.accounts?.default).toEqual({
|
||||||
botToken: "legacy-token",
|
botToken: "legacy-token",
|
||||||
dmPolicy: "allowlist",
|
dmPolicy: "allowlist",
|
||||||
@@ -137,20 +167,7 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await channelsAddCommand(
|
const next = await addAlertsTelegramAccount("alerts-token");
|
||||||
{ channel: "telegram", account: "alerts", token: "alerts-token" },
|
|
||||||
runtime,
|
|
||||||
{ hasFlags: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
|
||||||
channels?: {
|
|
||||||
telegram?: {
|
|
||||||
enabled?: boolean;
|
|
||||||
accounts?: Record<string, { botToken?: string }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
expect(next.channels?.telegram?.enabled).toBe(true);
|
expect(next.channels?.telegram?.enabled).toBe(true);
|
||||||
expect(next.channels?.telegram?.accounts?.default).toEqual({});
|
expect(next.channels?.telegram?.accounts?.default).toEqual({});
|
||||||
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
|
expect(next.channels?.telegram?.accounts?.alerts?.botToken).toBe("alerts-token");
|
||||||
@@ -169,12 +186,11 @@ describe("channels command", () => {
|
|||||||
{ hasFlags: true },
|
{ hasFlags: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
const next = getWrittenConfig<{
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
|
||||||
channels?: {
|
channels?: {
|
||||||
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
|
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.slack?.enabled).toBe(true);
|
expect(next.channels?.slack?.enabled).toBe(true);
|
||||||
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
|
expect(next.channels?.slack?.botToken).toBe("xoxb-1");
|
||||||
expect(next.channels?.slack?.appToken).toBe("xapp-1");
|
expect(next.channels?.slack?.appToken).toBe("xapp-1");
|
||||||
@@ -199,12 +215,11 @@ describe("channels command", () => {
|
|||||||
hasFlags: true,
|
hasFlags: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
const next = getWrittenConfig<{
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
|
||||||
channels?: {
|
channels?: {
|
||||||
discord?: { accounts?: Record<string, { token?: string }> };
|
discord?: { accounts?: Record<string, { token?: string }> };
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
|
expect(next.channels?.discord?.accounts?.work).toBeUndefined();
|
||||||
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
|
expect(next.channels?.discord?.accounts?.default?.token).toBe("d0");
|
||||||
});
|
});
|
||||||
@@ -217,11 +232,11 @@ describe("channels command", () => {
|
|||||||
{ hasFlags: true },
|
{ hasFlags: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
const next = getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
whatsapp?: { accounts?: Record<string, { name?: string }> };
|
whatsapp?: { accounts?: Record<string, { name?: string }> };
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
|
expect(next.channels?.whatsapp?.accounts?.family?.name).toBe("Family Phone");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,13 +265,13 @@ describe("channels command", () => {
|
|||||||
{ hasFlags: true },
|
{ hasFlags: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
const next = getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
signal?: {
|
signal?: {
|
||||||
accounts?: Record<string, { account?: string; name?: string }>;
|
accounts?: Record<string, { account?: string; name?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
|
expect(next.channels?.signal?.accounts?.lab?.account).toBe("+15555550123");
|
||||||
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
|
expect(next.channels?.signal?.accounts?.lab?.name).toBe("Lab");
|
||||||
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
|
expect(next.channels?.signal?.accounts?.default?.name).toBe("Primary");
|
||||||
@@ -270,20 +285,12 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
await runRemoveWithConfirm({ channel: "discord", account: "default" });
|
||||||
const promptSpy = vi
|
|
||||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
|
||||||
.mockReturnValue(prompt as never);
|
|
||||||
|
|
||||||
await channelsRemoveCommand({ channel: "discord", account: "default" }, runtime, {
|
const next = getWrittenConfig<{
|
||||||
hasFlags: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
|
||||||
channels?: { discord?: { enabled?: boolean } };
|
channels?: { discord?: { enabled?: boolean } };
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.discord?.enabled).toBe(false);
|
expect(next.channels?.discord?.enabled).toBe(false);
|
||||||
promptSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes external auth profiles in JSON output", async () => {
|
it("includes external auth profiles in JSON output", async () => {
|
||||||
@@ -348,14 +355,14 @@ describe("channels command", () => {
|
|||||||
{ hasFlags: true },
|
{ hasFlags: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
const next = getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
telegram?: {
|
telegram?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
accounts?: Record<string, { botToken?: string; name?: string }>;
|
accounts?: Record<string, { botToken?: string; name?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.telegram?.name).toBeUndefined();
|
expect(next.channels?.telegram?.name).toBeUndefined();
|
||||||
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
|
expect(next.channels?.telegram?.accounts?.default?.name).toBe("Primary Bot");
|
||||||
});
|
});
|
||||||
@@ -377,14 +384,14 @@ describe("channels command", () => {
|
|||||||
hasFlags: true,
|
hasFlags: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
const next = getWrittenConfig<{
|
||||||
channels?: {
|
channels?: {
|
||||||
discord?: {
|
discord?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
accounts?: Record<string, { name?: string; token?: string }>;
|
accounts?: Record<string, { name?: string; token?: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
}>();
|
||||||
expect(next.channels?.discord?.name).toBeUndefined();
|
expect(next.channels?.discord?.name).toBeUndefined();
|
||||||
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
|
expect(next.channels?.discord?.accounts?.default?.name).toBe("Primary Bot");
|
||||||
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
|
expect(next.channels?.discord?.accounts?.work?.token).toBe("d1");
|
||||||
@@ -405,8 +412,9 @@ describe("channels command", () => {
|
|||||||
expect(telegramIndex).toBeLessThan(whatsappIndex);
|
expect(telegramIndex).toBeLessThan(whatsappIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces Discord privileged intent issues in channels status output", () => {
|
it.each([
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
{
|
||||||
|
name: "surfaces Discord privileged intent issues in channels status output",
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
discord: [
|
discord: [
|
||||||
{
|
{
|
||||||
@@ -417,14 +425,14 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
patterns: [
|
||||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
/Warnings:/,
|
||||||
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
|
/Message Content Intent is disabled/i,
|
||||||
expect(lines.join("\n")).toMatch(/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/);
|
/Run: (?:openclaw|openclaw)( --profile isolated)? doctor/,
|
||||||
});
|
],
|
||||||
|
},
|
||||||
it("surfaces Discord permission audit issues in channels status output", () => {
|
{
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
name: "surfaces Discord permission audit issues in channels status output",
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
discord: [
|
discord: [
|
||||||
{
|
{
|
||||||
@@ -444,14 +452,10 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
patterns: [/Warnings:/, /permission audit/i, /Channel 111/i],
|
||||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
},
|
||||||
expect(lines.join("\n")).toMatch(/permission audit/i);
|
{
|
||||||
expect(lines.join("\n")).toMatch(/Channel 111/i);
|
name: "surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled",
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
|
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
telegram: [
|
telegram: [
|
||||||
{
|
{
|
||||||
@@ -462,54 +466,54 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
patterns: [/Warnings:/, /Telegram Bot API privacy mode/i],
|
||||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
},
|
||||||
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
|
])("$name", ({ channelAccounts, patterns }) => {
|
||||||
|
const joined = formatChannelStatusJoined(channelAccounts);
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
expect(joined).toMatch(pattern);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Telegram bot username from probe data", () => {
|
it("includes Telegram bot username from probe data", () => {
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
const joined = formatChannelStatusJoined({
|
||||||
channelAccounts: {
|
telegram: [
|
||||||
telegram: [
|
{
|
||||||
{
|
accountId: "default",
|
||||||
accountId: "default",
|
enabled: true,
|
||||||
enabled: true,
|
configured: true,
|
||||||
configured: true,
|
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
||||||
probe: { ok: true, bot: { username: "openclaw_bot" } },
|
},
|
||||||
},
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(lines.join("\n")).toMatch(/bot:@openclaw_bot/);
|
expect(joined).toMatch(/bot:@openclaw_bot/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
it("surfaces Telegram group membership audit issues in channels status output", () => {
|
||||||
const lines = formatGatewayChannelsStatusLines({
|
const joined = formatChannelStatusJoined({
|
||||||
channelAccounts: {
|
telegram: [
|
||||||
telegram: [
|
{
|
||||||
{
|
accountId: "default",
|
||||||
accountId: "default",
|
enabled: true,
|
||||||
enabled: true,
|
configured: true,
|
||||||
configured: true,
|
audit: {
|
||||||
audit: {
|
hasWildcardUnmentionedGroups: true,
|
||||||
hasWildcardUnmentionedGroups: true,
|
unresolvedGroups: 1,
|
||||||
unresolvedGroups: 1,
|
groups: [
|
||||||
groups: [
|
{
|
||||||
{
|
chatId: "-1001",
|
||||||
chatId: "-1001",
|
ok: false,
|
||||||
ok: false,
|
status: "left",
|
||||||
status: "left",
|
error: "not in group",
|
||||||
error: "not in group",
|
},
|
||||||
},
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
});
|
});
|
||||||
expect(lines.join("\n")).toMatch(/Warnings:/);
|
expect(joined).toMatch(/Warnings:/);
|
||||||
expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
|
expect(joined).toMatch(/membership probing is not possible/i);
|
||||||
expect(lines.join("\n")).toMatch(/Group -1001/i);
|
expect(joined).toMatch(/Group -1001/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
|
||||||
@@ -591,16 +595,8 @@ describe("channels command", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = { confirm: vi.fn().mockResolvedValue(true) };
|
await runRemoveWithConfirm({ channel: "telegram", account: "default" });
|
||||||
const promptSpy = vi
|
|
||||||
.spyOn(clackPrompterModule, "createClackPrompter")
|
|
||||||
.mockReturnValue(prompt as never);
|
|
||||||
|
|
||||||
await channelsRemoveCommand({ channel: "telegram", account: "default" }, runtime, {
|
|
||||||
hasFlags: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
|
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
|
||||||
promptSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,35 +51,56 @@ function makeRuntime(): RuntimeEnv {
|
|||||||
|
|
||||||
const noopPrompter = {} as WizardPrompter;
|
const noopPrompter = {} as WizardPrompter;
|
||||||
|
|
||||||
describe("promptAuthConfig", () => {
|
function createKilocodeProvider() {
|
||||||
it("keeps Kilo provider models while applying allowlist defaults", async () => {
|
return {
|
||||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
baseUrl: "https://api.kilo.ai/api/gateway/",
|
||||||
mocks.applyAuthChoice.mockResolvedValue({
|
api: "openai-completions",
|
||||||
config: {
|
models: [
|
||||||
agents: {
|
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||||
defaults: {
|
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
||||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
],
|
||||||
},
|
};
|
||||||
},
|
}
|
||||||
models: {
|
|
||||||
providers: {
|
function createApplyAuthChoiceConfig(includeMinimaxProvider = false) {
|
||||||
kilocode: {
|
return {
|
||||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
config: {
|
||||||
api: "openai-completions",
|
agents: {
|
||||||
models: [
|
defaults: {
|
||||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
||||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
models: {
|
||||||
mocks.promptModelAllowlist.mockResolvedValue({
|
providers: {
|
||||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
kilocode: createKilocodeProvider(),
|
||||||
});
|
...(includeMinimaxProvider
|
||||||
|
? {
|
||||||
|
minimax: {
|
||||||
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
async function runPromptAuthConfigWithAllowlist(includeMinimaxProvider = false) {
|
||||||
|
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
||||||
|
mocks.applyAuthChoice.mockResolvedValue(createApplyAuthChoiceConfig(includeMinimaxProvider));
|
||||||
|
mocks.promptModelAllowlist.mockResolvedValue({
|
||||||
|
models: ["kilocode/anthropic/claude-opus-4.6"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("promptAuthConfig", () => {
|
||||||
|
it("keeps Kilo provider models while applying allowlist defaults", async () => {
|
||||||
|
const result = await runPromptAuthConfigWithAllowlist();
|
||||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||||
"anthropic/claude-opus-4.6",
|
"anthropic/claude-opus-4.6",
|
||||||
"minimax/minimax-m2.5:free",
|
"minimax/minimax-m2.5:free",
|
||||||
@@ -90,38 +111,7 @@ describe("promptAuthConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not mutate provider model catalogs when allowlist is set", async () => {
|
it("does not mutate provider model catalogs when allowlist is set", async () => {
|
||||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("kilocode-api-key");
|
const result = await runPromptAuthConfigWithAllowlist(true);
|
||||||
mocks.applyAuthChoice.mockResolvedValue({
|
|
||||||
config: {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "kilocode/anthropic/claude-opus-4.6" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
kilocode: {
|
|
||||||
baseUrl: "https://api.kilo.ai/api/gateway/",
|
|
||||||
api: "openai-completions",
|
|
||||||
models: [
|
|
||||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
|
||||||
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
minimax: {
|
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
|
||||||
api: "anthropic-messages",
|
|
||||||
models: [{ id: "MiniMax-M2.1", name: "MiniMax M2.1" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mocks.promptModelAllowlist.mockResolvedValue({
|
|
||||||
models: ["kilocode/anthropic/claude-opus-4.6"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
|
||||||
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
expect(result.models?.providers?.kilocode?.models?.map((model) => model.id)).toEqual([
|
||||||
"anthropic/claude-opus-4.6",
|
"anthropic/claude-opus-4.6",
|
||||||
"minimax/minimax-m2.5:free",
|
"minimax/minimax-m2.5:free",
|
||||||
|
|||||||
@@ -28,67 +28,109 @@ describe("onboard auth credentials secret refs", () => {
|
|||||||
await lifecycle.cleanup();
|
await lifecycle.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps env-backed moonshot key as plaintext by default", async () => {
|
type AuthProfileEntry = { key?: string; keyRef?: unknown; metadata?: unknown };
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-");
|
|
||||||
|
async function withAuthEnv(
|
||||||
|
prefix: string,
|
||||||
|
run: (env: Awaited<ReturnType<typeof setupAuthTestEnv>>) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const env = await setupAuthTestEnv(prefix);
|
||||||
lifecycle.setStateDir(env.stateDir);
|
lifecycle.setStateDir(env.stateDir);
|
||||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
await run(env);
|
||||||
|
}
|
||||||
await setMoonshotApiKey("sk-moonshot-env");
|
|
||||||
|
|
||||||
|
async function readProfile(
|
||||||
|
agentDir: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<AuthProfileEntry | undefined> {
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
const parsed = await readAuthProfilesForAgent<{
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
profiles?: Record<string, AuthProfileEntry>;
|
||||||
}>(env.agentDir);
|
}>(agentDir);
|
||||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
return parsed.profiles?.[profileId];
|
||||||
key: "sk-moonshot-env",
|
}
|
||||||
|
|
||||||
|
async function expectStoredAuthKey(params: {
|
||||||
|
prefix: string;
|
||||||
|
envVar?: string;
|
||||||
|
envValue?: string;
|
||||||
|
profileId: string;
|
||||||
|
apply: (agentDir: string) => Promise<void>;
|
||||||
|
expected: AuthProfileEntry;
|
||||||
|
absent?: Array<keyof AuthProfileEntry>;
|
||||||
|
}) {
|
||||||
|
await withAuthEnv(params.prefix, async (env) => {
|
||||||
|
if (params.envVar && params.envValue !== undefined) {
|
||||||
|
process.env[params.envVar] = params.envValue;
|
||||||
|
}
|
||||||
|
await params.apply(env.agentDir);
|
||||||
|
const profile = await readProfile(env.agentDir, params.profileId);
|
||||||
|
expect(profile).toMatchObject(params.expected);
|
||||||
|
for (const key of params.absent ?? []) {
|
||||||
|
expect(profile?.[key]).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps env-backed moonshot key as plaintext by default", async () => {
|
||||||
|
await expectStoredAuthKey({
|
||||||
|
prefix: "openclaw-onboard-auth-credentials-",
|
||||||
|
envVar: "MOONSHOT_API_KEY",
|
||||||
|
envValue: "sk-moonshot-env",
|
||||||
|
profileId: "moonshot:default",
|
||||||
|
apply: async () => {
|
||||||
|
await setMoonshotApiKey("sk-moonshot-env");
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
key: "sk-moonshot-env",
|
||||||
|
},
|
||||||
|
absent: ["keyRef"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
|
it("stores env-backed moonshot key as keyRef when secret-input-mode=ref", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-ref-");
|
await expectStoredAuthKey({
|
||||||
lifecycle.setStateDir(env.stateDir);
|
prefix: "openclaw-onboard-auth-credentials-ref-",
|
||||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
|
envVar: "MOONSHOT_API_KEY",
|
||||||
|
envValue: "sk-moonshot-env",
|
||||||
await setMoonshotApiKey("sk-moonshot-env", env.agentDir, { secretInputMode: "ref" });
|
profileId: "moonshot:default",
|
||||||
|
apply: async (agentDir) => {
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
await setMoonshotApiKey("sk-moonshot-env", agentDir, { secretInputMode: "ref" });
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
},
|
||||||
}>(env.agentDir);
|
expected: {
|
||||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
},
|
||||||
|
absent: ["key"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
|
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-");
|
await expectStoredAuthKey({
|
||||||
lifecycle.setStateDir(env.stateDir);
|
prefix: "openclaw-onboard-auth-credentials-inline-ref-",
|
||||||
|
profileId: "moonshot:default",
|
||||||
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
apply: async () => {
|
||||||
|
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
},
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
expected: {
|
||||||
}>(env.agentDir);
|
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
||||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
},
|
||||||
keyRef: { source: "env", provider: "default", id: "MOONSHOT_API_KEY" },
|
absent: ["key"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps plaintext moonshot key when no env ref applies", async () => {
|
it("keeps plaintext moonshot key when no env ref applies", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-");
|
await expectStoredAuthKey({
|
||||||
lifecycle.setStateDir(env.stateDir);
|
prefix: "openclaw-onboard-auth-credentials-plaintext-",
|
||||||
process.env.MOONSHOT_API_KEY = "sk-moonshot-other";
|
envVar: "MOONSHOT_API_KEY",
|
||||||
|
envValue: "sk-moonshot-other",
|
||||||
await setMoonshotApiKey("sk-moonshot-plaintext");
|
profileId: "moonshot:default",
|
||||||
|
apply: async () => {
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
await setMoonshotApiKey("sk-moonshot-plaintext");
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
},
|
||||||
}>(env.agentDir);
|
expected: {
|
||||||
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
|
key: "sk-moonshot-plaintext",
|
||||||
key: "sk-moonshot-plaintext",
|
},
|
||||||
|
absent: ["keyRef"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves cloudflare metadata when storing keyRef", async () => {
|
it("preserves cloudflare metadata when storing keyRef", async () => {
|
||||||
@@ -111,35 +153,35 @@ describe("onboard auth credentials secret refs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps env-backed openai key as plaintext by default", async () => {
|
it("keeps env-backed openai key as plaintext by default", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-");
|
await expectStoredAuthKey({
|
||||||
lifecycle.setStateDir(env.stateDir);
|
prefix: "openclaw-onboard-auth-credentials-openai-",
|
||||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
envVar: "OPENAI_API_KEY",
|
||||||
|
envValue: "sk-openai-env",
|
||||||
await setOpenaiApiKey("sk-openai-env");
|
profileId: "openai:default",
|
||||||
|
apply: async () => {
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
await setOpenaiApiKey("sk-openai-env");
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
},
|
||||||
}>(env.agentDir);
|
expected: {
|
||||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
key: "sk-openai-env",
|
||||||
key: "sk-openai-env",
|
},
|
||||||
|
absent: ["keyRef"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["openai:default"]?.keyRef).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores env-backed openai key as keyRef in ref mode", async () => {
|
it("stores env-backed openai key as keyRef in ref mode", async () => {
|
||||||
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-openai-ref-");
|
await expectStoredAuthKey({
|
||||||
lifecycle.setStateDir(env.stateDir);
|
prefix: "openclaw-onboard-auth-credentials-openai-ref-",
|
||||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
envVar: "OPENAI_API_KEY",
|
||||||
|
envValue: "sk-openai-env",
|
||||||
await setOpenaiApiKey("sk-openai-env", env.agentDir, { secretInputMode: "ref" });
|
profileId: "openai:default",
|
||||||
|
apply: async (agentDir) => {
|
||||||
const parsed = await readAuthProfilesForAgent<{
|
await setOpenaiApiKey("sk-openai-env", agentDir, { secretInputMode: "ref" });
|
||||||
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
|
},
|
||||||
}>(env.agentDir);
|
expected: {
|
||||||
expect(parsed.profiles?.["openai:default"]).toMatchObject({
|
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
},
|
||||||
|
absent: ["key"],
|
||||||
});
|
});
|
||||||
expect(parsed.profiles?.["openai:default"]?.key).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {
|
it("stores env-backed volcengine and byteplus keys as keyRef in ref mode", async () => {
|
||||||
|
|||||||
@@ -31,6 +31,68 @@ function createUnexpectedPromptGuards() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetupChannelsOptions = Parameters<typeof setupChannels>[3];
|
||||||
|
|
||||||
|
function runSetupChannels(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
options?: SetupChannelsOptions,
|
||||||
|
) {
|
||||||
|
return setupChannels(cfg, createExitThrowingRuntime(), prompter, {
|
||||||
|
skipConfirm: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQuickstartTelegramSelect(options?: {
|
||||||
|
configuredAction?: "skip";
|
||||||
|
strictUnexpected?: boolean;
|
||||||
|
}) {
|
||||||
|
return vi.fn(async ({ message }: { message: string }) => {
|
||||||
|
if (message === "Select channel (QuickStart)") {
|
||||||
|
return "telegram";
|
||||||
|
}
|
||||||
|
if (options?.configuredAction && message.includes("already configured")) {
|
||||||
|
return options.configuredAction;
|
||||||
|
}
|
||||||
|
if (options?.strictUnexpected) {
|
||||||
|
throw new Error(`unexpected select prompt: ${message}`);
|
||||||
|
}
|
||||||
|
return "__done__";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
|
||||||
|
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||||
|
return {
|
||||||
|
prompter: createPrompter({ select, multiselect, text }),
|
||||||
|
multiselect,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTelegramCfg(botToken: string, enabled?: boolean): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
botToken,
|
||||||
|
...(typeof enabled === "boolean" ? { enabled } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTelegramAdapter(overrides: Parameters<typeof patchChannelOnboardingAdapter>[1]) {
|
||||||
|
return patchChannelOnboardingAdapter("telegram", {
|
||||||
|
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||||
|
channel: "telegram",
|
||||||
|
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||||
|
statusLines: [],
|
||||||
|
})),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
vi.mock("node:fs/promises", () => ({
|
||||||
default: {
|
default: {
|
||||||
access: vi.fn(async () => {
|
access: vi.fn(async () => {
|
||||||
@@ -81,10 +143,7 @@ describe("setupChannels", () => {
|
|||||||
text: text as unknown as WizardPrompter["text"],
|
text: text as unknown as WizardPrompter["text"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||||
|
|
||||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
quickstartDefaults: true,
|
||||||
forceAllowFromChannels: ["whatsapp"],
|
forceAllowFromChannels: ["whatsapp"],
|
||||||
});
|
});
|
||||||
@@ -116,10 +175,7 @@ describe("setupChannels", () => {
|
|||||||
text: text as unknown as WizardPrompter["text"],
|
text: text as unknown as WizardPrompter["text"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||||
|
|
||||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
quickstartDefaults: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,11 +202,7 @@ describe("setupChannels", () => {
|
|||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
await runSetupChannels({} as OpenClawConfig, prompter);
|
||||||
|
|
||||||
await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
|
||||||
skipConfirm: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sawPrimer = note.mock.calls.some(
|
const sawPrimer = note.mock.calls.some(
|
||||||
([message, title]) =>
|
([message, title]) =>
|
||||||
@@ -162,41 +214,18 @@ describe("setupChannels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect({
|
||||||
if (message === "Select channel (QuickStart)") {
|
configuredAction: "skip",
|
||||||
return "telegram";
|
strictUnexpected: true,
|
||||||
}
|
|
||||||
if (message.includes("already configured")) {
|
|
||||||
return "skip";
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected select prompt: ${message}`);
|
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter, multiselect, text } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
|
|
||||||
await setupChannels(
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
botToken: "token",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
{
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await runSetupChannels(createTelegramCfg("token"), prompter, {
|
||||||
|
quickstartDefaults: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(select).toHaveBeenCalledWith(
|
expect(select).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: "Select channel (QuickStart)" }),
|
expect.objectContaining({ message: "Select channel (QuickStart)" }),
|
||||||
);
|
);
|
||||||
@@ -231,58 +260,26 @@ describe("setupChannels", () => {
|
|||||||
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
|
text: vi.fn(async () => "") as unknown as WizardPrompter["text"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
await runSetupChannels(createTelegramCfg("token", false), prompter);
|
||||||
|
|
||||||
await setupChannels(
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
botToken: "token",
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
{
|
|
||||||
skipConfirm: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
|
||||||
expect(multiselect).not.toHaveBeenCalled();
|
expect(multiselect).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses configureInteractive skip without mutating selection/account state", async () => {
|
it("uses configureInteractive skip without mutating selection/account state", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect();
|
||||||
if (message === "Select channel (QuickStart)") {
|
|
||||||
return "telegram";
|
|
||||||
}
|
|
||||||
return "__done__";
|
|
||||||
});
|
|
||||||
const selection = vi.fn();
|
const selection = vi.fn();
|
||||||
const onAccountId = vi.fn();
|
const onAccountId = vi.fn();
|
||||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
const restore = patchTelegramAdapter({
|
||||||
getStatus: vi.fn(async ({ cfg }) => ({
|
|
||||||
channel: "telegram",
|
|
||||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
|
||||||
statusLines: [],
|
|
||||||
})),
|
|
||||||
configureInteractive,
|
configureInteractive,
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
try {
|
try {
|
||||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
quickstartDefaults: true,
|
||||||
onSelection: selection,
|
onSelection: selection,
|
||||||
onAccountId,
|
onAccountId,
|
||||||
@@ -300,12 +297,7 @@ describe("setupChannels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies configureInteractive result cfg/account updates", async () => {
|
it("applies configureInteractive result cfg/account updates", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect();
|
||||||
if (message === "Select channel (QuickStart)") {
|
|
||||||
return "telegram";
|
|
||||||
}
|
|
||||||
return "__done__";
|
|
||||||
});
|
|
||||||
const selection = vi.fn();
|
const selection = vi.fn();
|
||||||
const onAccountId = vi.fn();
|
const onAccountId = vi.fn();
|
||||||
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||||
@@ -321,27 +313,16 @@ describe("setupChannels", () => {
|
|||||||
const configure = vi.fn(async () => {
|
const configure = vi.fn(async () => {
|
||||||
throw new Error("configure should not be called when configureInteractive is present");
|
throw new Error("configure should not be called when configureInteractive is present");
|
||||||
});
|
});
|
||||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
const restore = patchTelegramAdapter({
|
||||||
getStatus: vi.fn(async ({ cfg }) => ({
|
|
||||||
channel: "telegram",
|
|
||||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
|
||||||
statusLines: [],
|
|
||||||
})),
|
|
||||||
configureInteractive,
|
configureInteractive,
|
||||||
configure,
|
configure,
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
try {
|
try {
|
||||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
const cfg = await runSetupChannels({} as OpenClawConfig, prompter, {
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
quickstartDefaults: true,
|
||||||
onSelection: selection,
|
onSelection: selection,
|
||||||
onAccountId,
|
onAccountId,
|
||||||
@@ -358,12 +339,7 @@ describe("setupChannels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses configureWhenConfigured when channel is already configured", async () => {
|
it("uses configureWhenConfigured when channel is already configured", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect();
|
||||||
if (message === "Select channel (QuickStart)") {
|
|
||||||
return "telegram";
|
|
||||||
}
|
|
||||||
return "__done__";
|
|
||||||
});
|
|
||||||
const selection = vi.fn();
|
const selection = vi.fn();
|
||||||
const onAccountId = vi.fn();
|
const onAccountId = vi.fn();
|
||||||
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||||
@@ -381,43 +357,21 @@ describe("setupChannels", () => {
|
|||||||
"configure should not be called when configureWhenConfigured handles updates",
|
"configure should not be called when configureWhenConfigured handles updates",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
const restore = patchTelegramAdapter({
|
||||||
getStatus: vi.fn(async ({ cfg }) => ({
|
|
||||||
channel: "telegram",
|
|
||||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
|
||||||
statusLines: [],
|
|
||||||
})),
|
|
||||||
configureInteractive: undefined,
|
configureInteractive: undefined,
|
||||||
configureWhenConfigured,
|
configureWhenConfigured,
|
||||||
configure,
|
configure,
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
try {
|
try {
|
||||||
const cfg = await setupChannels(
|
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||||
{
|
quickstartDefaults: true,
|
||||||
channels: {
|
onSelection: selection,
|
||||||
telegram: {
|
onAccountId,
|
||||||
botToken: "old-token",
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
{
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
|
||||||
onSelection: selection,
|
|
||||||
onAccountId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
|
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
|
||||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||||
@@ -433,55 +387,28 @@ describe("setupChannels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
|
it("respects configureWhenConfigured skip without mutating selection or account state", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||||
if (message === "Select channel (QuickStart)") {
|
|
||||||
return "telegram";
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected select prompt: ${message}`);
|
|
||||||
});
|
|
||||||
const selection = vi.fn();
|
const selection = vi.fn();
|
||||||
const onAccountId = vi.fn();
|
const onAccountId = vi.fn();
|
||||||
const configureWhenConfigured = vi.fn(async () => "skip" as const);
|
const configureWhenConfigured = vi.fn(async () => "skip" as const);
|
||||||
const configure = vi.fn(async () => {
|
const configure = vi.fn(async () => {
|
||||||
throw new Error("configure should not run when configureWhenConfigured handles skip");
|
throw new Error("configure should not run when configureWhenConfigured handles skip");
|
||||||
});
|
});
|
||||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
const restore = patchTelegramAdapter({
|
||||||
getStatus: vi.fn(async ({ cfg }) => ({
|
|
||||||
channel: "telegram",
|
|
||||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
|
||||||
statusLines: [],
|
|
||||||
})),
|
|
||||||
configureInteractive: undefined,
|
configureInteractive: undefined,
|
||||||
configureWhenConfigured,
|
configureWhenConfigured,
|
||||||
configure,
|
configure,
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
try {
|
try {
|
||||||
const cfg = await setupChannels(
|
const cfg = await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||||
{
|
quickstartDefaults: true,
|
||||||
channels: {
|
onSelection: selection,
|
||||||
telegram: {
|
onAccountId,
|
||||||
botToken: "old-token",
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
{
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
|
||||||
onSelection: selection,
|
|
||||||
onAccountId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
expect(configureWhenConfigured).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||||
@@ -496,54 +423,27 @@ describe("setupChannels", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
|
it("prefers configureInteractive over configureWhenConfigured when both hooks exist", async () => {
|
||||||
const select = vi.fn(async ({ message }: { message: string }) => {
|
const select = createQuickstartTelegramSelect({ strictUnexpected: true });
|
||||||
if (message === "Select channel (QuickStart)") {
|
|
||||||
return "telegram";
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected select prompt: ${message}`);
|
|
||||||
});
|
|
||||||
const selection = vi.fn();
|
const selection = vi.fn();
|
||||||
const onAccountId = vi.fn();
|
const onAccountId = vi.fn();
|
||||||
const configureInteractive = vi.fn(async () => "skip" as const);
|
const configureInteractive = vi.fn(async () => "skip" as const);
|
||||||
const configureWhenConfigured = vi.fn(async () => {
|
const configureWhenConfigured = vi.fn(async () => {
|
||||||
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
|
throw new Error("configureWhenConfigured should not run when configureInteractive exists");
|
||||||
});
|
});
|
||||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
const restore = patchTelegramAdapter({
|
||||||
getStatus: vi.fn(async ({ cfg }) => ({
|
|
||||||
channel: "telegram",
|
|
||||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
|
||||||
statusLines: [],
|
|
||||||
})),
|
|
||||||
configureInteractive,
|
configureInteractive,
|
||||||
configureWhenConfigured,
|
configureWhenConfigured,
|
||||||
});
|
});
|
||||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
const { prompter } = createUnexpectedQuickstartPrompter(
|
||||||
|
select as unknown as WizardPrompter["select"],
|
||||||
|
);
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
select: select as unknown as WizardPrompter["select"],
|
|
||||||
multiselect,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createExitThrowingRuntime();
|
|
||||||
try {
|
try {
|
||||||
await setupChannels(
|
await runSetupChannels(createTelegramCfg("old-token"), prompter, {
|
||||||
{
|
quickstartDefaults: true,
|
||||||
channels: {
|
onSelection: selection,
|
||||||
telegram: {
|
onAccountId,
|
||||||
botToken: "old-token",
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
{
|
|
||||||
skipConfirm: true,
|
|
||||||
quickstartDefaults: true,
|
|
||||||
onSelection: selection,
|
|
||||||
onAccountId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(configureInteractive).toHaveBeenCalledWith(
|
expect(configureInteractive).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
expect.objectContaining({ configured: true, label: expect.any(String) }),
|
||||||
|
|||||||
@@ -76,6 +76,43 @@ function expectOpenAiCompatResult(params: {
|
|||||||
expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions");
|
expect(params.result.config.models?.providers?.custom?.api).toBe("openai-completions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCustomProviderConfig(contextWindow?: number) {
|
||||||
|
if (contextWindow === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
custom: {
|
||||||
|
api: "openai-completions",
|
||||||
|
baseUrl: "https://llm.example.com/v1",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "foo-large",
|
||||||
|
name: "foo-large",
|
||||||
|
contextWindow,
|
||||||
|
maxTokens: contextWindow > CONTEXT_WINDOW_HARD_MIN_TOKENS ? 4096 : 1024,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
reasoning: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomModelConfigWithContextWindow(contextWindow?: number) {
|
||||||
|
return applyCustomApiConfig({
|
||||||
|
config: buildCustomProviderConfig(contextWindow),
|
||||||
|
baseUrl: "https://llm.example.com/v1",
|
||||||
|
modelId: "foo-large",
|
||||||
|
compatibility: "openai",
|
||||||
|
providerId: "custom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("promptCustomApiConfig", () => {
|
describe("promptCustomApiConfig", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@@ -327,89 +364,28 @@ describe("promptCustomApiConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("applyCustomApiConfig", () => {
|
describe("applyCustomApiConfig", () => {
|
||||||
it("uses hard-min context window for newly added custom models", () => {
|
it.each([
|
||||||
const result = applyCustomApiConfig({
|
{
|
||||||
config: {},
|
name: "uses hard-min context window for newly added custom models",
|
||||||
baseUrl: "https://llm.example.com/v1",
|
existingContextWindow: undefined,
|
||||||
modelId: "foo-large",
|
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||||
compatibility: "openai",
|
},
|
||||||
providerId: "custom",
|
{
|
||||||
});
|
name: "upgrades existing custom model context window when below hard minimum",
|
||||||
|
existingContextWindow: 4096,
|
||||||
|
expectedContextWindow: CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserves existing custom model context window when already above minimum",
|
||||||
|
existingContextWindow: 131072,
|
||||||
|
expectedContextWindow: 131072,
|
||||||
|
},
|
||||||
|
])("$name", ({ existingContextWindow, expectedContextWindow }) => {
|
||||||
|
const result = applyCustomModelConfigWithContextWindow(existingContextWindow);
|
||||||
const model = result.config.models?.providers?.custom?.models?.find(
|
const model = result.config.models?.providers?.custom?.models?.find(
|
||||||
(entry) => entry.id === "foo-large",
|
(entry) => entry.id === "foo-large",
|
||||||
);
|
);
|
||||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
expect(model?.contextWindow).toBe(expectedContextWindow);
|
||||||
});
|
|
||||||
|
|
||||||
it("upgrades existing custom model context window when below hard minimum", () => {
|
|
||||||
const result = applyCustomApiConfig({
|
|
||||||
config: {
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
custom: {
|
|
||||||
api: "openai-completions",
|
|
||||||
baseUrl: "https://llm.example.com/v1",
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "foo-large",
|
|
||||||
name: "foo-large",
|
|
||||||
contextWindow: 4096,
|
|
||||||
maxTokens: 1024,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
reasoning: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
baseUrl: "https://llm.example.com/v1",
|
|
||||||
modelId: "foo-large",
|
|
||||||
compatibility: "openai",
|
|
||||||
providerId: "custom",
|
|
||||||
});
|
|
||||||
|
|
||||||
const model = result.config.models?.providers?.custom?.models?.find(
|
|
||||||
(entry) => entry.id === "foo-large",
|
|
||||||
);
|
|
||||||
expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves existing custom model context window when already above minimum", () => {
|
|
||||||
const result = applyCustomApiConfig({
|
|
||||||
config: {
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
custom: {
|
|
||||||
api: "openai-completions",
|
|
||||||
baseUrl: "https://llm.example.com/v1",
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "foo-large",
|
|
||||||
name: "foo-large",
|
|
||||||
contextWindow: 131072,
|
|
||||||
maxTokens: 4096,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
reasoning: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
baseUrl: "https://llm.example.com/v1",
|
|
||||||
modelId: "foo-large",
|
|
||||||
compatibility: "openai",
|
|
||||||
providerId: "custom",
|
|
||||||
});
|
|
||||||
|
|
||||||
const model = result.config.models?.providers?.custom?.models?.find(
|
|
||||||
(entry) => entry.id === "foo-large",
|
|
||||||
);
|
|
||||||
expect(model?.contextWindow).toBe(131072);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
|||||||
return createWizardPrompter(overrides, { defaultSelect: "" });
|
return createWizardPrompter(overrides, { defaultSelect: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSelectPrompter(
|
||||||
|
responses: Partial<Record<string, string>>,
|
||||||
|
): WizardPrompter["select"] {
|
||||||
|
return vi.fn(async (params) => {
|
||||||
|
const value = responses[params.message];
|
||||||
|
if (value !== undefined) {
|
||||||
|
return value as never;
|
||||||
|
}
|
||||||
|
return (params.options[0]?.value ?? "") as never;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("promptRemoteGatewayConfig", () => {
|
describe("promptRemoteGatewayConfig", () => {
|
||||||
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
|
const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]);
|
||||||
|
|
||||||
@@ -49,17 +61,10 @@ describe("promptRemoteGatewayConfig", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
const select = createSelectPrompter({
|
||||||
if (params.message === "Select gateway") {
|
"Select gateway": "0",
|
||||||
return "0" as never;
|
"Connection method": "direct",
|
||||||
}
|
"Gateway auth": "token",
|
||||||
if (params.message === "Connection method") {
|
|
||||||
return "direct" as never;
|
|
||||||
}
|
|
||||||
if (params.message === "Gateway auth") {
|
|
||||||
return "token" as never;
|
|
||||||
}
|
|
||||||
return (params.options[0]?.value ?? "") as never;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
const text: WizardPrompter["text"] = vi.fn(async (params) => {
|
||||||
@@ -106,12 +111,7 @@ describe("promptRemoteGatewayConfig", () => {
|
|||||||
return "";
|
return "";
|
||||||
}) as WizardPrompter["text"];
|
}) as WizardPrompter["text"];
|
||||||
|
|
||||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||||
if (params.message === "Gateway auth") {
|
|
||||||
return "off" as never;
|
|
||||||
}
|
|
||||||
return (params.options[0]?.value ?? "") as never;
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = {} as OpenClawConfig;
|
const cfg = {} as OpenClawConfig;
|
||||||
const prompter = createPrompter({
|
const prompter = createPrompter({
|
||||||
@@ -138,12 +138,7 @@ describe("promptRemoteGatewayConfig", () => {
|
|||||||
return "";
|
return "";
|
||||||
}) as WizardPrompter["text"];
|
}) as WizardPrompter["text"];
|
||||||
|
|
||||||
const select: WizardPrompter["select"] = vi.fn(async (params) => {
|
const select = createSelectPrompter({ "Gateway auth": "off" });
|
||||||
if (params.message === "Gateway auth") {
|
|
||||||
return "off" as never;
|
|
||||||
}
|
|
||||||
return (params.options[0]?.value ?? "") as never;
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = {} as OpenClawConfig;
|
const cfg = {} as OpenClawConfig;
|
||||||
const prompter = createPrompter({
|
const prompter = createPrompter({
|
||||||
|
|||||||
@@ -85,6 +85,66 @@ async function withUnknownUsageStore(run: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRuntimeLogs() {
|
||||||
|
return runtimeLogMock.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJoinedRuntimeLogs() {
|
||||||
|
return getRuntimeLogs().join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStatusAndGetLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||||
|
runtimeLogMock.mockClear();
|
||||||
|
await statusCommand(args, runtime as never);
|
||||||
|
return getRuntimeLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStatusAndGetJoinedLogs(args: Parameters<typeof statusCommand>[0] = {}) {
|
||||||
|
await runStatusAndGetLogs(args);
|
||||||
|
return getJoinedRuntimeLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProbeGatewayResult = {
|
||||||
|
ok: boolean;
|
||||||
|
url: string;
|
||||||
|
connectLatencyMs: number | null;
|
||||||
|
error: string | null;
|
||||||
|
close: { code: number; reason: string } | null;
|
||||||
|
health: unknown;
|
||||||
|
status: unknown;
|
||||||
|
presence: unknown;
|
||||||
|
configSnapshot: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockProbeGatewayResult(overrides: Partial<ProbeGatewayResult>) {
|
||||||
|
mocks.probeGateway.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
url: "ws://127.0.0.1:18789",
|
||||||
|
connectLatencyMs: null,
|
||||||
|
error: "timeout",
|
||||||
|
close: null,
|
||||||
|
health: null,
|
||||||
|
status: null,
|
||||||
|
presence: null,
|
||||||
|
configSnapshot: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
const prevValue = process.env[key];
|
||||||
|
process.env[key] = value;
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
if (prevValue === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = prevValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
loadSessionStore: vi.fn().mockReturnValue({
|
loadSessionStore: vi.fn().mockReturnValue({
|
||||||
"+1000": createDefaultSessionStoreEntry(),
|
"+1000": createDefaultSessionStoreEntry(),
|
||||||
@@ -367,86 +427,68 @@ describe("statusCommand", () => {
|
|||||||
|
|
||||||
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
|
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
|
||||||
await withUnknownUsageStore(async () => {
|
await withUnknownUsageStore(async () => {
|
||||||
runtimeLogMock.mockClear();
|
const logs = await runStatusAndGetLogs();
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
||||||
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
|
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints formatted lines otherwise", async () => {
|
it("prints formatted lines otherwise", async () => {
|
||||||
runtimeLogMock.mockClear();
|
const logs = await runStatusAndGetLogs();
|
||||||
await statusCommand({}, runtime as never);
|
for (const token of [
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
"OpenClaw status",
|
||||||
expect(logs.some((l: string) => l.includes("OpenClaw status"))).toBe(true);
|
"Overview",
|
||||||
expect(logs.some((l: string) => l.includes("Overview"))).toBe(true);
|
"Security audit",
|
||||||
expect(logs.some((l: string) => l.includes("Security audit"))).toBe(true);
|
"Summary:",
|
||||||
expect(logs.some((l: string) => l.includes("Summary:"))).toBe(true);
|
"CRITICAL",
|
||||||
expect(logs.some((l: string) => l.includes("CRITICAL"))).toBe(true);
|
"Dashboard",
|
||||||
expect(logs.some((l: string) => l.includes("Dashboard"))).toBe(true);
|
"macos 14.0 (arm64)",
|
||||||
expect(logs.some((l: string) => l.includes("macos 14.0 (arm64)"))).toBe(true);
|
"Memory",
|
||||||
expect(logs.some((l: string) => l.includes("Memory"))).toBe(true);
|
"Channels",
|
||||||
expect(logs.some((l: string) => l.includes("Channels"))).toBe(true);
|
"WhatsApp",
|
||||||
expect(logs.some((l: string) => l.includes("WhatsApp"))).toBe(true);
|
"bootstrap files",
|
||||||
expect(logs.some((l: string) => l.includes("bootstrap files"))).toBe(true);
|
"Sessions",
|
||||||
expect(logs.some((l: string) => l.includes("Sessions"))).toBe(true);
|
"+1000",
|
||||||
expect(logs.some((l: string) => l.includes("+1000"))).toBe(true);
|
"50%",
|
||||||
expect(logs.some((l: string) => l.includes("50%"))).toBe(true);
|
"40% cached",
|
||||||
expect(logs.some((l: string) => l.includes("40% cached"))).toBe(true);
|
"LaunchAgent",
|
||||||
expect(logs.some((l: string) => l.includes("LaunchAgent"))).toBe(true);
|
"FAQ:",
|
||||||
expect(logs.some((l: string) => l.includes("FAQ:"))).toBe(true);
|
"Troubleshooting:",
|
||||||
expect(logs.some((l: string) => l.includes("Troubleshooting:"))).toBe(true);
|
"Next steps:",
|
||||||
expect(logs.some((l: string) => l.includes("Next steps:"))).toBe(true);
|
]) {
|
||||||
|
expect(logs.some((line) => line.includes(token))).toBe(true);
|
||||||
|
}
|
||||||
expect(
|
expect(
|
||||||
logs.some(
|
logs.some(
|
||||||
(l: string) =>
|
(line) =>
|
||||||
l.includes("openclaw status --all") ||
|
line.includes("openclaw status --all") ||
|
||||||
l.includes("openclaw --profile isolated status --all") ||
|
line.includes("openclaw --profile isolated status --all"),
|
||||||
l.includes("openclaw status --all") ||
|
|
||||||
l.includes("openclaw --profile isolated status --all"),
|
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows gateway auth when reachable", async () => {
|
it("shows gateway auth when reachable", async () => {
|
||||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
await withEnvVar("OPENCLAW_GATEWAY_TOKEN", "abcd1234", async () => {
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = "abcd1234";
|
mockProbeGatewayResult({
|
||||||
try {
|
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
connectLatencyMs: 123,
|
connectLatencyMs: 123,
|
||||||
error: null,
|
error: null,
|
||||||
close: null,
|
|
||||||
health: {},
|
health: {},
|
||||||
status: {},
|
status: {},
|
||||||
presence: [],
|
presence: [],
|
||||||
configSnapshot: null,
|
|
||||||
});
|
});
|
||||||
runtimeLogMock.mockClear();
|
const logs = await runStatusAndGetLogs();
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
||||||
expect(logs.some((l: string) => l.includes("auth token"))).toBe(true);
|
expect(logs.some((l: string) => l.includes("auth token"))).toBe(true);
|
||||||
} finally {
|
});
|
||||||
if (prevToken === undefined) {
|
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces channel runtime errors from the gateway", async () => {
|
it("surfaces channel runtime errors from the gateway", async () => {
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
mockProbeGatewayResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
connectLatencyMs: 10,
|
connectLatencyMs: 10,
|
||||||
error: null,
|
error: null,
|
||||||
close: null,
|
|
||||||
health: {},
|
health: {},
|
||||||
status: {},
|
status: {},
|
||||||
presence: [],
|
presence: [],
|
||||||
configSnapshot: null,
|
|
||||||
});
|
});
|
||||||
mocks.callGateway.mockResolvedValueOnce({
|
mocks.callGateway.mockResolvedValueOnce({
|
||||||
channelAccounts: {
|
channelAccounts: {
|
||||||
@@ -471,98 +513,58 @@ describe("statusCommand", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
runtimeLogMock.mockClear();
|
const joined = await runStatusAndGetJoinedLogs();
|
||||||
await statusCommand({}, runtime as never);
|
expect(joined).toMatch(/Signal/i);
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
expect(joined).toMatch(/iMessage/i);
|
||||||
expect(logs.join("\n")).toMatch(/Signal/i);
|
expect(joined).toMatch(/gateway:/i);
|
||||||
expect(logs.join("\n")).toMatch(/iMessage/i);
|
expect(joined).toMatch(/WARN/);
|
||||||
expect(logs.join("\n")).toMatch(/gateway:/i);
|
|
||||||
expect(logs.join("\n")).toMatch(/WARN/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints requestId-aware recovery guidance when gateway pairing is required", async () => {
|
it.each([
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
{
|
||||||
ok: false,
|
name: "prints requestId-aware recovery guidance when gateway pairing is required",
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
connectLatencyMs: null,
|
|
||||||
error: "connect failed: pairing required (requestId: req-123)",
|
error: "connect failed: pairing required (requestId: req-123)",
|
||||||
close: { code: 1008, reason: "pairing required (requestId: req-123)" },
|
closeReason: "pairing required (requestId: req-123)",
|
||||||
health: null,
|
includes: ["devices approve req-123"],
|
||||||
status: null,
|
excludes: [],
|
||||||
presence: null,
|
},
|
||||||
configSnapshot: null,
|
{
|
||||||
});
|
name: "prints fallback recovery guidance when pairing requestId is unavailable",
|
||||||
|
|
||||||
runtimeLogMock.mockClear();
|
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
||||||
const joined = logs.join("\n");
|
|
||||||
expect(joined).toContain("Gateway pairing approval required.");
|
|
||||||
expect(joined).toContain("devices approve req-123");
|
|
||||||
expect(joined).toContain("devices approve --latest");
|
|
||||||
expect(joined).toContain("devices list");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prints fallback recovery guidance when pairing requestId is unavailable", async () => {
|
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
connectLatencyMs: null,
|
|
||||||
error: "connect failed: pairing required",
|
error: "connect failed: pairing required",
|
||||||
close: { code: 1008, reason: "connect failed" },
|
closeReason: "connect failed",
|
||||||
health: null,
|
includes: [],
|
||||||
status: null,
|
excludes: ["devices approve req-"],
|
||||||
presence: null,
|
},
|
||||||
configSnapshot: null,
|
{
|
||||||
|
name: "does not render unsafe requestId content into approval command hints",
|
||||||
|
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
||||||
|
closeReason: "pairing required (requestId: req-123;rm -rf /)",
|
||||||
|
includes: [],
|
||||||
|
excludes: ["devices approve req-123;rm -rf /"],
|
||||||
|
},
|
||||||
|
])("$name", async ({ error, closeReason, includes, excludes }) => {
|
||||||
|
mockProbeGatewayResult({
|
||||||
|
error,
|
||||||
|
close: { code: 1008, reason: closeReason },
|
||||||
});
|
});
|
||||||
|
const joined = await runStatusAndGetJoinedLogs();
|
||||||
runtimeLogMock.mockClear();
|
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const logs = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
||||||
const joined = logs.join("\n");
|
|
||||||
expect(joined).toContain("Gateway pairing approval required.");
|
expect(joined).toContain("Gateway pairing approval required.");
|
||||||
expect(joined).not.toContain("devices approve req-");
|
|
||||||
expect(joined).toContain("devices approve --latest");
|
expect(joined).toContain("devices approve --latest");
|
||||||
expect(joined).toContain("devices list");
|
expect(joined).toContain("devices list");
|
||||||
});
|
for (const expected of includes) {
|
||||||
|
expect(joined).toContain(expected);
|
||||||
it("does not render unsafe requestId content into approval command hints", async () => {
|
}
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
for (const blocked of excludes) {
|
||||||
ok: false,
|
expect(joined).not.toContain(blocked);
|
||||||
url: "ws://127.0.0.1:18789",
|
}
|
||||||
connectLatencyMs: null,
|
|
||||||
error: "connect failed: pairing required (requestId: req-123;rm -rf /)",
|
|
||||||
close: { code: 1008, reason: "pairing required (requestId: req-123;rm -rf /)" },
|
|
||||||
health: null,
|
|
||||||
status: null,
|
|
||||||
presence: null,
|
|
||||||
configSnapshot: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
runtimeLogMock.mockClear();
|
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
|
||||||
expect(joined).toContain("Gateway pairing approval required.");
|
|
||||||
expect(joined).not.toContain("devices approve req-123;rm -rf /");
|
|
||||||
expect(joined).toContain("devices approve --latest");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts requestId from close reason when error text omits it", async () => {
|
it("extracts requestId from close reason when error text omits it", async () => {
|
||||||
mocks.probeGateway.mockResolvedValueOnce({
|
mockProbeGatewayResult({
|
||||||
ok: false,
|
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
connectLatencyMs: null,
|
|
||||||
error: "connect failed: pairing required",
|
error: "connect failed: pairing required",
|
||||||
close: { code: 1008, reason: "pairing required (requestId: req-close-456)" },
|
close: { code: 1008, reason: "pairing required (requestId: req-close-456)" },
|
||||||
health: null,
|
|
||||||
status: null,
|
|
||||||
presence: null,
|
|
||||||
configSnapshot: null,
|
|
||||||
});
|
});
|
||||||
|
const joined = await runStatusAndGetJoinedLogs();
|
||||||
runtimeLogMock.mockClear();
|
|
||||||
await statusCommand({}, runtime as never);
|
|
||||||
const joined = runtimeLogMock.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
|
||||||
expect(joined).toContain("devices approve req-close-456");
|
expect(joined).toContain("devices approve req-close-456");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,35 +2,57 @@ import { EventEmitter } from "node:events";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||||
|
|
||||||
|
function createGatewayWaitHarness() {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const disconnect = vi.fn();
|
||||||
|
const abort = new AbortController();
|
||||||
|
return { emitter, disconnect, abort };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGatewayWait(params?: {
|
||||||
|
onGatewayError?: (error: unknown) => void;
|
||||||
|
shouldStopOnError?: (error: unknown) => boolean;
|
||||||
|
registerForceStop?: (fn: (error: unknown) => void) => void;
|
||||||
|
}) {
|
||||||
|
const harness = createGatewayWaitHarness();
|
||||||
|
const promise = waitForDiscordGatewayStop({
|
||||||
|
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
|
||||||
|
abortSignal: harness.abort.signal,
|
||||||
|
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
|
||||||
|
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
|
||||||
|
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
|
||||||
|
});
|
||||||
|
return { ...harness, promise };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAbortToResolve(params: {
|
||||||
|
emitter: EventEmitter;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
abort: AbortController;
|
||||||
|
promise: Promise<void>;
|
||||||
|
expectedDisconnectBeforeAbort?: number;
|
||||||
|
}) {
|
||||||
|
if (params.expectedDisconnectBeforeAbort !== undefined) {
|
||||||
|
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
|
||||||
|
}
|
||||||
|
expect(params.emitter.listenerCount("error")).toBe(1);
|
||||||
|
params.abort.abort();
|
||||||
|
await expect(params.promise).resolves.toBeUndefined();
|
||||||
|
expect(params.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(params.emitter.listenerCount("error")).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
describe("waitForDiscordGatewayStop", () => {
|
describe("waitForDiscordGatewayStop", () => {
|
||||||
it("resolves on abort and disconnects gateway", async () => {
|
it("resolves on abort and disconnects gateway", async () => {
|
||||||
const emitter = new EventEmitter();
|
const { emitter, disconnect, abort, promise } = startGatewayWait();
|
||||||
const disconnect = vi.fn();
|
await expectAbortToResolve({ emitter, disconnect, abort, promise });
|
||||||
const abort = new AbortController();
|
|
||||||
|
|
||||||
const promise = waitForDiscordGatewayStop({
|
|
||||||
gateway: { emitter, disconnect },
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(emitter.listenerCount("error")).toBe(1);
|
|
||||||
abort.abort();
|
|
||||||
|
|
||||||
await expect(promise).resolves.toBeUndefined();
|
|
||||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
|
||||||
expect(emitter.listenerCount("error")).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects on gateway error and disconnects", async () => {
|
it("rejects on gateway error and disconnects", async () => {
|
||||||
const emitter = new EventEmitter();
|
|
||||||
const disconnect = vi.fn();
|
|
||||||
const onGatewayError = vi.fn();
|
const onGatewayError = vi.fn();
|
||||||
const abort = new AbortController();
|
|
||||||
const err = new Error("boom");
|
const err = new Error("boom");
|
||||||
|
|
||||||
const promise = waitForDiscordGatewayStop({
|
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||||
gateway: { emitter, disconnect },
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
onGatewayError,
|
onGatewayError,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,28 +68,23 @@ describe("waitForDiscordGatewayStop", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores gateway errors when instructed", async () => {
|
it("ignores gateway errors when instructed", async () => {
|
||||||
const emitter = new EventEmitter();
|
|
||||||
const disconnect = vi.fn();
|
|
||||||
const onGatewayError = vi.fn();
|
const onGatewayError = vi.fn();
|
||||||
const abort = new AbortController();
|
|
||||||
const err = new Error("transient");
|
const err = new Error("transient");
|
||||||
|
|
||||||
const promise = waitForDiscordGatewayStop({
|
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||||
gateway: { emitter, disconnect },
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
onGatewayError,
|
onGatewayError,
|
||||||
shouldStopOnError: () => false,
|
shouldStopOnError: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
emitter.emit("error", err);
|
emitter.emit("error", err);
|
||||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||||
expect(disconnect).toHaveBeenCalledTimes(0);
|
await expectAbortToResolve({
|
||||||
expect(emitter.listenerCount("error")).toBe(1);
|
emitter,
|
||||||
|
disconnect,
|
||||||
abort.abort();
|
abort,
|
||||||
await expect(promise).resolves.toBeUndefined();
|
promise,
|
||||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
expectedDisconnectBeforeAbort: 0,
|
||||||
expect(emitter.listenerCount("error")).toBe(0);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves on abort without a gateway", async () => {
|
it("resolves on abort without a gateway", async () => {
|
||||||
@@ -83,14 +100,9 @@ describe("waitForDiscordGatewayStop", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects via registerForceStop and disconnects gateway", async () => {
|
it("rejects via registerForceStop and disconnects gateway", async () => {
|
||||||
const emitter = new EventEmitter();
|
|
||||||
const disconnect = vi.fn();
|
|
||||||
const abort = new AbortController();
|
|
||||||
let forceStop: ((err: unknown) => void) | undefined;
|
let forceStop: ((err: unknown) => void) | undefined;
|
||||||
|
|
||||||
const promise = waitForDiscordGatewayStop({
|
const { emitter, disconnect, promise } = startGatewayWait({
|
||||||
gateway: { emitter, disconnect },
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
registerForceStop: (fn) => {
|
registerForceStop: (fn) => {
|
||||||
forceStop = fn;
|
forceStop = fn;
|
||||||
},
|
},
|
||||||
@@ -106,14 +118,9 @@ describe("waitForDiscordGatewayStop", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores forceStop after promise already settled", async () => {
|
it("ignores forceStop after promise already settled", async () => {
|
||||||
const emitter = new EventEmitter();
|
|
||||||
const disconnect = vi.fn();
|
|
||||||
const abort = new AbortController();
|
|
||||||
let forceStop: ((err: unknown) => void) | undefined;
|
let forceStop: ((err: unknown) => void) | undefined;
|
||||||
|
|
||||||
const promise = waitForDiscordGatewayStop({
|
const { abort, disconnect, promise } = startGatewayWait({
|
||||||
gateway: { emitter, disconnect },
|
|
||||||
abortSignal: abort.signal,
|
|
||||||
registerForceStop: (fn) => {
|
registerForceStop: (fn) => {
|
||||||
forceStop = fn;
|
forceStop = fn;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,24 +59,34 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("generates a token when the prompt returns undefined", async () => {
|
async function runGatewayConfig(params?: {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
flow?: "advanced" | "quickstart";
|
||||||
|
bindChoice?: string;
|
||||||
|
authChoice?: "token" | "password";
|
||||||
|
tailscaleChoice?: "off" | "serve";
|
||||||
|
textQueue?: Array<string | undefined>;
|
||||||
|
nextConfig?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const authChoice = params?.authChoice ?? "token";
|
||||||
const prompter = createPrompter({
|
const prompter = createPrompter({
|
||||||
selectQueue: ["loopback", "token", "off"],
|
selectQueue: [params?.bindChoice ?? "loopback", authChoice, params?.tailscaleChoice ?? "off"],
|
||||||
textQueue: ["18789", undefined],
|
textQueue: params?.textQueue ?? ["18789", undefined],
|
||||||
});
|
});
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
return configureGatewayForOnboarding({
|
||||||
const result = await configureGatewayForOnboarding({
|
flow: params?.flow ?? "advanced",
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
baseConfig: {},
|
||||||
nextConfig: {},
|
nextConfig: params?.nextConfig ?? {},
|
||||||
localPort: 18789,
|
localPort: 18789,
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
quickstartGateway: createQuickstartGateway(authChoice),
|
||||||
prompter,
|
prompter,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("generates a token when the prompt returns undefined", async () => {
|
||||||
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
|
const result = await runGatewayConfig();
|
||||||
|
|
||||||
expect(result.settings.gatewayToken).toBe("generated-token");
|
expect(result.settings.gatewayToken).toBe("generated-token");
|
||||||
expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([
|
expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([
|
||||||
@@ -95,21 +105,10 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
mocks.randomToken.mockClear();
|
mocks.randomToken.mockClear();
|
||||||
|
|
||||||
const prompter = createPrompter({
|
|
||||||
selectQueue: ["loopback", "token", "off"],
|
|
||||||
textQueue: [],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await configureGatewayForOnboarding({
|
const result = await runGatewayConfig({
|
||||||
flow: "quickstart",
|
flow: "quickstart",
|
||||||
baseConfig: {},
|
textQueue: [],
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.settings.gatewayToken).toBe("token-from-env");
|
expect(result.settings.gatewayToken).toBe("token-from-env");
|
||||||
@@ -124,22 +123,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
|
|
||||||
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
|
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
|
||||||
mocks.randomToken.mockReturnValue("unused");
|
mocks.randomToken.mockReturnValue("unused");
|
||||||
|
const result = await runGatewayConfig({
|
||||||
// Flow: loopback bind → password auth → tailscale off
|
authChoice: "password",
|
||||||
const prompter = createPrompter({
|
|
||||||
selectQueue: ["loopback", "password", "off"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("password"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const authConfig = result.nextConfig.gateway?.auth as { mode?: string; password?: string };
|
const authConfig = result.nextConfig.gateway?.auth as { mode?: string; password?: string };
|
||||||
@@ -150,21 +135,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
|
|
||||||
it("seeds control UI allowed origins for non-loopback binds", async () => {
|
it("seeds control UI allowed origins for non-loopback binds", async () => {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
|
const result = await runGatewayConfig({
|
||||||
const prompter = createPrompter({
|
bindChoice: "lan",
|
||||||
selectQueue: ["lan", "token", "off"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toEqual([
|
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toEqual([
|
||||||
@@ -176,21 +148,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
|
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||||
|
const result = await runGatewayConfig({
|
||||||
const prompter = createPrompter({
|
tailscaleChoice: "serve",
|
||||||
selectQueue: ["loopback", "token", "serve"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||||
@@ -201,21 +160,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
|
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
|
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
|
||||||
|
const result = await runGatewayConfig({
|
||||||
const prompter = createPrompter({
|
tailscaleChoice: "serve",
|
||||||
selectQueue: ["loopback", "token", "serve"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
|
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
|
||||||
@@ -224,21 +170,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
|
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
|
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
|
||||||
|
const result = await runGatewayConfig({
|
||||||
const prompter = createPrompter({
|
tailscaleChoice: "serve",
|
||||||
selectQueue: ["loopback", "token", "serve"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {},
|
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
|
||||||
@@ -249,16 +182,8 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
|
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
|
||||||
mocks.randomToken.mockReturnValue("generated-token");
|
mocks.randomToken.mockReturnValue("generated-token");
|
||||||
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
|
||||||
|
const result = await runGatewayConfig({
|
||||||
const prompter = createPrompter({
|
tailscaleChoice: "serve",
|
||||||
selectQueue: ["loopback", "token", "serve"],
|
|
||||||
textQueue: ["18789", undefined],
|
|
||||||
});
|
|
||||||
const runtime = createRuntime();
|
|
||||||
|
|
||||||
const result = await configureGatewayForOnboarding({
|
|
||||||
flow: "advanced",
|
|
||||||
baseConfig: {},
|
|
||||||
nextConfig: {
|
nextConfig: {
|
||||||
gateway: {
|
gateway: {
|
||||||
controlUi: {
|
controlUi: {
|
||||||
@@ -266,10 +191,6 @@ describe("configureGatewayForOnboarding", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localPort: 18789,
|
|
||||||
quickstartGateway: createQuickstartGateway("token"),
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
|
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
|
||||||
|
|||||||
Reference in New Issue
Block a user