refactor: dedupe agent and browser cli helpers
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user