mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
perf(test): eliminate resetModules via injectable seams
This commit is contained in:
@@ -1,14 +1,15 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||||
|
import { createExecTool, setPtyModuleLoaderForTests } from "./bash-tools.exec";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
resetProcessRegistryForTests();
|
resetProcessRegistryForTests();
|
||||||
vi.resetModules();
|
setPtyModuleLoaderForTests();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exec falls back when PTY spawn fails", async () => {
|
test("exec falls back when PTY spawn fails", async () => {
|
||||||
vi.doMock("@lydell/node-pty", () => ({
|
setPtyModuleLoaderForTests(async () => ({
|
||||||
spawn: () => {
|
spawn: () => {
|
||||||
const err = new Error("spawn EBADF");
|
const err = new Error("spawn EBADF");
|
||||||
(err as NodeJS.ErrnoException).code = "EBADF";
|
(err as NodeJS.ErrnoException).code = "EBADF";
|
||||||
@@ -16,7 +17,6 @@ test("exec falls back when PTY spawn fails", async () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { createExecTool } = await import("./bash-tools.exec");
|
|
||||||
const tool = createExecTool({ allowBackground: false });
|
const tool = createExecTool({ allowBackground: false });
|
||||||
const result = await tool.execute("toolcall", {
|
const result = await tool.execute("toolcall", {
|
||||||
command: "printf ok",
|
command: "printf ok",
|
||||||
|
|||||||
@@ -144,6 +144,19 @@ type PtySpawn = (
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
},
|
},
|
||||||
) => PtyHandle;
|
) => PtyHandle;
|
||||||
|
type PtyModule = {
|
||||||
|
spawn?: PtySpawn;
|
||||||
|
default?: { spawn?: PtySpawn };
|
||||||
|
};
|
||||||
|
type PtyModuleLoader = () => Promise<PtyModule>;
|
||||||
|
|
||||||
|
const loadPtyModuleDefault: PtyModuleLoader = async () =>
|
||||||
|
(await import("@lydell/node-pty")) as unknown as PtyModule;
|
||||||
|
let loadPtyModule: PtyModuleLoader = loadPtyModuleDefault;
|
||||||
|
|
||||||
|
export function setPtyModuleLoaderForTests(loader?: PtyModuleLoader): void {
|
||||||
|
loadPtyModule = loader ?? loadPtyModuleDefault;
|
||||||
|
}
|
||||||
|
|
||||||
type ExecProcessOutcome = {
|
type ExecProcessOutcome = {
|
||||||
status: "completed" | "failed";
|
status: "completed" | "failed";
|
||||||
@@ -477,10 +490,7 @@ async function runExecProcess(opts: {
|
|||||||
} else if (opts.usePty) {
|
} else if (opts.usePty) {
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
const { shell, args: shellArgs } = getShellConfig();
|
||||||
try {
|
try {
|
||||||
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
|
const ptyModule = await loadPtyModule();
|
||||||
spawn?: PtySpawn;
|
|
||||||
default?: { spawn?: PtySpawn };
|
|
||||||
};
|
|
||||||
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
|
||||||
if (!spawnPty) {
|
if (!spawnPty) {
|
||||||
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
throw new Error("PTY support is unavailable (node-pty spawn not found).");
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const execSyncMock = vi.fn();
|
|||||||
|
|
||||||
describe("cli credentials", () => {
|
describe("cli credentials", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
|
|
||||||
@@ -45,7 +45,6 @@ describe("models-config", () => {
|
|||||||
|
|
||||||
it("normalizes gemini 3 ids to preview for google providers", async () => {
|
it("normalizes gemini 3 ids to preview for google providers", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
vi.resetModules();
|
|
||||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||||
vi.resetModules();
|
|
||||||
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not sanitize tool call ids for openai-responses", async () => {
|
it("sanitizes tool call ids for openai-responses", async () => {
|
||||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||||
|
|
||||||
await sanitizeSessionHistory({
|
await sanitizeSessionHistory({
|
||||||
@@ -108,7 +107,11 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||||
mockMessages,
|
mockMessages,
|
||||||
"session:history",
|
"session:history",
|
||||||
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
|
expect.objectContaining({
|
||||||
|
sanitizeMode: "images-only",
|
||||||
|
sanitizeToolCallIds: true,
|
||||||
|
toolCallIdMode: "strict",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ describe("sanitizeSessionHistory", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||||
vi.resetModules();
|
|
||||||
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
({ sanitizeSessionHistory } = await import("./pi-embedded-runner/google.js"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ vi.mock("../skills.js", async (importOriginal) => {
|
|||||||
describe("Agent-specific sandbox config", () => {
|
describe("Agent-specific sandbox config", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spawnCalls.length = 0;
|
spawnCalls.length = 0;
|
||||||
vi.resetModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use agent-specific workspaceRoot", async () => {
|
it("should use agent-specific workspaceRoot", async () => {
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ const installRegistry = async () => {
|
|||||||
describe("resolveAnnounceTarget", () => {
|
describe("resolveAnnounceTarget", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
vi.resetModules();
|
|
||||||
await installRegistry();
|
await installRegistry();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
|
||||||
|
|
||||||
|
const connectOverCdpMock = vi.fn();
|
||||||
|
const getChromeWebSocketUrlMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("playwright-core", () => ({
|
||||||
|
chromium: {
|
||||||
|
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./chrome.js", () => ({
|
||||||
|
getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("pw-session getPageForTargetId", () => {
|
describe("pw-session getPageForTargetId", () => {
|
||||||
it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => {
|
it("falls back to the only page when CDP session attachment is blocked (extension relays)", async () => {
|
||||||
vi.resetModules();
|
connectOverCdpMock.mockReset();
|
||||||
|
getChromeWebSocketUrlMock.mockReset();
|
||||||
|
|
||||||
const pageOn = vi.fn();
|
const pageOn = vi.fn();
|
||||||
const contextOn = vi.fn();
|
const contextOn = vi.fn();
|
||||||
@@ -31,24 +46,16 @@ describe("pw-session getPageForTargetId", () => {
|
|||||||
close: browserClose,
|
close: browserClose,
|
||||||
} as unknown as import("playwright-core").Browser;
|
} as unknown as import("playwright-core").Browser;
|
||||||
|
|
||||||
vi.doMock("playwright-core", () => ({
|
connectOverCdpMock.mockResolvedValue(browser);
|
||||||
chromium: {
|
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||||
connectOverCDP: vi.fn(async () => browser),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("./chrome.js", () => ({
|
const resolved = await getPageForTargetId({
|
||||||
getChromeWebSocketUrl: vi.fn(async () => null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mod = await import("./pw-session.js");
|
|
||||||
const resolved = await mod.getPageForTargetId({
|
|
||||||
cdpUrl: "http://127.0.0.1:18792",
|
cdpUrl: "http://127.0.0.1:18792",
|
||||||
targetId: "NOT_A_TAB",
|
targetId: "NOT_A_TAB",
|
||||||
});
|
});
|
||||||
expect(resolved).toBe(page);
|
expect(resolved).toBe(page);
|
||||||
|
|
||||||
await mod.closePlaywrightBrowserConnection();
|
await closePlaywrightBrowserConnection();
|
||||||
expect(browserClose).toHaveBeenCalled();
|
expect(browserClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,14 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
|||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
const workspace = path.join(stateDir, "openclaw");
|
const workspace = path.join(stateDir, "openclaw");
|
||||||
|
|
||||||
// Other test files mock ../config/config.js. This onboarding flow needs the real
|
|
||||||
// implementation so it can persist the config and then read it back (Windows CI
|
|
||||||
// otherwise sees a mocked writeConfigFile and the config never lands on disk).
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("../config/config.js", async () => {
|
|
||||||
return await vi.importActual("../config/config.js");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||||
await runNonInteractiveOnboarding(
|
await runNonInteractiveOnboarding(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,21 +2,28 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ensureTailscaleEndpoint,
|
||||||
|
resetGmailSetupUtilsCachesForTest,
|
||||||
|
resolvePythonExecutablePath,
|
||||||
|
} from "./gmail-setup-utils.js";
|
||||||
|
|
||||||
const itUnix = process.platform === "win32" ? it.skip : it;
|
const itUnix = process.platform === "win32" ? it.skip : it;
|
||||||
|
const runCommandWithTimeoutMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../process/exec.js", () => ({
|
||||||
|
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
runCommandWithTimeoutMock.mockReset();
|
||||||
|
resetGmailSetupUtilsCachesForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolvePythonExecutablePath", () => {
|
describe("resolvePythonExecutablePath", () => {
|
||||||
itUnix(
|
itUnix(
|
||||||
"resolves a working python path and caches the result",
|
"resolves a working python path and caches the result",
|
||||||
async () => {
|
async () => {
|
||||||
vi.doMock("../process/exec.js", () => ({
|
|
||||||
runCommandWithTimeout: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-python-"));
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-python-"));
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
try {
|
try {
|
||||||
@@ -32,10 +39,7 @@ describe("resolvePythonExecutablePath", () => {
|
|||||||
|
|
||||||
process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`;
|
process.env.PATH = `${shimDir}${path.delimiter}/usr/bin`;
|
||||||
|
|
||||||
const { resolvePythonExecutablePath } = await import("./gmail-setup-utils.js");
|
runCommandWithTimeoutMock.mockResolvedValue({
|
||||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
|
||||||
const runCommand = vi.mocked(runCommandWithTimeout);
|
|
||||||
runCommand.mockResolvedValue({
|
|
||||||
stdout: `${realPython}\n`,
|
stdout: `${realPython}\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: 0,
|
code: 0,
|
||||||
@@ -49,7 +53,7 @@ describe("resolvePythonExecutablePath", () => {
|
|||||||
process.env.PATH = "/bin";
|
process.env.PATH = "/bin";
|
||||||
const cached = await resolvePythonExecutablePath();
|
const cached = await resolvePythonExecutablePath();
|
||||||
expect(cached).toBe(realPython);
|
expect(cached).toBe(realPython);
|
||||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
await fs.rm(tmp, { recursive: true, force: true });
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
@@ -61,15 +65,7 @@ describe("resolvePythonExecutablePath", () => {
|
|||||||
|
|
||||||
describe("ensureTailscaleEndpoint", () => {
|
describe("ensureTailscaleEndpoint", () => {
|
||||||
it("includes stdout and exit code when tailscale serve fails", async () => {
|
it("includes stdout and exit code when tailscale serve fails", async () => {
|
||||||
vi.doMock("../process/exec.js", () => ({
|
runCommandWithTimeoutMock
|
||||||
runCommandWithTimeout: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureTailscaleEndpoint } = await import("./gmail-setup-utils.js");
|
|
||||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
|
||||||
const runCommand = vi.mocked(runCommandWithTimeout);
|
|
||||||
|
|
||||||
runCommand
|
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net." } }),
|
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net." } }),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@@ -102,15 +98,7 @@ describe("ensureTailscaleEndpoint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes JSON parse failure details with stdout", async () => {
|
it("includes JSON parse failure details with stdout", async () => {
|
||||||
vi.doMock("../process/exec.js", () => ({
|
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||||
runCommandWithTimeout: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ensureTailscaleEndpoint } = await import("./gmail-setup-utils.js");
|
|
||||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
|
||||||
const runCommand = vi.mocked(runCommandWithTimeout);
|
|
||||||
|
|
||||||
runCommand.mockResolvedValueOnce({
|
|
||||||
stdout: "not-json",
|
stdout: "not-json",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: 0,
|
code: 0,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { normalizeServePath } from "./gmail.js";
|
|||||||
let cachedPythonPath: string | null | undefined;
|
let cachedPythonPath: string | null | undefined;
|
||||||
const MAX_OUTPUT_CHARS = 800;
|
const MAX_OUTPUT_CHARS = 800;
|
||||||
|
|
||||||
|
export function resetGmailSetupUtilsCachesForTest(): void {
|
||||||
|
cachedPythonPath = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function trimOutput(value: string): string {
|
function trimOutput(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
+45
-39
@@ -1,49 +1,32 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ResolvedIMessageAccount } from "./accounts.js";
|
||||||
const loadSendMessageIMessage = async () => await import("./send.js");
|
import { sendMessageIMessage } from "./send.js";
|
||||||
|
|
||||||
const requestMock = vi.fn();
|
const requestMock = vi.fn();
|
||||||
const stopMock = vi.fn();
|
const stopMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../config/config.js", async (importOriginal) => {
|
const defaultAccount: ResolvedIMessageAccount = {
|
||||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
accountId: "default",
|
||||||
return {
|
enabled: true,
|
||||||
...actual,
|
configured: false,
|
||||||
loadConfig: () => ({}),
|
config: {},
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./client.js", () => ({
|
|
||||||
createIMessageRpcClient: vi.fn().mockResolvedValue({
|
|
||||||
request: (...args: unknown[]) => requestMock(...args),
|
|
||||||
stop: (...args: unknown[]) => stopMock(...args),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../web/media.js", () => ({
|
|
||||||
loadWebMedia: vi.fn().mockResolvedValue({
|
|
||||||
buffer: Buffer.from("data"),
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../media/store.js", () => ({
|
|
||||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
|
||||||
path: "/tmp/imessage-media.jpg",
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("sendMessageIMessage", () => {
|
describe("sendMessageIMessage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
requestMock.mockReset().mockResolvedValue({ ok: true });
|
requestMock.mockReset().mockResolvedValue({ ok: true });
|
||||||
stopMock.mockReset().mockResolvedValue(undefined);
|
stopMock.mockReset().mockResolvedValue(undefined);
|
||||||
vi.resetModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends to chat_id targets", async () => {
|
it("sends to chat_id targets", async () => {
|
||||||
const { sendMessageIMessage } = await loadSendMessageIMessage();
|
await sendMessageIMessage("chat_id:123", "hi", {
|
||||||
await sendMessageIMessage("chat_id:123", "hi");
|
account: defaultAccount,
|
||||||
|
config: {},
|
||||||
|
client: {
|
||||||
|
request: (...args: unknown[]) => requestMock(...args),
|
||||||
|
stop: (...args: unknown[]) => stopMock(...args),
|
||||||
|
} as unknown as import("./client.js").IMessageRpcClient,
|
||||||
|
});
|
||||||
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object));
|
expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object));
|
||||||
expect(params.chat_id).toBe(123);
|
expect(params.chat_id).toBe(123);
|
||||||
@@ -51,16 +34,33 @@ describe("sendMessageIMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies sms service prefix", async () => {
|
it("applies sms service prefix", async () => {
|
||||||
const { sendMessageIMessage } = await loadSendMessageIMessage();
|
await sendMessageIMessage("sms:+1555", "hello", {
|
||||||
await sendMessageIMessage("sms:+1555", "hello");
|
account: defaultAccount,
|
||||||
|
config: {},
|
||||||
|
client: {
|
||||||
|
request: (...args: unknown[]) => requestMock(...args),
|
||||||
|
stop: (...args: unknown[]) => stopMock(...args),
|
||||||
|
} as unknown as import("./client.js").IMessageRpcClient,
|
||||||
|
});
|
||||||
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
expect(params.service).toBe("sms");
|
expect(params.service).toBe("sms");
|
||||||
expect(params.to).toBe("+1555");
|
expect(params.to).toBe("+1555");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds file attachment with placeholder text", async () => {
|
it("adds file attachment with placeholder text", async () => {
|
||||||
const { sendMessageIMessage } = await loadSendMessageIMessage();
|
await sendMessageIMessage("chat_id:7", "", {
|
||||||
await sendMessageIMessage("chat_id:7", "", { mediaUrl: "http://x/y.jpg" });
|
mediaUrl: "http://x/y.jpg",
|
||||||
|
account: defaultAccount,
|
||||||
|
config: {},
|
||||||
|
resolveAttachmentImpl: async () => ({
|
||||||
|
path: "/tmp/imessage-media.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
}),
|
||||||
|
client: {
|
||||||
|
request: (...args: unknown[]) => requestMock(...args),
|
||||||
|
stop: (...args: unknown[]) => stopMock(...args),
|
||||||
|
} as unknown as import("./client.js").IMessageRpcClient,
|
||||||
|
});
|
||||||
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
const params = requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
||||||
expect(params.text).toBe("<media:image>");
|
expect(params.text).toBe("<media:image>");
|
||||||
@@ -68,8 +68,14 @@ describe("sendMessageIMessage", () => {
|
|||||||
|
|
||||||
it("returns message id when rpc provides one", async () => {
|
it("returns message id when rpc provides one", async () => {
|
||||||
requestMock.mockResolvedValue({ ok: true, id: 123 });
|
requestMock.mockResolvedValue({ ok: true, id: 123 });
|
||||||
const { sendMessageIMessage } = await loadSendMessageIMessage();
|
const result = await sendMessageIMessage("chat_id:7", "hello", {
|
||||||
const result = await sendMessageIMessage("chat_id:7", "hello");
|
account: defaultAccount,
|
||||||
|
config: {},
|
||||||
|
client: {
|
||||||
|
request: (...args: unknown[]) => requestMock(...args),
|
||||||
|
stop: (...args: unknown[]) => stopMock(...args),
|
||||||
|
} as unknown as import("./client.js").IMessageRpcClient,
|
||||||
|
});
|
||||||
expect(result.messageId).toBe("123");
|
expect(result.messageId).toBe("123");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+22
-8
@@ -4,7 +4,7 @@ import { convertMarkdownTables } from "../markdown/tables.js";
|
|||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveIMessageAccount } from "./accounts.js";
|
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||||
|
|
||||||
@@ -19,6 +19,13 @@ export type IMessageSendOpts = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
client?: IMessageRpcClient;
|
client?: IMessageRpcClient;
|
||||||
|
config?: ReturnType<typeof loadConfig>;
|
||||||
|
account?: ResolvedIMessageAccount;
|
||||||
|
resolveAttachmentImpl?: (
|
||||||
|
mediaUrl: string,
|
||||||
|
maxBytes: number,
|
||||||
|
) => Promise<{ path: string; contentType?: string }>;
|
||||||
|
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IMessageSendResult = {
|
export type IMessageSendResult = {
|
||||||
@@ -58,11 +65,13 @@ export async function sendMessageIMessage(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: IMessageSendOpts = {},
|
opts: IMessageSendOpts = {},
|
||||||
): Promise<IMessageSendResult> {
|
): Promise<IMessageSendResult> {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const account = resolveIMessageAccount({
|
const account =
|
||||||
cfg,
|
opts.account ??
|
||||||
accountId: opts.accountId,
|
resolveIMessageAccount({
|
||||||
});
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
||||||
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
||||||
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
|
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
|
||||||
@@ -81,7 +90,8 @@ export async function sendMessageIMessage(
|
|||||||
let filePath: string | undefined;
|
let filePath: string | undefined;
|
||||||
|
|
||||||
if (opts.mediaUrl?.trim()) {
|
if (opts.mediaUrl?.trim()) {
|
||||||
const resolved = await resolveAttachment(opts.mediaUrl.trim(), maxBytes);
|
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveAttachment;
|
||||||
|
const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes);
|
||||||
filePath = resolved.path;
|
filePath = resolved.path;
|
||||||
if (!message.trim()) {
|
if (!message.trim()) {
|
||||||
const kind = mediaKindFromMime(resolved.contentType ?? undefined);
|
const kind = mediaKindFromMime(resolved.contentType ?? undefined);
|
||||||
@@ -122,7 +132,11 @@ export async function sendMessageIMessage(
|
|||||||
params.to = target.to;
|
params.to = target.to;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath }));
|
const client =
|
||||||
|
opts.client ??
|
||||||
|
(opts.createClient
|
||||||
|
? await opts.createClient({ cliPath, dbPath })
|
||||||
|
: await createIMessageRpcClient({ cliPath, dbPath }));
|
||||||
const shouldClose = !opts.client;
|
const shouldClose = !opts.client;
|
||||||
try {
|
try {
|
||||||
const result = await client.request<{ ok?: string }>("send", params, {
|
const result = await client.request<{ ok?: string }>("send", params, {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getResolvedConsoleSettings,
|
getResolvedConsoleSettings,
|
||||||
routeLogsToStderr,
|
routeLogsToStderr,
|
||||||
setConsoleSubsystemFilter,
|
setConsoleSubsystemFilter,
|
||||||
|
setConsoleConfigLoaderForTests,
|
||||||
setConsoleTimestampPrefix,
|
setConsoleTimestampPrefix,
|
||||||
shouldLogSubsystemToConsole,
|
shouldLogSubsystemToConsole,
|
||||||
} from "./logging/console.js";
|
} from "./logging/console.js";
|
||||||
@@ -36,6 +37,7 @@ export {
|
|||||||
getResolvedConsoleSettings,
|
getResolvedConsoleSettings,
|
||||||
routeLogsToStderr,
|
routeLogsToStderr,
|
||||||
setConsoleSubsystemFilter,
|
setConsoleSubsystemFilter,
|
||||||
|
setConsoleConfigLoaderForTests,
|
||||||
setConsoleTimestampPrefix,
|
setConsoleTimestampPrefix,
|
||||||
shouldLogSubsystemToConsole,
|
shouldLogSubsystemToConsole,
|
||||||
ALLOWED_LOG_LEVELS,
|
ALLOWED_LOG_LEVELS,
|
||||||
|
|||||||
@@ -16,29 +16,6 @@ vi.mock("./logger.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let loadConfigCalls = 0;
|
let loadConfigCalls = 0;
|
||||||
vi.mock("node:module", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("node:module")>("node:module");
|
|
||||||
return Object.assign({}, actual, {
|
|
||||||
createRequire: (url: string | URL) => {
|
|
||||||
const realRequire = actual.createRequire(url);
|
|
||||||
return (specifier: string) => {
|
|
||||||
if (specifier.endsWith("config.js")) {
|
|
||||||
return {
|
|
||||||
loadConfig: () => {
|
|
||||||
loadConfigCalls += 1;
|
|
||||||
if (loadConfigCalls > 5) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
console.error("config load failed");
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return realRequire(specifier);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
type ConsoleSnapshot = {
|
type ConsoleSnapshot = {
|
||||||
log: typeof console.log;
|
log: typeof console.log;
|
||||||
info: typeof console.info;
|
info: typeof console.info;
|
||||||
@@ -53,7 +30,6 @@ let snapshot: ConsoleSnapshot;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfigCalls = 0;
|
loadConfigCalls = 0;
|
||||||
vi.resetModules();
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
info: console.info,
|
info: console.info,
|
||||||
@@ -66,7 +42,7 @@ beforeEach(() => {
|
|||||||
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
console.log = snapshot.log;
|
console.log = snapshot.log;
|
||||||
console.info = snapshot.info;
|
console.info = snapshot.info;
|
||||||
console.warn = snapshot.warn;
|
console.warn = snapshot.warn;
|
||||||
@@ -74,6 +50,8 @@ afterEach(() => {
|
|||||||
console.debug = snapshot.debug;
|
console.debug = snapshot.debug;
|
||||||
console.trace = snapshot.trace;
|
console.trace = snapshot.trace;
|
||||||
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
|
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
|
||||||
|
const logging = await import("../logging.js");
|
||||||
|
logging.setConsoleConfigLoaderForTests();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +59,14 @@ async function loadLogging() {
|
|||||||
const logging = await import("../logging.js");
|
const logging = await import("../logging.js");
|
||||||
const state = await import("./state.js");
|
const state = await import("./state.js");
|
||||||
state.loggingState.cachedConsoleSettings = null;
|
state.loggingState.cachedConsoleSettings = null;
|
||||||
|
logging.setConsoleConfigLoaderForTests(() => {
|
||||||
|
loadConfigCalls += 1;
|
||||||
|
if (loadConfigCalls > 5) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
console.error("config load failed");
|
||||||
|
return {};
|
||||||
|
});
|
||||||
return { logging, state };
|
return { logging, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -16,6 +16,22 @@ type ConsoleSettings = {
|
|||||||
export type ConsoleLoggerSettings = ConsoleSettings;
|
export type ConsoleLoggerSettings = ConsoleSettings;
|
||||||
|
|
||||||
const requireConfig = createRequire(import.meta.url);
|
const requireConfig = createRequire(import.meta.url);
|
||||||
|
type ConsoleConfigLoader = () => OpenClawConfig["logging"] | undefined;
|
||||||
|
const loadConfigFallbackDefault: ConsoleConfigLoader = () => {
|
||||||
|
try {
|
||||||
|
const loaded = requireConfig("../config/config.js") as {
|
||||||
|
loadConfig?: () => OpenClawConfig;
|
||||||
|
};
|
||||||
|
return loaded.loadConfig?.().logging;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let loadConfigFallback: ConsoleConfigLoader = loadConfigFallbackDefault;
|
||||||
|
|
||||||
|
export function setConsoleConfigLoaderForTests(loader?: ConsoleConfigLoader): void {
|
||||||
|
loadConfigFallback = loader ?? loadConfigFallbackDefault;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConsoleLevel(level?: string): LogLevel {
|
function normalizeConsoleLevel(level?: string): LogLevel {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
@@ -43,12 +59,7 @@ function resolveConsoleSettings(): ConsoleSettings {
|
|||||||
} else {
|
} else {
|
||||||
loggingState.resolvingConsoleSettings = true;
|
loggingState.resolvingConsoleSettings = true;
|
||||||
try {
|
try {
|
||||||
const loaded = requireConfig("../config/config.js") as {
|
cfg = loadConfigFallback();
|
||||||
loadConfig?: () => OpenClawConfig;
|
|
||||||
};
|
|
||||||
cfg = loaded.loadConfig?.().logging;
|
|
||||||
} catch {
|
|
||||||
cfg = undefined;
|
|
||||||
} finally {
|
} finally {
|
||||||
loggingState.resolvingConsoleSettings = false;
|
loggingState.resolvingConsoleSettings = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,44 @@
|
|||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { saveMediaSource, setMediaStoreNetworkDepsForTest } from "./store.js";
|
||||||
|
|
||||||
const realOs = await vi.importActual<typeof import("node:os")>("node:os");
|
const HOME = path.join(os.tmpdir(), "openclaw-home-redirect");
|
||||||
const HOME = path.join(realOs.tmpdir(), "openclaw-home-redirect");
|
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||||
const mockRequest = vi.fn();
|
const mockRequest = vi.fn();
|
||||||
|
|
||||||
vi.doMock("node:os", () => ({
|
|
||||||
default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() },
|
|
||||||
homedir: () => HOME,
|
|
||||||
tmpdir: () => realOs.tmpdir(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.doMock("node:https", () => ({
|
|
||||||
request: (...args: unknown[]) => mockRequest(...args),
|
|
||||||
}));
|
|
||||||
vi.doMock("node:dns/promises", () => ({
|
|
||||||
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const loadStore = async () => await import("./store.js");
|
|
||||||
|
|
||||||
describe("media store redirects", () => {
|
describe("media store redirects", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
|
process.env.OPENCLAW_STATE_DIR = HOME;
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRequest.mockReset();
|
mockRequest.mockReset();
|
||||||
vi.resetModules();
|
setMediaStoreNetworkDepsForTest({
|
||||||
|
httpRequest: (...args) => mockRequest(...args),
|
||||||
|
httpsRequest: (...args) => mockRequest(...args),
|
||||||
|
resolvePinnedHostname: async () => ({
|
||||||
|
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await fs.rm(HOME, { recursive: true, force: true });
|
await fs.rm(HOME, { recursive: true, force: true });
|
||||||
|
if (previousStateDir === undefined) {
|
||||||
|
delete process.env.OPENCLAW_STATE_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||||
|
}
|
||||||
|
setMediaStoreNetworkDepsForTest();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("follows redirects and keeps detected mime/extension", async () => {
|
it("follows redirects and keeps detected mime/extension", async () => {
|
||||||
const { saveMediaSource } = await loadStore();
|
|
||||||
let call = 0;
|
let call = 0;
|
||||||
mockRequest.mockImplementation((_url, _opts, cb) => {
|
mockRequest.mockImplementation((_url, _opts, cb) => {
|
||||||
call += 1;
|
call += 1;
|
||||||
@@ -84,7 +83,6 @@ describe("media store redirects", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sniffs xlsx from zip content when headers and url extension are missing", async () => {
|
it("sniffs xlsx from zip content when headers and url extension are missing", async () => {
|
||||||
const { saveMediaSource } = await loadStore();
|
|
||||||
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
mockRequest.mockImplementationOnce((_url, _opts, cb) => {
|
||||||
const res = new PassThrough();
|
const res = new PassThrough();
|
||||||
const req = {
|
const req = {
|
||||||
|
|||||||
+22
-2
@@ -13,6 +13,26 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
|
|||||||
export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
export const MEDIA_MAX_BYTES = 5 * 1024 * 1024; // 5MB default
|
||||||
const MAX_BYTES = MEDIA_MAX_BYTES;
|
const MAX_BYTES = MEDIA_MAX_BYTES;
|
||||||
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
type RequestImpl = typeof httpRequest;
|
||||||
|
type ResolvePinnedHostnameImpl = typeof resolvePinnedHostname;
|
||||||
|
|
||||||
|
const defaultHttpRequestImpl: RequestImpl = httpRequest;
|
||||||
|
const defaultHttpsRequestImpl: RequestImpl = httpsRequest;
|
||||||
|
const defaultResolvePinnedHostnameImpl: ResolvePinnedHostnameImpl = resolvePinnedHostname;
|
||||||
|
|
||||||
|
let httpRequestImpl: RequestImpl = defaultHttpRequestImpl;
|
||||||
|
let httpsRequestImpl: RequestImpl = defaultHttpsRequestImpl;
|
||||||
|
let resolvePinnedHostnameImpl: ResolvePinnedHostnameImpl = defaultResolvePinnedHostnameImpl;
|
||||||
|
|
||||||
|
export function setMediaStoreNetworkDepsForTest(deps?: {
|
||||||
|
httpRequest?: RequestImpl;
|
||||||
|
httpsRequest?: RequestImpl;
|
||||||
|
resolvePinnedHostname?: ResolvePinnedHostnameImpl;
|
||||||
|
}): void {
|
||||||
|
httpRequestImpl = deps?.httpRequest ?? defaultHttpRequestImpl;
|
||||||
|
httpsRequestImpl = deps?.httpsRequest ?? defaultHttpsRequestImpl;
|
||||||
|
resolvePinnedHostnameImpl = deps?.resolvePinnedHostname ?? defaultResolvePinnedHostnameImpl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize a filename for cross-platform safety.
|
* Sanitize a filename for cross-platform safety.
|
||||||
@@ -107,8 +127,8 @@ async function downloadToFile(
|
|||||||
reject(new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`));
|
reject(new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestImpl = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
|
const requestImpl = parsedUrl.protocol === "https:" ? httpsRequestImpl : httpRequestImpl;
|
||||||
resolvePinnedHostname(parsedUrl.hostname)
|
resolvePinnedHostnameImpl(parsedUrl.hostname)
|
||||||
.then((pinned) => {
|
.then((pinned) => {
|
||||||
const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => {
|
const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => {
|
||||||
// Follow redirects
|
// Follow redirects
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
const loadJsonFile = vi.fn();
|
deriveCopilotApiBaseUrlFromToken,
|
||||||
const saveJsonFile = vi.fn();
|
resolveCopilotApiToken,
|
||||||
const resolveStateDir = vi.fn().mockReturnValue("/tmp/openclaw-state");
|
} from "./github-copilot-token.js";
|
||||||
|
|
||||||
vi.mock("../infra/json-file.js", () => ({
|
|
||||||
loadJsonFile,
|
|
||||||
saveJsonFile,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../config/paths.js", () => ({
|
|
||||||
resolveStateDir,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("github-copilot token", () => {
|
describe("github-copilot token", () => {
|
||||||
|
const loadJsonFile = vi.fn();
|
||||||
|
const saveJsonFile = vi.fn();
|
||||||
|
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
|
||||||
loadJsonFile.mockReset();
|
loadJsonFile.mockReset();
|
||||||
saveJsonFile.mockReset();
|
saveJsonFile.mockReset();
|
||||||
resolveStateDir.mockReset();
|
|
||||||
resolveStateDir.mockReturnValue("/tmp/openclaw-state");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives baseUrl from token", async () => {
|
it("derives baseUrl from token", async () => {
|
||||||
const { deriveCopilotApiBaseUrlFromToken } = await import("./github-copilot-token.js");
|
|
||||||
|
|
||||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||||
"https://api.example.com",
|
"https://api.example.com",
|
||||||
);
|
);
|
||||||
@@ -41,11 +31,12 @@ describe("github-copilot token", () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { resolveCopilotApiToken } = await import("./github-copilot-token.js");
|
|
||||||
|
|
||||||
const fetchImpl = vi.fn();
|
const fetchImpl = vi.fn();
|
||||||
const res = await resolveCopilotApiToken({
|
const res = await resolveCopilotApiToken({
|
||||||
githubToken: "gh",
|
githubToken: "gh",
|
||||||
|
cachePath,
|
||||||
|
loadJsonFileImpl: loadJsonFile,
|
||||||
|
saveJsonFileImpl: saveJsonFile,
|
||||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +62,9 @@ describe("github-copilot token", () => {
|
|||||||
|
|
||||||
const res = await resolveCopilotApiToken({
|
const res = await resolveCopilotApiToken({
|
||||||
githubToken: "gh",
|
githubToken: "gh",
|
||||||
|
cachePath,
|
||||||
|
loadJsonFileImpl: loadJsonFile,
|
||||||
|
saveJsonFileImpl: saveJsonFile,
|
||||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export async function resolveCopilotApiToken(params: {
|
|||||||
githubToken: string;
|
githubToken: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
|
cachePath?: string;
|
||||||
|
loadJsonFileImpl?: (path: string) => unknown;
|
||||||
|
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
token: string;
|
token: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -89,8 +92,10 @@ export async function resolveCopilotApiToken(params: {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}> {
|
}> {
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const cachePath = resolveCopilotTokenCachePath(env);
|
const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env);
|
||||||
const cached = loadJsonFile(cachePath) as CachedCopilotToken | undefined;
|
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile;
|
||||||
|
const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile;
|
||||||
|
const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined;
|
||||||
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
|
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
|
||||||
if (isTokenUsable(cached)) {
|
if (isTokenUsable(cached)) {
|
||||||
return {
|
return {
|
||||||
@@ -121,7 +126,7 @@ export async function resolveCopilotApiToken(params: {
|
|||||||
expiresAt: json.expiresAt,
|
expiresAt: json.expiresAt,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
saveJsonFile(cachePath, payload);
|
saveJsonFileFn(cachePath, payload);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: payload.token,
|
token: payload.token,
|
||||||
|
|||||||
Reference in New Issue
Block a user