diff --git a/packages/cli/package.json b/packages/cli/package.json index b8a5f6b..88f022a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:*" diff --git a/packages/cli/src/commands/open.test.ts b/packages/cli/src/commands/open.test.ts new file mode 100644 index 0000000..cf323d5 --- /dev/null +++ b/packages/cli/src/commands/open.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 1eb993b..a07a876 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -4,7 +4,7 @@ * 用法: * bb-browser open # 在新 tab 中打开 * bb-browser open --tab current # 在当前 tab 中打开 - * bb-browser open --tab 123 # 在指定 tabId 的 tab 中打开 + * bb-browser open --tab # 在指定 tabId 的 tab 中打开 */ import type { Request, Response } from "@bb-browser/shared"; @@ -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( @@ -48,11 +52,7 @@ export async function openCommand( (request as Record).tabId = "current"; } else { // 使用指定 tabId - const tabId = parseInt(options.tab, 10); - if (isNaN(tabId)) { - throw new Error(`无效的 tabId: ${options.tab}`); - } - (request as Record).tabId = tabId; + request.tabId = resolveOpenTabOption(options.tab); } } // 不指定 --tab 时,tabId 为 undefined,扩展会创建新 tab @@ -65,7 +65,7 @@ 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}`); } @@ -73,6 +73,9 @@ export async function openCommand( 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) { diff --git a/packages/daemon/src/__tests__/open-targeting.test.ts b/packages/daemon/src/__tests__/open-targeting.test.ts new file mode 100644 index 0000000..0a33e8b --- /dev/null +++ b/packages/daemon/src/__tests__/open-targeting.test.ts @@ -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 = []; + pageCommands: Array<{ targetId: string; method: string; params: Record }> = []; + browserCommands: Array<{ method: string; params: Record }> = []; + evaluateCalls: Array<{ targetId: string; expression: string }> = []; + + constructor() { + this.tabManager.addTab(this.target.id); + } + + async ensurePageTarget(tabRef?: string | number): Promise { + 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( + targetId: string, + method: string, + params: Record = {}, + ): Promise { + this.pageCommands.push({ targetId, method, params }); + return {} as T; + } + + async browserCommand(method: string, params: Record = {}): Promise { + 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(targetId: string, expression: string): Promise { + 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], + ); + }); +}); diff --git a/packages/daemon/src/command-dispatch.ts b/packages/daemon/src/command-dispatch.ts index 2b5751a..1b6efd2 100644 --- a/packages/daemon/src/command-dispatch.ts +++ b/packages/daemon/src/command-dispatch.ts @@ -595,6 +595,7 @@ export async function dispatchRequest( ): Promise { // 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(). @@ -636,7 +637,7 @@ export async function dispatchRequest( } 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"); @@ -661,7 +662,7 @@ export async function dispatchRequest( return ok({ url: request.url, tabId: newTarget.id, - tab: newTab?.shortId ?? shortId, + tab: newTab?.shortId ?? newTarget.id.slice(-4).toLowerCase(), seq, }); }