refactor: dedupe agent and browser cli helpers

This commit is contained in:
Peter Steinberger
2026-03-03 00:14:48 +00:00
parent fe14be2352
commit fd3ca8a34c
46 changed files with 1051 additions and 1117 deletions

View File

@@ -2,18 +2,76 @@ import type { BrowserActionOk, BrowserActionTargetOk } from "./client-actions-ty
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
import { fetchBrowserJson } from "./client-fetch.js";
type TargetedProfileOptions = {
targetId?: string;
profile?: string;
};
type HttpCredentialsOptions = TargetedProfileOptions & {
username?: string;
password?: string;
clear?: boolean;
};
type GeolocationOptions = TargetedProfileOptions & {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
};
function buildStateQuery(params: { targetId?: string; key?: string; profile?: string }): string {
const query = new URLSearchParams();
if (params.targetId) {
query.set("targetId", params.targetId);
}
if (params.key) {
query.set("key", params.key);
}
if (params.profile) {
query.set("profile", params.profile);
}
const suffix = query.toString();
return suffix ? `?${suffix}` : "";
}
async function postProfileJson<T>(
baseUrl: string | undefined,
params: { path: string; profile?: string; body: unknown },
): Promise<T> {
const query = buildProfileQuery(params.profile);
return await fetchBrowserJson<T>(withBaseUrl(baseUrl, `${params.path}${query}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params.body),
timeoutMs: 20000,
});
}
async function postTargetedProfileJson(
baseUrl: string | undefined,
params: {
path: string;
opts: { targetId?: string; profile?: string };
body: Record<string, unknown>;
},
): Promise<BrowserActionTargetOk> {
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: params.path,
profile: params.opts.profile,
body: {
targetId: params.opts.targetId,
...params.body,
},
});
}
export async function browserCookies(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
const suffix = buildStateQuery({ targetId: opts.targetId, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
@@ -29,12 +87,10 @@ export async function browserCookiesSet(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/set",
profile: opts.profile,
body: { targetId: opts.targetId, cookie: opts.cookie },
});
}
@@ -42,12 +98,10 @@ export async function browserCookiesClear(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/cookies/clear",
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
@@ -60,17 +114,7 @@ export async function browserStorageGet(
profile?: string;
},
): Promise<{ ok: true; targetId: string; values: Record<string, string> }> {
const q = new URLSearchParams();
if (opts.targetId) {
q.set("targetId", opts.targetId);
}
if (opts.key) {
q.set("key", opts.key);
}
if (opts.profile) {
q.set("profile", opts.profile);
}
const suffix = q.toString() ? `?${q.toString()}` : "";
const suffix = buildStateQuery({ targetId: opts.targetId, key: opts.key, profile: opts.profile });
return await fetchBrowserJson<{
ok: true;
targetId: string;
@@ -88,48 +132,36 @@ export async function browserStorageSet(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/set`,
profile: opts.profile,
body: {
targetId: opts.targetId,
key: opts.key,
value: opts.value,
},
);
});
}
export async function browserStorageClear(
baseUrl: string | undefined,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
timeoutMs: 20000,
},
);
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: `/storage/${opts.kind}/clear`,
profile: opts.profile,
body: { targetId: opts.targetId },
});
}
export async function browserSetOffline(
baseUrl: string | undefined,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/offline",
profile: opts.profile,
body: { targetId: opts.targetId, offline: opts.offline },
});
}
@@ -141,71 +173,43 @@ export async function browserSetHeaders(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/headers",
profile: opts.profile,
body: { targetId: opts.targetId, headers: opts.headers },
});
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
opts: {
username?: string;
password?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
opts: HttpCredentialsOptions = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/credentials${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
return await postTargetedProfileJson(baseUrl, {
path: "/set/credentials",
opts,
body: {
username: opts.username,
password: opts.password,
clear: opts.clear,
},
);
});
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
opts: {
latitude?: number;
longitude?: number;
accuracy?: number;
origin?: string;
clear?: boolean;
targetId?: string;
profile?: string;
} = {},
opts: GeolocationOptions = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/geolocation${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
return await postTargetedProfileJson(baseUrl, {
path: "/set/geolocation",
opts,
body: {
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
},
);
});
}
export async function browserSetMedia(
@@ -216,15 +220,13 @@ export async function browserSetMedia(
profile?: string;
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/media",
profile: opts.profile,
body: {
targetId: opts.targetId,
colorScheme: opts.colorScheme,
}),
timeoutMs: 20000,
},
});
}
@@ -232,15 +234,13 @@ export async function browserSetTimezone(
baseUrl: string | undefined,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/timezone",
profile: opts.profile,
body: {
targetId: opts.targetId,
timezoneId: opts.timezoneId,
}),
timeoutMs: 20000,
},
});
}
@@ -248,12 +248,10 @@ export async function browserSetLocale(
baseUrl: string | undefined,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/locale",
profile: opts.profile,
body: { targetId: opts.targetId, locale: opts.locale },
});
}
@@ -261,12 +259,10 @@ export async function browserSetDevice(
baseUrl: string | undefined,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionTargetOk>(baseUrl, {
path: "/set/device",
profile: opts.profile,
body: { targetId: opts.targetId, name: opts.name },
});
}
@@ -274,11 +270,9 @@ export async function browserClearPermissions(
baseUrl: string | undefined,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
timeoutMs: 20000,
return await postProfileJson<BrowserActionOk>(baseUrl, {
path: "/set/geolocation",
profile: opts.profile,
body: { targetId: opts.targetId, clear: true },
});
}

