mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 10:31:16 +00:00
* refactor(browser): collapse Playwright export paths * refactor(browser): remove dead plugin exports * refactor(codex): remove dead app-server exports * refactor(codex): remove remaining dead exports * test(codex): use canonical private-type owners * test(browser): isolate proxy startup state * test(browser): remove stale chrome imports * refactor(codex): privatize remaining helpers * chore(deadcode): refresh export baseline after rebase * refactor(browser): finish canonical helper ownership * refactor: fix dead-export cleanup gates * refactor(codex): keep runtime facades LOC-neutral * chore(ci): refresh TypeScript LOC baseline * chore(deadcode): refresh ratchets after rebase * chore(ci): refresh LOC baseline after main advance * chore(deadcode): align ratchets with latest main
694 lines
22 KiB
TypeScript
694 lines
22 KiB
TypeScript
// Browser tests cover invoke browser plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import nodePath from "node:path";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { BROWSER_PROXY_MAX_FILE_BYTES } from "../browser-proxy-envelope.js";
|
|
|
|
const BROWSER_PROXY_MAX_FILES = 256;
|
|
const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024;
|
|
|
|
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[] } },
|
|
})),
|
|
sourceConfig: null as Record<string, unknown> | null,
|
|
}));
|
|
|
|
const browserConfigMocks = vi.hoisted(() => ({
|
|
resolveBrowserConfig: vi.fn((browser?: { defaultProfile?: string }) => ({
|
|
enabled: true,
|
|
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../sdk-config.js", () => ({
|
|
getRuntimeConfig: configMocks.loadConfig,
|
|
getRuntimeConfigSourceSnapshot: () => configMocks.sourceConfig,
|
|
loadConfig: configMocks.loadConfig,
|
|
}));
|
|
|
|
vi.mock("../sdk-node-runtime.js", () => ({
|
|
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(
|
|
toLintErrorObject(abortCtrl.signal.reason ?? timeoutError, "Non-Error rejection"),
|
|
),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("../sdk-setup-tools.js", () => ({
|
|
detectMime: vi.fn(async () => "image/png"),
|
|
}));
|
|
|
|
vi.mock("../browser/cdp.helpers.js", () => ({
|
|
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;
|
|
}
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../browser/config.js", () => ({
|
|
resolveBrowserConfig: browserConfigMocks.resolveBrowserConfig,
|
|
}));
|
|
|
|
vi.mock("../browser/request-policy.js", () => ({
|
|
isPersistentBrowserProfileMutation: vi.fn((method: string, path: string) => {
|
|
if (method === "POST" && (path === "/profiles/create" || path === "/reset-profile")) {
|
|
return true;
|
|
}
|
|
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(path);
|
|
}),
|
|
isBrowserHostLocalRoute: vi.fn((method: string, path: string) => {
|
|
if (method === "POST" && path === "/profiles/import") {
|
|
return true;
|
|
}
|
|
return method === "GET" && path === "/system-profiles";
|
|
}),
|
|
normalizeBrowserRequestPath: vi.fn((path: string) => path),
|
|
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;
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("../browser/routes/dispatcher.js", () => ({
|
|
createBrowserRouteDispatcher: dispatcherMocks.createBrowserRouteDispatcher,
|
|
}));
|
|
|
|
vi.mock("../control-service.js", () => ({
|
|
createBrowserControlContext: controlServiceMocks.createBrowserControlContext,
|
|
startBrowserControlServiceFromConfig: controlServiceMocks.startBrowserControlServiceFromConfig,
|
|
}));
|
|
|
|
let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand;
|
|
|
|
type BrowserDispatchRequest = {
|
|
path?: string;
|
|
query?: unknown;
|
|
};
|
|
|
|
function firstBrowserDispatchRequest(): BrowserDispatchRequest {
|
|
const [call] = dispatcherMocks.dispatch.mock.calls;
|
|
if (!call) {
|
|
throw new Error("expected browser dispatch call");
|
|
}
|
|
const [request] = call as [BrowserDispatchRequest, ...unknown[]];
|
|
return request;
|
|
}
|
|
|
|
describe("runBrowserProxyCommand", () => {
|
|
beforeEach(async () => {
|
|
vi.useRealTimers();
|
|
dispatcherMocks.dispatch.mockReset();
|
|
dispatcherMocks.createBrowserRouteDispatcher.mockReset().mockImplementation(() => ({
|
|
dispatch: dispatcherMocks.dispatch,
|
|
}));
|
|
controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true });
|
|
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
|
|
configMocks.sourceConfig = null;
|
|
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);
|
|
vi.resetModules();
|
|
({ runBrowserProxyCommand } = await import("./invoke-browser.js"));
|
|
});
|
|
|
|
it("serializes plural action downloads without reading nested page paths", async () => {
|
|
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-action-"));
|
|
const firstPath = nodePath.join(tempDir, "first.txt");
|
|
const secondPath = nodePath.join(tempDir, "second.txt");
|
|
const nestedPagePath = nodePath.join(tempDir, "page-controlled.txt");
|
|
const result = {
|
|
ok: true,
|
|
downloads: [
|
|
{ path: firstPath, suggestedFilename: "first.txt" },
|
|
null,
|
|
{ path: 42 },
|
|
{ path: secondPath, suggestedFilename: "second.txt" },
|
|
{ path: firstPath, suggestedFilename: "first-copy.txt" },
|
|
],
|
|
result: {
|
|
path: nestedPagePath,
|
|
downloads: [{ path: nestedPagePath }],
|
|
},
|
|
};
|
|
|
|
try {
|
|
await Promise.all([
|
|
fs.writeFile(firstPath, "first browser download", "utf8"),
|
|
fs.writeFile(secondPath, "second browser download", "utf8"),
|
|
fs.writeFile(nestedPagePath, "must stay on the node", "utf8"),
|
|
]);
|
|
dispatcherMocks.dispatch.mockResolvedValueOnce({ status: 200, body: result });
|
|
|
|
const payload = JSON.parse(
|
|
await runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
|
|
) as {
|
|
result: unknown;
|
|
files?: Array<{ path: string; base64: string; mimeType?: string }>;
|
|
};
|
|
|
|
expect(payload.result).toEqual(result);
|
|
expect(
|
|
payload.files?.map((file) => ({
|
|
path: file.path,
|
|
contents: Buffer.from(file.base64, "base64").toString("utf8"),
|
|
mimeType: file.mimeType,
|
|
})),
|
|
).toEqual([
|
|
{ path: firstPath, contents: "first browser download", mimeType: "image/png" },
|
|
{ path: secondPath, contents: "second browser download", mimeType: "image/png" },
|
|
]);
|
|
expect(payload.files?.some((file) => file.path === nestedPagePath)).toBe(false);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects an aggregate above the proxy transport budget", async () => {
|
|
const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-limit-"));
|
|
const firstPath = nodePath.join(tempDir, "first.bin");
|
|
const secondPath = nodePath.join(tempDir, "second.bin");
|
|
try {
|
|
await Promise.all([fs.writeFile(firstPath, ""), fs.writeFile(secondPath, "")]);
|
|
await Promise.all([
|
|
fs.truncate(firstPath, BROWSER_PROXY_MAX_FILE_BYTES),
|
|
fs.truncate(
|
|
secondPath,
|
|
BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1,
|
|
),
|
|
]);
|
|
dispatcherMocks.dispatch.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: { downloads: [{ path: firstPath }, { path: secondPath }] },
|
|
});
|
|
|
|
const error = await runBrowserProxyCommand(
|
|
JSON.stringify({ method: "POST", path: "/act" }),
|
|
).then(
|
|
() => null,
|
|
(err: unknown) => err,
|
|
);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).message).toBe(
|
|
`browser proxy file read failed for ${secondPath}: Error: browser proxy files exceed 16 MiB aggregate limit`,
|
|
);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects too many unique files before reading them", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: {
|
|
downloads: Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({
|
|
path: `/missing/browser-download-${index}.bin`,
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
|
|
).rejects.toThrow("browser proxy response exceeds 256 file limit");
|
|
});
|
|
|
|
it("rejects a result whose encoded node frame would exceed the transport limit", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValueOnce({
|
|
status: 200,
|
|
body: { result: "\\".repeat(7 * 1024 * 1024) },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })),
|
|
).rejects.toThrow("browser proxy payload exceeds 24 MiB encoded limit");
|
|
});
|
|
|
|
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.each([
|
|
{ status: 500, body: { error: "tab not found" }, expected: "500: tab not found" },
|
|
{
|
|
status: 404,
|
|
body: { error: 'tab not found: browser tab "abc"' },
|
|
expected: "404: tab not found",
|
|
},
|
|
{ status: 503, body: { error: "" }, expected: "HTTP 503" },
|
|
])("preserves legacy node errors without envelope opt-in: $expected", async (response) => {
|
|
dispatcherMocks.dispatch.mockResolvedValue(response);
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/act",
|
|
profile: "openclaw",
|
|
timeoutMs: 50,
|
|
}),
|
|
),
|
|
).rejects.toThrow(response.expected);
|
|
});
|
|
|
|
it("preserves only validated browser error metadata in the proxy envelope", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 409,
|
|
body: {
|
|
error: "headed mode needs a display",
|
|
reason: "no_display_for_headed_profile",
|
|
details: {
|
|
profile: "openclaw",
|
|
requestedHeadless: false,
|
|
headlessSource: "config",
|
|
displayPresent: false,
|
|
remediation: "untrusted",
|
|
},
|
|
untrusted: "drop me",
|
|
},
|
|
});
|
|
|
|
const result = JSON.parse(
|
|
await runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "POST",
|
|
path: "/start",
|
|
profile: "openclaw",
|
|
errorEnvelope: "browser-v1",
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
error: {
|
|
status: 409,
|
|
body: {
|
|
error: "headed mode needs a display",
|
|
reason: "no_display_for_headed_profile",
|
|
details: {
|
|
profile: "openclaw",
|
|
requestedHeadless: false,
|
|
headlessSource: "config",
|
|
displayPresent: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
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("uses the browser source snapshot for proxy default-profile decisions", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: { defaultProfile: "openclaw" },
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
|
});
|
|
configMocks.sourceConfig = {
|
|
browser: { defaultProfile: "work" },
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["work"] } },
|
|
};
|
|
browserConfigMocks.resolveBrowserConfig.mockImplementation(
|
|
(browser?: { defaultProfile?: string }) => ({
|
|
enabled: true,
|
|
defaultProfile: browser?.defaultProfile ?? "openclaw",
|
|
}),
|
|
);
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 200,
|
|
body: { ok: true },
|
|
});
|
|
|
|
await runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
timeoutMs: 50,
|
|
}),
|
|
);
|
|
|
|
const request = firstBrowserDispatchRequest();
|
|
expect(request.path).toBe("/snapshot");
|
|
});
|
|
|
|
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");
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects host-local system profile listing on a browser node", async () => {
|
|
configMocks.loadConfig.mockReturnValue({
|
|
browser: {},
|
|
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] } },
|
|
});
|
|
|
|
await expect(
|
|
runBrowserProxyCommand(
|
|
JSON.stringify({ method: "GET", path: "/system-profiles", timeoutMs: 50 }),
|
|
),
|
|
).rejects.toThrow("INVALID_REQUEST: browser.proxy cannot run host-local browser routes");
|
|
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");
|
|
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");
|
|
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,
|
|
}),
|
|
);
|
|
|
|
const request = firstBrowserDispatchRequest();
|
|
expect(request.path).toBe("/stop");
|
|
expect(request.query).toEqual({ profile: "openclaw" });
|
|
});
|
|
|
|
it("caps browser proxy command timeout before dispatch", async () => {
|
|
dispatcherMocks.dispatch.mockResolvedValue({
|
|
status: 200,
|
|
body: { ok: true },
|
|
});
|
|
const timeoutSpy = vi
|
|
.spyOn(globalThis, "setTimeout")
|
|
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
|
|
|
try {
|
|
await runBrowserProxyCommand(
|
|
JSON.stringify({
|
|
method: "GET",
|
|
path: "/snapshot",
|
|
timeoutMs: Number.MAX_SAFE_INTEGER,
|
|
}),
|
|
);
|
|
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
|
} finally {
|
|
timeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects persistent profile creation when allowProfiles is empty", async () => {
|
|
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");
|
|
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
|
if (value instanceof Error) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string") {
|
|
return new Error(value);
|
|
}
|
|
const error = new Error(fallbackMessage, { cause: value });
|
|
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
Object.assign(error, value);
|
|
}
|
|
return error;
|
|
}
|