Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "tsx --test src/openclaw-json.test.ts src/openclaw-bridge.test.ts src/__tests__/daemon-startup.test.ts"
"test": "tsx --test src/openclaw-json.test.ts src/openclaw-bridge.test.ts src/commands/open.test.ts src/__tests__/daemon-startup.test.ts"
},
"dependencies": {
"@bb-browser/shared": "workspace:*"
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveOpenTabOption } from "./open.js";

describe("resolveOpenTabOption", () => {
it("preserves short string tab ids", () => {
assert.equal(resolveOpenTabOption("a1b2"), "a1b2");
});

it("preserves full CDP target ids", () => {
const targetId = "D9E4E599BD8F29EA64E59C80EEB70234";
assert.equal(resolveOpenTabOption(targetId), targetId);
});

it("preserves numeric strings so the daemon can resolve short ids before indexes", () => {
assert.equal(resolveOpenTabOption("1234"), "1234");
});
});
19 changes: 11 additions & 8 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 用法:
* bb-browser open <url> # 在新 tab 中打开
* bb-browser open <url> --tab current # 在当前 tab 中打开
* bb-browser open <url> --tab 123 # 在指定 tabId 的 tab 中打开
* bb-browser open <url> --tab <tabId> # 在指定 tabId 的 tab 中打开
*/

import type { Request, Response } from "@bb-browser/shared";
Expand All @@ -14,7 +14,11 @@ import { getSiteHintForDomain } from "./site.js";

export interface OpenOptions {
json?: boolean;
tab?: string; // "current" | tabId 数字字符串 | undefined(新建 tab)
tab?: string; // "current" | tabId | undefined(新建 tab)
}

export function resolveOpenTabOption(tab: string): string {
return tab;
}