View File

@@ -28,6 +28,17 @@ async function withFixtureRoot<T>(
}
}
async function createAliasedUploadsRoot(baseDir: string): Promise<{
canonicalUploadsDir: string;
aliasedUploadsDir: string;
}> {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
return { canonicalUploadsDir, aliasedUploadsDir };
}
describe("resolveExistingPathsWithinRoot", () => {
function expectInvalidResult(
result: Awaited<ReturnType<typeof resolveExistingPathsWithinRoot>>,
@@ -167,10 +178,7 @@ describe("resolveExistingPathsWithinRoot", () => {
"accepts canonical absolute paths when upload root is a symlink alias",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const { canonicalUploadsDir, aliasedUploadsDir } = await createAliasedUploadsRoot(baseDir);
const filePath = path.join(canonicalUploadsDir, "ok.txt");
await fs.writeFile(filePath, "ok", "utf8");
@@ -198,10 +206,7 @@ describe("resolveExistingPathsWithinRoot", () => {
"rejects canonical absolute paths outside symlinked upload root",
async () => {
await withFixtureRoot(async ({ baseDir }) => {
const canonicalUploadsDir = path.join(baseDir, "canonical", "uploads");
const aliasedUploadsDir = path.join(baseDir, "uploads-link");
await fs.mkdir(canonicalUploadsDir, { recursive: true });
await fs.symlink(canonicalUploadsDir, aliasedUploadsDir);
const { aliasedUploadsDir } = await createAliasedUploadsRoot(baseDir);
const outsideDir = path.join(baseDir, "outside");
await fs.mkdir(outsideDir, { recursive: true });

View File

@@ -45,15 +45,23 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
return { state, ctx };
}
async function createWorkProfileWithConfig(params: {
resolved: BrowserServerState["resolved"];
browserConfig: Record<string, unknown>;
}) {
const { ctx, state } = createCtx(params.resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: params.browserConfig });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
return { result, state };
}
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved: resolveBrowserConfig({}),
browserConfig: { profiles: {} },
});
expect(result.cdpPort).toBe(18801);
expect(result.isRemote).toBe(false);
@@ -74,12 +82,10 @@ describe("BrowserProfilesService", () => {
...baseWithoutRange,
controlPort: 30000,
} as BrowserServerState["resolved"];
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved,
browserConfig: { profiles: {} },
});
expect(result.cdpPort).toBe(30009);
expect(state.resolved.profiles.work?.cdpPort).toBe(30009);
@@ -87,13 +93,10 @@ describe("BrowserProfilesService", () => {
});
it("allocates from configured cdpPortRangeStart for new local profiles", async () => {
const resolved = resolveBrowserConfig({ cdpPortRangeStart: 19000 });
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { cdpPortRangeStart: 19000, profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
const { result, state } = await createWorkProfileWithConfig({
resolved: resolveBrowserConfig({ cdpPortRangeStart: 19000 }),
browserConfig: { cdpPortRangeStart: 19000, profiles: {} },
});
expect(result.cdpPort).toBe(19001);
expect(result.isRemote).toBe(false);

View File

@@ -456,6 +456,18 @@ async function findPageByTargetId(
return null;
}
async function resolvePageByTargetIdOrThrow(opts: {
cdpUrl: string;
targetId: string;
}): Promise<Page> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
return page;
}
export async function getPageForTargetId(opts: {
cdpUrl: string;
targetId?: string;
@@ -782,11 +794,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
const page = await resolvePageByTargetIdOrThrow(opts);
await page.close();
}
@@ -798,11 +806,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
cdpUrl: string;
targetId: string;
}): Promise<void> {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
const page = await resolvePageByTargetIdOrThrow(opts);
try {
await page.bringToFront();
} catch (err) {

View File

@@ -41,6 +41,18 @@ vi.mock("./paths.js", () => {
let setInputFilesViaPlaywright: typeof import("./pw-tools-core.interactions.js").setInputFilesViaPlaywright;
function seedSingleLocatorPage(): { setInputFiles: ReturnType<typeof vi.fn> } {
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
return { setInputFiles };
}
describe("setInputFilesViaPlaywright", () => {
beforeAll(async () => {
({ setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js"));
@@ -57,14 +69,7 @@ describe("setInputFilesViaPlaywright", () => {
});
it("revalidates upload paths and uses resolved canonical paths for inputRef", async () => {
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -88,14 +93,7 @@ describe("setInputFilesViaPlaywright", () => {
error: "Invalid path: must stay within uploads directory",
});
const setInputFiles = vi.fn(async () => {});
locator = {
setInputFiles,
elementHandle: vi.fn(async () => null),
};
page = {
locator: vi.fn(() => ({ first: () => locator })),
};
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({

View File

@@ -14,6 +14,17 @@ installPwToolsCoreTestHooks();
const sessionMocks = getPwToolsCoreSessionMocks();
const mod = await import("./pw-tools-core.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
return { fileChooser, press, waitForEvent };
}
describe("pw-tools-core", () => {
it("screenshots an element selector", async () => {
const elementScreenshot = vi.fn(async () => Buffer.from("E"));
@@ -118,13 +129,7 @@ describe("pw-tools-core", () => {
});
it("revalidates file-chooser paths at use-time and cancels missing files", async () => {
const missingPath = path.join(DEFAULT_UPLOAD_DIR, `vitest-missing-${crypto.randomUUID()}.txt`);
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
const { fileChooser, press } = createFileChooserPageMocks();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -139,13 +144,7 @@ describe("pw-tools-core", () => {
expect(fileChooser.setFiles).not.toHaveBeenCalled();
});
it("arms the next file chooser and escapes if no paths provided", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
const { fileChooser, press } = createFileChooserPageMocks();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",

View File

@@ -1,17 +1,7 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchOpenClawChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw-test"),
stopOpenClawChrome: vi.fn(async () => {}),
}));
import "./server-context.chrome-test-harness.js";
import * as chromeModule from "./chrome.js";
import type { RunningChrome } from "./chrome.js";
import type { BrowserServerState } from "./server-context.js";
@@ -63,6 +53,22 @@ function mockLaunchedChrome(
});
}
function setupEnsureBrowserAvailableHarness() {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile };
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -71,21 +77,11 @@ afterEach(() => {
describe("browser server-context ensureBrowserAvailable", () => {
it("waits for CDP readiness after launching to avoid follow-up PortInUseError races (#21149)", async () => {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
isChromeCdpReady.mockResolvedValueOnce(false).mockResolvedValue(true);
mockLaunchedChrome(launchOpenClawChrome, 123);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
const promise = profile.ensureBrowserAvailable();
await vi.advanceTimersByTimeAsync(100);
await expect(promise).resolves.toBeUndefined();
@@ -96,21 +92,11 @@ describe("browser server-context ensureBrowserAvailable", () => {
});
it("stops launched chrome when CDP readiness never arrives", async () => {
vi.useFakeTimers();
const launchOpenClawChrome = vi.mocked(chromeModule.launchOpenClawChrome);
const stopOpenClawChrome = vi.mocked(chromeModule.stopOpenClawChrome);
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const isChromeCdpReady = vi.mocked(chromeModule.isChromeCdpReady);
isChromeReachable.mockResolvedValue(false);
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
setupEnsureBrowserAvailableHarness();
isChromeCdpReady.mockResolvedValue(false);
mockLaunchedChrome(launchOpenClawChrome, 321);
const state = makeBrowserState();
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
const promise = profile.ensureBrowserAvailable();
const rejected = expect(promise).rejects.toThrow("not reachable after start");
await vi.advanceTimersByTimeAsync(8100);

View File

@@ -24,23 +24,41 @@ afterEach(() => {
vi.clearAllMocks();
});
function localOpenClawProfile(): Parameters<typeof createProfileResetOps>[0]["profile"] {
return {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
};
}
function createLocalOpenClawResetOps(
params: Omit<Parameters<typeof createProfileResetOps>[0], "profile">,
) {
return createProfileResetOps({ profile: localOpenClawProfile(), ...params });
}
function createStatelessResetOps(profile: Parameters<typeof createProfileResetOps>[0]["profile"]) {
return createProfileResetOps({
profile,
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
});
}
describe("createProfileResetOps", () => {
it("stops extension relay for extension profiles", async () => {
const ops = createProfileResetOps({
profile: {
name: "chrome",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "extension",
attachOnly: false,
},
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
const ops = createStatelessResetOps({
...localOpenClawProfile(),
name: "chrome",
driver: "extension",
});
await expect(ops.resetProfile()).resolves.toEqual({
@@ -54,21 +72,14 @@ describe("createProfileResetOps", () => {
});
it("rejects remote non-extension profiles", async () => {
const ops = createProfileResetOps({
profile: {
name: "remote",
cdpUrl: "https://browserless.example/chrome",
cdpHost: "browserless.example",
cdpIsLoopback: false,
cdpPort: 443,
color: "#0f0",
driver: "openclaw",
attachOnly: false,
},
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser: vi.fn(async () => ({ stopped: false })),
isHttpReachable: vi.fn(async () => false),
resolveOpenClawUserDataDir: (name: string) => `/tmp/${name}`,
const ops = createStatelessResetOps({
...localOpenClawProfile(),
name: "remote",
cdpUrl: "https://browserless.example/chrome",
cdpHost: "browserless.example",
cdpIsLoopback: false,
cdpPort: 443,
color: "#0f0",
});
await expect(ops.resetProfile()).rejects.toThrow(/only supported for local profiles/i);
@@ -86,17 +97,7 @@ describe("createProfileResetOps", () => {
running: { pid: 1 } as never,
}));
const ops = createProfileResetOps({
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
},
const ops = createLocalOpenClawResetOps({
getProfileState,
stopRunningBrowser,
isHttpReachable,
@@ -121,17 +122,7 @@ describe("createProfileResetOps", () => {
fs.mkdirSync(profileDir, { recursive: true });
const stopRunningBrowser = vi.fn(async () => ({ stopped: false }));
const ops = createProfileResetOps({
profile: {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
cdpPort: 18800,
color: "#f60",
driver: "openclaw",
attachOnly: false,
},
const ops = createLocalOpenClawResetOps({
getProfileState: () => ({ profile: {} as never, running: null }),
stopRunningBrowser,
isHttpReachable: vi.fn(async () => true),

View File

@@ -54,6 +54,34 @@ function createOldTabCleanupFetchMock(
});
}
function createManagedTabListFetchMock(params: {
existingTabs: ReturnType<typeof makeManagedTabsWithNew>;
onClose: (url: string) => Response | Promise<Response>;
}): ReturnType<typeof vi.fn> {
return vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => params.existingTabs } as unknown as Response;
}
if (value.includes("/json/close/")) {
return await params.onClose(value);
}
throw new Error(`unexpected fetch: ${value}`);
});
}
async function openManagedTabWithRunningProfile(params: {
fetchMock: ReturnType<typeof vi.fn>;
url?: string;
}) {
global.fetch = withFetchPreconnect(params.fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
return await openclaw.openTab(params.url ?? "http://127.0.0.1:3009");
}
describe("browser server-context tab selection state", () => {
it("updates lastTargetId when openTab is created via CDP", async () => {
const createTargetViaCdp = vi
@@ -99,13 +127,7 @@ describe("browser server-context tab selection state", () => {
const existingTabs = makeManagedTabsWithNew();
const fetchMock = createOldTabCleanupFetchMock(existingTabs);
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:3009");
const opened = await openManagedTabWithRunningProfile({ fetchMock });
expect(opened.targetId).toBe("NEW");
await expectOldManagedTabClose(fetchMock);
});
@@ -115,13 +137,7 @@ describe("browser server-context tab selection state", () => {
const existingTabs = makeManagedTabsWithNew({ newFirst: true });
const fetchMock = createOldTabCleanupFetchMock(existingTabs, { rejectNewTabClose: true });
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:3009");
const opened = await openManagedTabWithRunningProfile({ fetchMock });
expect(opened.targetId).toBe("NEW");
await expectOldManagedTabClose(fetchMock);
expect(fetchMock).not.toHaveBeenCalledWith(
@@ -170,16 +186,11 @@ describe("browser server-context tab selection state", () => {
it("does not run managed tab cleanup in attachOnly mode", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
const existingTabs = makeManagedTabsWithNew();
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => existingTabs } as unknown as Response;
}
if (value.includes("/json/close/")) {
const fetchMock = createManagedTabListFetchMock({
existingTabs,
onClose: () => {
throw new Error("should not close tabs in attachOnly mode");
}
throw new Error(`unexpected fetch: ${value}`);
},
});
global.fetch = withFetchPreconnect(fetchMock);
@@ -199,26 +210,18 @@ describe("browser server-context tab selection state", () => {
it("does not block openTab on slow best-effort cleanup closes", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
const existingTabs = makeManagedTabsWithNew();
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => existingTabs } as unknown as Response;
}
if (value.includes("/json/close/OLD1")) {
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${value}`);
const fetchMock = createManagedTabListFetchMock({
existingTabs,
onClose: (url) => {
if (url.includes("/json/close/OLD1")) {
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
},
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
seedRunningProfileState(state);
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await Promise.race([
openclaw.openTab("http://127.0.0.1:3009"),
openManagedTabWithRunningProfile({ fetchMock }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("openTab timed out waiting for cleanup")), 300),
),