mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 15:51:06 +00:00
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const controlServiceMocks = vi.hoisted(() => ({
|
|
createBrowserControlContext: vi.fn(() => ({ control: true })),
|
|
startBrowserControlServiceFromConfig: vi.fn(async () => true),
|
|
}));
|
|
|
|
const dispatcherMocks = vi.hoisted(() => ({
|
|
dispatch: vi.fn(),
|
|
createBrowserRouteDispatcher: vi.fn(() => ({
|
|
dispatch: dispatcherMocks.dispatch,
|
|
})),
|
|
}));
|
|
|
|
const configMocks = vi.hoisted(() => ({
|
|
loadConfig: vi.fn(() => ({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
|
})),
|
|
}));
|
|
|
|
const browserConfigMocks = vi.hoisted(() => ({
|
|
resolveBrowserConfig: vi.fn(() => ({
|
|
enabled: true,
|
|
defaultProfile: "openclaw",
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../core-api.js", () => ({
|
|
createBrowserControlContext: controlServiceMocks.createBrowserControlContext,
|
|
createBrowserRouteDispatcher: dispatcherMocks.createBrowserRouteDispatcher,
|
|
detectMime: vi.fn(async () => "image/png"),
|
|
isPersistentBrowserProfileMutation: vi.fn((method: string, path: string) => {
|
|
if (method === "POST" && (path === "/profiles/create" || path === "/reset-profile")) {
|
|
return true;
|
|
}
|
|
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(path);
|
|
}),
|
|
loadConfig: configMocks.loadConfig,
|
|
normalizeBrowserRequestPath: vi.fn((path: string) => path),
|
|
redactCdpUrl: vi.fn((url: string) => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
const normalized = parsed.toString().replace(/\/$/, "");
|
|
const token = parsed.searchParams.get("token");
|
|
if (!token || token.length <= 8) {
|
|
return normalized;
|
|
}
|
|
return normalized.replace(token, `${token.slice(0, 6)}…${token.slice(-4)}`);
|
|
} catch {
|
|
return url;
|
|
}
|
|
}),
|
|
resolveBrowserConfig: browserConfigMocks.resolveBrowserConfig,
|
|
resolveRequestedBrowserProfile: vi.fn(
|
|
({
|
|
query,
|
|
body,
|
|
profile,
|
|
}: {
|
|
query?: Record<string, unknown>;
|
|
body?: unknown;
|
|
profile?: string;
|
|
}) => {
|
|
if (query && typeof query.profile === "string" && query.profile.trim()) {
|
|
return query.profile.trim();
|
|
}
|
|
const bodyProfile =
|
|
body && typeof body === "object" ? (body as { profile?: unknown }).profile : undefined;
|
|
if (typeof bodyProfile === "string" && bodyProfile.trim()) {
|
|
return bodyProfile.trim();
|
|
}
|
|
return typeof profile === "string" && profile.trim() ? profile.trim() : undefined;
|
|
},
|
|
),
|
|
startBrowserControlServiceFromConfig: controlServiceMocks.startBrowserControlServiceFromConfig,
|
|
withTimeout: vi.fn(
|
|
async (
|
|
run: (signal: AbortSignal | undefined) => Promise<unknown>,
|
|
timeoutMs?: number,
|
|
label?: string,
|
|
) => {
|
|
const resolved =
|
|
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
|
? Math.max(1, Math.floor(timeoutMs))
|
|
: undefined;
|
|
if (!resolved) {
|
|
return await run(undefined);
|
|
}
|
|
const abortCtrl = new AbortController();
|
|
const timeoutError = new Error(`${label ?? "request"} timed out`);
|
|
const timer = setTimeout(() => abortCtrl.abort(timeoutError), resolved);
|
|
try {
|
|
return await Promise.race([
|
|
run(abortCtrl.signal),
|
|
new Promise<never>((_, reject) => {
|
|
abortCtrl.signal.addEventListener(
|
|
"abort",
|
|
() => reject(abortCtrl.signal.reason ?? timeoutError),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
},
|
|
),
|
|
}));
|
|
|
|
let resetBrowserProxyCommandStateForTests: typeof import("./invoke-browser.js").resetBrowserProxyCommandStateForTests;
|
|
let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand;
|
|
|
|
beforeAll(async () => {
|
|
({ resetBrowserProxyCommandStateForTests, runBrowserProxyCommand } =
|
|
await import("./invoke-browser.js"));
|
|
});
|
|
|
|
describe("runBrowserProxyCommand", () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
resetBrowserProxyCommandStateForTests();
|
|
dispatcherMocks.dispatch.mockReset();
|
|
dispatcherMocks.createBrowserRouteDispatcher.mockReset().mockImplementation(() => ({
|
|
dispatch: dispatcherMocks.dispatch,
|
|
}));
|
|
controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true });
|
|
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
|
|
configMocks.loadConfig.mockReset().mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
|
});
|
|
browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({
|
|
enabled: true,
|
|
defaultProfile: "openclaw",
|
|
});
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
|
|
});
|
|
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
|
|
enabled: true,
|
|
defaultProfile: "openclaw",
|
|
});
|
|
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
|
|
});
|
|
|
|
it("adds profile and browser status details on ws-backed timeouts", async () => {
|
|
vi.useFakeTimers();
|
|
dispatcherMocks.dispatch
|
|
.mockImplementationOnce(async () => {
|
|
await new Promise(() => {});
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: {
|
|
running: true,
|
|
cdpHttp: true,
|
|
cdpReady: false,
|
|
cdpUrl: "http://127.0.0.1:18792",
|
|
},
|
|
});
|
|
|
|
const result = expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
profile: "openclaw",
|
|
timeoutMs: 5,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await result;
|
|
});
|
|
|
|
it("includes chrome-mcp transport in timeout diagnostics when no CDP URL exists", async () => {
|
|
vi.useFakeTimers();
|
|
dispatcherMocks.dispatch
|
|
.mockImplementationOnce(async () => {
|
|
await new Promise(() => {});
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: {
|
|
running: true,
|
|
transport: "chrome-mcp",
|
|
cdpHttp: true,
|
|
cdpReady: false,
|
|
cdpUrl: null,
|
|
},
|
|
});
|
|
|
|
const result = expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
profile: "user",
|
|
timeoutMs: 5,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=user; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await result;
|
|
});
|
|
|
|
it("redacts sensitive cdpUrl details in timeout diagnostics", async () => {
|
|
vi.useFakeTimers();
|
|
dispatcherMocks.dispatch
|
|
.mockImplementationOnce(async () => {
|
|
await new Promise(() => {});
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: {
|
|
running: true,
|
|
cdpHttp: true,
|
|
cdpReady: false,
|
|
cdpUrl:
|
|
"https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890",
|
|
},
|
|
});
|
|
|
|
const result = expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
profile: "remote",
|
|
timeoutMs: 5,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
/status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=https:\/\/example\.com\/chrome\?token=supers…7890\)/,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await result;
|
|
});
|
|
|
|
it("keeps non-timeout browser errors intact", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 500,
|
|
body: { error: "tab not found" },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/act",
|
|
profile: "openclaw",
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow("tab not found");
|
|
});
|
|
|
|
it("rejects unauthorized query.profile when allowProfiles is configured", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
query: { profile: "user" },
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow("INVALID_REQUEST: browser profile not allowed");
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects unauthorized body.profile when allowProfiles is configured", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/stop",
|
|
body: { profile: "user" },
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow("INVALID_REQUEST: browser profile not allowed");
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects persistent profile creation when allowProfiles is configured", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/profiles/create",
|
|
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
|
);
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects persistent profile deletion when allowProfiles is configured", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "DELETE",
|
|
path: "/profiles/poc",
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
|
);
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects persistent profile reset when allowProfiles is configured", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/reset-profile",
|
|
body: { profile: "openclaw", name: "openclaw" },
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow(
|
|
"INVALID_REQUEST: browser.proxy cannot mutate persistent browser profiles when allowProfiles is configured",
|
|
);
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("canonicalizes an allowlisted body profile into the dispatched query", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
|
|
});
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 200,
|
|
body: { ok: true },
|
|
});
|
|
|
|
await runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/stop",
|
|
body: { profile: "openclaw" },
|
|
timeoutMs: 50,
|
|
}),
|
|
);
|
|
|
|
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
path: "/stop",
|
|
query: { profile: "openclaw" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves legacy proxy behavior when allowProfiles is empty", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 200,
|
|
body: { ok: true },
|
|
});
|
|
|
|
await runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/profiles/create",
|
|
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
|
timeoutMs: 50,
|
|
}),
|
|
);
|
|
|
|
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
path: "/profiles/create",
|
|
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
|
|
}),
|
|
);
|
|
});
|
|
});
|