export async function openCommand(
Expand Down Expand Up @@ -48,11 +52,7 @@ export async function openCommand(
(request as Record<string, unknown>).tabId = "current";
} else {
// 使用指定 tabId
const tabId = parseInt(options.tab, 10);
if (isNaN(tabId)) {
throw new Error(`无效的 tabId: ${options.tab}`);
}
(request as Record<string, unknown>).tabId = tabId;
request.tabId = resolveOpenTabOption(options.tab);
}
}
// 不指定 --tab 时,tabId 为 undefined,扩展会创建新 tab
Expand All @@ -65,14 +65,17 @@ export async function openCommand(
console.log(JSON.stringify(response, null, 2));
} else {
if (response.result) {
const tab = response.result?.tab;
const tab = response.result?.tab ?? response.result?.tabId;
if (tab) {
console.log(`tab: ${tab}`);
}
console.log(`url: ${response.result?.url ?? normalizedUrl}`);
if (response.result?.title) {
console.log(`title: ${response.result.title}`);
}
if (response.result?.tab && response.result?.tabId && response.result.tab !== response.result.tabId) {
console.log(`targetId: ${response.result.tabId}`);
}
// 提示:如果该域名有 site adapter,引导使用
const siteHint = getSiteHintForDomain(normalizedUrl);
if (siteHint) {
Expand Down
175 changes: 175 additions & 0 deletions packages/daemon/src/__tests__/open-targeting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { Request } from "@bb-browser/shared";
import { dispatchRequest } from "../command-dispatch.js";
import type { CdpConnection, CdpTargetInfo } from "../cdp-connection.js";
import { TabStateManager } from "../tab-state.js";

class FakeCdp {
readonly tabManager = new TabStateManager();
readonly existingTargetId = "D9E4E599BD8F29EA64E59C80EEB70234";
readonly createdTargetId = "A1E4E599BD8F29EA64E59C80EEB75678";
readonly target: CdpTargetInfo = {
id: this.existingTargetId,
type: "page",
title: "Existing",
url: "https://old.example",
};
readonly createdTarget: CdpTargetInfo = {
id: this.createdTargetId,
type: "page",
title: "Created",
url: "https://new.example",
};
currentTargetId: string | undefined = this.target.id;
targets: CdpTargetInfo[] = [this.target];
ensureCalls: Array<string | number | undefined> = [];
pageCommands: Array<{ targetId: string; method: string; params: Record<string, unknown> }> = [];
browserCommands: Array<{ method: string; params: Record<string, unknown> }> = [];
evaluateCalls: Array<{ targetId: string; expression: string }> = [];

constructor() {
this.tabManager.addTab(this.target.id);
}

async ensurePageTarget(tabRef?: string | number): Promise<CdpTargetInfo> {
this.ensureCalls.push(tabRef);
let target: CdpTargetInfo | undefined;

if (typeof tabRef === "string") {
const resolvedTargetId = this.tabManager.resolveShortId(tabRef);
target = this.targets.find((t) => t.id === resolvedTargetId);
target ??= this.targets.find((t) => t.id === tabRef);
if (!target) {
const index = Number(tabRef);
if (!Number.isNaN(index)) target = this.targets[index];
}
} else if (typeof tabRef === "number") {
target = this.targets[tabRef];
} else if (this.currentTargetId) {
target = this.targets.find((t) => t.id === this.currentTargetId);
}

target ??= this.targets[0];
this.currentTargetId = target.id;
this.tabManager.addTab(target.id);
return target;
}

async pageCommand<T = unknown>(
targetId: string,
method: string,
params: Record<string, unknown> = {},
): Promise<T> {
this.pageCommands.push({ targetId, method, params });
return {} as T;
}

async browserCommand<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
this.browserCommands.push({ method, params });
if (method === "Target.createTarget") {
this.targets.push(this.createdTarget);
return { targetId: this.createdTarget.id } as T;
}
return {} as T;
}

async evaluate<T = unknown>(targetId: string, expression: string): Promise<T> {
this.evaluateCalls.push({ targetId, expression });
const target = this.targets.find((t) => t.id === targetId);
if (expression === "document.title") {
return target?.title as T;
}
return undefined as T;
}
}

function fakeConnection(): CdpConnection {
return new FakeCdp() as unknown as CdpConnection;
}

describe("dispatchRequest open tab targeting", () => {
it("navigates the current tab when tabId is current", async () => {
const cdp = fakeConnection();

const response = await dispatchRequest(cdp, {
method: "open",
url: "https://example.com",
tabId: "current",
} satisfies Request);

assert.equal(response.error, undefined);
assert.equal(response.result?.tabId, (cdp as unknown as FakeCdp).existingTargetId);

const fake = cdp as unknown as FakeCdp;
assert.deepEqual(fake.ensureCalls, [undefined]);
assert.equal(fake.browserCommands.length, 0);
assert.deepEqual(fake.pageCommands, [
{
targetId: fake.existingTargetId,
method: "Page.navigate",
params: { url: "https://example.com" },
},
]);
});

it("passes short and full string tab ids through daemon target resolution", async () => {
for (const tabId of ["0234", "D9E4E599BD8F29EA64E59C80EEB70234"]) {
const cdp = fakeConnection();

const response = await dispatchRequest(cdp, {
method: "open",
url: "https://example.com",
tabId,
} satisfies Request);

assert.equal(response.error, undefined);
assert.deepEqual((cdp as unknown as FakeCdp).ensureCalls, [tabId]);
}
});

it("returns ids that target the opened tab in follow-up commands", async () => {
const cdp = fakeConnection();

const openResponse = await dispatchRequest(cdp, {
method: "open",
url: "https://new.example",
} satisfies Request);

assert.equal(openResponse.error, undefined);
assert.equal(openResponse.result?.tabId, (cdp as unknown as FakeCdp).createdTargetId);
assert.equal(openResponse.result?.tab, "5678");

const fake = cdp as unknown as FakeCdp;
assert.deepEqual(fake.browserCommands, [
{
method: "Target.createTarget",
params: { url: "https://new.example", background: true },
},
]);

const byShortId = await dispatchRequest(cdp, {
method: "eval",
script: "document.title",
tabId: openResponse.result?.tab,
} satisfies Request);

assert.equal(byShortId.error, undefined);
assert.equal(byShortId.result?.result, "Created");

const byFullId = await dispatchRequest(cdp, {
method: "eval",
script: "document.title",
tabId: openResponse.result?.tabId,
} satisfies Request);

assert.equal(byFullId.error, undefined);
assert.equal(byFullId.result?.result, "Created");
assert.deepEqual(
fake.evaluateCalls
.filter((call) => call.expression === "document.title")
.map((call) => call.targetId),
[fake.createdTargetId, fake.createdTargetId],
);
});
});
5 changes: 3 additions & 2 deletions packages/daemon/src/command-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
TraceEntry,
TraceStatus,
} from "@bb-browser/shared";
import { CdpConnection, type CdpTargetInfo } from "./cdp-connection.js";

Check failure on line 21 in packages/daemon/src/command-dispatch.ts

View workflow job for this annotation

GitHub Actions / check

'CdpTargetInfo' is defined but never used. Allowed unused vars must match /^_/u
import type { TabState } from "./tab-state.js";
import type { ActionDetail } from "./tab-state.js";

Check failure on line 23 in packages/daemon/src/command-dispatch.ts

View workflow job for this annotation

GitHub Actions / check

'ActionDetail' is defined but never used. Allowed unused vars must match /^_/u
import { getAllSites, executeSiteAdapter } from "./site-adapter.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -595,6 +595,7 @@
): Promise<Response> {
// Resolve target from request.tabId (supports short IDs)
const tabRef = request.tabId;
const useCurrentTab = tabRef === "current";

// tab_new must work even when there are no existing tabs,
// so handle it before ensurePageTarget().
Expand Down Expand Up @@ -636,7 +637,7 @@
}

const target = await cdp.ensurePageTarget(
tabRef !== undefined ? String(tabRef) : undefined,
tabRef !== undefined && !useCurrentTab ? String(tabRef) : undefined,
);
const tab = cdp.tabManager.getTab(target.id);
if (!tab) throw new Error("Internal error: tab state not found");
Expand All @@ -661,7 +662,7 @@
return ok({
url: request.url,
tabId: newTarget.id,
tab: newTab?.shortId ?? shortId,
tab: newTab?.shortId ?? newTarget.id.slice(-4).toLowerCase(),
seq,
});
}
Expand Down
Loading