mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
892 lines
36 KiB
TypeScript
892 lines
36 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { SessionManager } from "@/session-manager/manager";
|
|
import {
|
|
type AgentOverlayResetApi,
|
|
type ChromeWindowsApi,
|
|
handleTabBorrow,
|
|
handleTabClose,
|
|
handleTabCreate,
|
|
handleTabList,
|
|
handleTabReturn,
|
|
handleTabSelect,
|
|
NEW_TAB_DEFAULT_URL,
|
|
returnBorrowedTab,
|
|
type TabMutationApi,
|
|
} from "../tabs";
|
|
|
|
function fakeAgentWindow(ids: number[]) {
|
|
let i = 0;
|
|
return {
|
|
create: vi.fn(async () => {
|
|
const id = ids[i++];
|
|
if (id === undefined) throw new Error("ran out of fake window ids");
|
|
return id;
|
|
}),
|
|
remove: vi.fn(async () => {}),
|
|
ensureActiveTab: vi.fn(async () => 1),
|
|
};
|
|
}
|
|
|
|
function fakeChromeTabsApi(tabs: Partial<chrome.tabs.Tab>[]) {
|
|
return {
|
|
query: vi.fn(async (_q: chrome.tabs.QueryInfo) =>
|
|
tabs.map((t, i) => ({ id: i + 1, ...t }) as chrome.tabs.Tab),
|
|
),
|
|
};
|
|
}
|
|
|
|
describe("handleTabList", () => {
|
|
it("returns invalid_params for unknown scope values", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([10]) });
|
|
await sm.start("aa11");
|
|
const res = await handleTabList(
|
|
sm,
|
|
{ session_id: "aa11", scope: "private" } as unknown as Parameters<typeof handleTabList>[1],
|
|
fakeChromeTabsApi([]),
|
|
);
|
|
expect(res).toMatchObject({ code: "invalid_params" });
|
|
});
|
|
|
|
it("default scope=all unions user and agent tabs when scope is omitted", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const tabs = fakeChromeTabsApi([
|
|
{ windowId: 100, url: "chrome://newtab/", title: "Agent New Tab", active: true },
|
|
{ windowId: 200, url: "https://example.com", title: "Example", active: true },
|
|
]);
|
|
const res = await handleTabList(sm, { session_id: "aa11" }, tabs);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.tabs).toHaveLength(2);
|
|
expect(res.tabs.map((t) => t.scope).sort()).toEqual(["agent", "user"]);
|
|
});
|
|
|
|
it("scope=user includes unclaimed tabs inside the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const tabs = fakeChromeTabsApi([
|
|
{ windowId: 100, url: "chrome://newtab/", title: "Agent New Tab", active: true },
|
|
{ windowId: 100, url: "https://user.example/", title: "Free user tab", active: false },
|
|
{ windowId: 200, url: "https://example.com", title: "Example", active: true },
|
|
{ windowId: 200, url: "https://github.com", title: "GitHub", active: false },
|
|
]);
|
|
const res = await handleTabList(sm, { session_id: "aa11", scope: "user" }, tabs);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.tabs).toHaveLength(3);
|
|
expect(res.tabs.every((t) => t.scope === "user")).toBe(true);
|
|
expect(res.tabs.map((t) => t.url)).toEqual([
|
|
"https://user.example/",
|
|
"https://example.com",
|
|
"https://github.com",
|
|
]);
|
|
});
|
|
|
|
it("scope=agent returns only the requesting session's Agent Window tabs", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const tabs = fakeChromeTabsApi([
|
|
{ windowId: 100, url: "chrome://newtab/", title: "Agent" },
|
|
{ windowId: 200, url: "https://example.com" },
|
|
]);
|
|
const res = await handleTabList(sm, { session_id: "aa11", scope: "agent" }, tabs);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.tabs).toHaveLength(1);
|
|
expect(res.tabs[0]).toMatchObject({ scope: "agent", url: "chrome://newtab/" });
|
|
});
|
|
|
|
it("hides other sessions' Agent Window tabs from scope=all (cross-session isolation)", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11"); // agent window 100
|
|
await sm.start("bb22"); // agent window 200
|
|
const tabs = fakeChromeTabsApi([
|
|
{ windowId: 100, url: "chrome://newtab/" }, // aa11's agent window
|
|
{ windowId: 200, url: "https://internal-tools/" }, // bb22's agent window — must be hidden from aa11
|
|
{ windowId: 300, url: "https://example.com" },
|
|
]);
|
|
const res = await handleTabList(sm, { session_id: "aa11", scope: "all" }, tabs);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.tabs.map((t) => t.url).sort()).toEqual(["chrome://newtab/", "https://example.com"]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M8 helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FakeTabState {
|
|
tabs: Map<number, chrome.tabs.Tab>;
|
|
nextTabId: number;
|
|
windowsClosed: Set<number>;
|
|
}
|
|
|
|
function makeTabMutationApi(state: FakeTabState): {
|
|
api: TabMutationApi;
|
|
spies: {
|
|
create: ReturnType<typeof vi.fn>;
|
|
remove: ReturnType<typeof vi.fn>;
|
|
update: ReturnType<typeof vi.fn>;
|
|
get: ReturnType<typeof vi.fn>;
|
|
move: ReturnType<typeof vi.fn>;
|
|
};
|
|
} {
|
|
const create = vi.fn(async (props: chrome.tabs.CreateProperties) => {
|
|
const id = state.nextTabId++;
|
|
const tab = {
|
|
id,
|
|
windowId: props.windowId ?? 1,
|
|
url: props.url ?? "",
|
|
pendingUrl: props.url,
|
|
active: props.active ?? true,
|
|
index: props.index ?? state.tabs.size,
|
|
} as chrome.tabs.Tab;
|
|
state.tabs.set(id, tab);
|
|
return tab;
|
|
});
|
|
const remove = vi.fn(async (tabId: number) => {
|
|
state.tabs.delete(tabId);
|
|
});
|
|
const update = vi.fn(async (tabId: number, props: chrome.tabs.UpdateProperties) => {
|
|
const t = state.tabs.get(tabId);
|
|
if (!t) throw new Error(`update: tab ${tabId} not found`);
|
|
if (props.active !== undefined) (t as { active?: boolean }).active = props.active;
|
|
return t;
|
|
});
|
|
const get = vi.fn(async (tabId: number) => {
|
|
const t = state.tabs.get(tabId);
|
|
if (!t) throw new Error(`get: tab ${tabId} not found`);
|
|
return t;
|
|
});
|
|
const move = vi.fn(async (tabId: number, props: chrome.tabs.MoveProperties) => {
|
|
const t = state.tabs.get(tabId);
|
|
if (!t) throw new Error(`move: tab ${tabId} not found`);
|
|
if (typeof props.windowId === "number") {
|
|
if (state.windowsClosed.has(props.windowId)) {
|
|
throw new Error(`move: window ${props.windowId} closed`);
|
|
}
|
|
(t as { windowId?: number }).windowId = props.windowId;
|
|
}
|
|
if (typeof props.index === "number") {
|
|
(t as { index?: number }).index = props.index === -1 ? 999 : props.index;
|
|
}
|
|
return t;
|
|
});
|
|
return {
|
|
api: { create, remove, update, get, move },
|
|
spies: { create, remove, update, get, move },
|
|
};
|
|
}
|
|
|
|
function makeWindowsApi(
|
|
state: FakeTabState,
|
|
opts?: { lastFocused?: number | null; createWindowId?: number },
|
|
): {
|
|
api: ChromeWindowsApi;
|
|
spies: {
|
|
get: ReturnType<typeof vi.fn>;
|
|
lastFocused: ReturnType<typeof vi.fn>;
|
|
create: ReturnType<typeof vi.fn>;
|
|
remove: ReturnType<typeof vi.fn>;
|
|
};
|
|
} {
|
|
const get = vi.fn(async (windowId: number) => {
|
|
if (state.windowsClosed.has(windowId)) {
|
|
throw new Error(`window ${windowId} closed`);
|
|
}
|
|
return { id: windowId } as chrome.windows.Window;
|
|
});
|
|
const lastFocused = vi.fn(async () => {
|
|
if (opts?.lastFocused === null) {
|
|
throw new Error("no last focused window");
|
|
}
|
|
return { id: opts?.lastFocused ?? 500 } as chrome.windows.Window;
|
|
});
|
|
const create = vi.fn(async (_props: chrome.windows.CreateData) => {
|
|
const id = opts?.createWindowId ?? 999;
|
|
return { id } as chrome.windows.Window;
|
|
});
|
|
const remove = vi.fn(async (windowId: number) => {
|
|
state.windowsClosed.add(windowId);
|
|
});
|
|
return {
|
|
api: { get, getLastFocused: lastFocused, create, remove },
|
|
spies: { get, lastFocused, create, remove },
|
|
};
|
|
}
|
|
|
|
describe("handleTabCreate", () => {
|
|
it("creates a tab inside the requesting session's Agent Window with the default URL", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(spies.create).toHaveBeenCalledWith({
|
|
windowId: 100,
|
|
url: NEW_TAB_DEFAULT_URL,
|
|
active: true,
|
|
});
|
|
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
|
|
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
|
|
});
|
|
|
|
it("forwards explicit url + index + active=false", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabCreate(
|
|
sm,
|
|
{
|
|
session_id: "aa11",
|
|
url: "https://example.com/",
|
|
active: false,
|
|
index: 2,
|
|
},
|
|
{ tabs: api },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(spies.create).toHaveBeenCalledWith({
|
|
windowId: 100,
|
|
url: "https://example.com/",
|
|
active: false,
|
|
index: 2,
|
|
});
|
|
expect(res.url).toBe("https://example.com/");
|
|
});
|
|
|
|
it("rejects negative index", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "invalid_params" });
|
|
});
|
|
|
|
it("does not claim any tab when chrome.tabs.create fails", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
|
|
|
|
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "protocol_error" });
|
|
|
|
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
|
|
});
|
|
|
|
it("claims the returned id even when a user tab appears during tab creation", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
|
|
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
|
|
resolveCreate = resolve;
|
|
});
|
|
spies.create.mockImplementationOnce(() => createResult);
|
|
|
|
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
|
|
// A user can create an unrelated same-window tab while Chrome is still
|
|
// resolving the agent request. There is no pending slot for it to steal.
|
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
|
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
|
|
|
|
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
|
|
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
|
|
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("handleTabClose", () => {
|
|
it("removes a tab inside the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.agentCreatedTabs.add(5);
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[5, { id: 5, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 5 }, { tabs: api });
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.tab_id).toBe(5);
|
|
expect(spies.remove).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
it("refuses to close a free tab in the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[5, { id: 5, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 5 }, { tabs: api });
|
|
expect(res).toMatchObject({
|
|
code: "permission_denied",
|
|
data: { reason: "agent_window_scope" },
|
|
});
|
|
expect(spies.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects closing a borrowed tab — caller must tab_return first", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(8, { tabId: 8, originalWindowId: 200, originalIndex: 3 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[8, { id: 8, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 8 }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "invalid_params" });
|
|
expect(spies.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses to close a tab outside the Agent Window with permission_denied", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[9, { id: 9, windowId: 200 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 9 }, { tabs: api });
|
|
expect(res).toMatchObject({
|
|
code: "permission_denied",
|
|
data: { reason: "agent_window_scope" },
|
|
});
|
|
});
|
|
|
|
it("rejects when another session has borrowed the tab", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11");
|
|
const ctxB = await sm.start("bb22");
|
|
ctxB.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 1 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "borrow_conflict" } });
|
|
});
|
|
});
|
|
|
|
describe("handleTabSelect", () => {
|
|
it("activates a tab inside the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.agentCreatedTabs.add(3);
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[3, { id: 3, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res).toEqual({ tab_id: 3, window_id: 100 });
|
|
expect(spies.update).toHaveBeenCalledWith(3, { active: true });
|
|
});
|
|
|
|
it("rejects activating a tab in a different session's Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11");
|
|
await sm.start("bb22");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[3, { id: 3, windowId: 200 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
|
|
expect(res).toMatchObject({
|
|
code: "permission_denied",
|
|
data: { reason: "agent_window_scope" },
|
|
});
|
|
});
|
|
|
|
it("rejects a borrowed tab that is no longer in the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(3, { tabId: 3, originalWindowId: 200, originalIndex: 0 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[3, { id: 3, windowId: 200 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
|
|
expect(res).toMatchObject({
|
|
code: "permission_denied",
|
|
data: { reason: "agent_window_scope" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("handleTabBorrow", () => {
|
|
it("moves the tab into the Agent Window and records the original position", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200, index: 4 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res).toEqual({
|
|
tab_id: 7,
|
|
original_window_id: 200,
|
|
original_index: 4,
|
|
agent_window_id: 100,
|
|
});
|
|
expect(ctx.borrowedTabs.get(7)).toEqual({
|
|
tabId: 7,
|
|
originalWindowId: 200,
|
|
originalIndex: 4,
|
|
});
|
|
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 100, index: -1 });
|
|
expect(spies.update).toHaveBeenCalledWith(7, { active: true });
|
|
});
|
|
|
|
it("refuses to borrow a tab already inside the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100, index: 0 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "invalid_params" });
|
|
});
|
|
|
|
it("refuses to borrow a tab already held by another session", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11");
|
|
const ctxB = await sm.start("bb22");
|
|
ctxB.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 1 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
|
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "borrow_conflict" } });
|
|
});
|
|
|
|
it("allows only one concurrent borrow of the same tab across sessions", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11");
|
|
await sm.start("bb22");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 300, index: 1 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
|
|
const [a, b] = await Promise.all([
|
|
handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api }),
|
|
handleTabBorrow(sm, { session_id: "bb22", tab_id: 7 }, { tabs: api }),
|
|
]);
|
|
|
|
const results = [a, b];
|
|
expect(results.filter((r) => !("code" in r))).toHaveLength(1);
|
|
expect(results.filter((r) => "code" in r && r.code === "permission_denied")).toHaveLength(1);
|
|
expect(sm.list().filter((ctx) => ctx.borrowedTabs.has(7))).toHaveLength(1);
|
|
});
|
|
|
|
it("refuses to borrow a tab inside another session's Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
await sm.start("aa11");
|
|
await sm.start("bb22");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
|
|
expect(res).toMatchObject({
|
|
code: "permission_denied",
|
|
data: { reason: "agent_window_scope" },
|
|
});
|
|
});
|
|
|
|
it("returns cancelled when the approver declines", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const res = await handleTabBorrow(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, approveBorrow: async () => false },
|
|
);
|
|
expect(res).toMatchObject({ code: "cancelled" });
|
|
expect(ctx.borrowedTabs.has(7)).toBe(false);
|
|
expect(spies.move).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes confirm to the approver and treats approver errors as cancellation", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 200, index: 0 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const approveBorrow = vi.fn(async () => {
|
|
throw new Error("confirmation dismissed");
|
|
});
|
|
|
|
const res = await handleTabBorrow(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7, confirm: false },
|
|
{ tabs: api, approveBorrow },
|
|
);
|
|
|
|
expect(approveBorrow).toHaveBeenCalledWith({ sessionId: "aa11", tabId: 7, confirm: false });
|
|
expect(res).toMatchObject({ code: "cancelled" });
|
|
expect(spies.move).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("handleTabReturn", () => {
|
|
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100, index: 999 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi } = makeWindowsApi(state);
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.returned_to_window_id).toBe(200);
|
|
expect(res.returned_to_index).toBe(4);
|
|
expect(res.fallback).toBeUndefined();
|
|
expect(ctx.borrowedTabs.has(7)).toBe(false);
|
|
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 200, index: 4 });
|
|
});
|
|
|
|
it("resets agent overlays in a returned tab after a successful tab_return", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100, index: 999 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const { api: windowsApi } = makeWindowsApi(state);
|
|
const agentOverlayReset = {
|
|
resetAgentOverlays: vi.fn(async () => {}),
|
|
} satisfies AgentOverlayResetApi;
|
|
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi, agentOverlayReset },
|
|
);
|
|
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(agentOverlayReset.resetAgentOverlays).toHaveBeenCalledWith(7, "aa11");
|
|
});
|
|
|
|
it("falls back to the last focused normal window when the original is gone", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([200]),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, { lastFocused: 500 });
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.returned_to_window_id).toBe(500);
|
|
expect(res.fallback).toBe(true);
|
|
expect(spies.move).toHaveBeenCalledWith(7, { windowId: 500, index: -1 });
|
|
expect(winSpies.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates a new window when no normal window remains", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([200]),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: null,
|
|
createWindowId: 777,
|
|
});
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.returned_to_window_id).toBe(777);
|
|
expect(res.fallback).toBe(true);
|
|
expect(winSpies.create).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("closes a newly created fallback window when cancellation wins before the move", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([200]),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: 100,
|
|
createWindowId: 777,
|
|
});
|
|
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
|
|
winSpies.create.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveCreate = resolve;
|
|
}),
|
|
);
|
|
const controller = new AbortController();
|
|
|
|
const pending = handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi, signal: controller.signal },
|
|
);
|
|
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
|
|
controller.abort();
|
|
resolveCreate({ id: 777 } as chrome.windows.Window);
|
|
|
|
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
|
|
expect(winSpies.remove).toHaveBeenCalledWith(777);
|
|
expect(spies.move).not.toHaveBeenCalled();
|
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
|
});
|
|
|
|
it("surfaces the fallback window id when cancellation cleanup fails", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([200]),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: 100,
|
|
createWindowId: 777,
|
|
});
|
|
let resolveCreate: (window: chrome.windows.Window) => void = () => {};
|
|
winSpies.create.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveCreate = resolve;
|
|
}),
|
|
);
|
|
winSpies.remove.mockRejectedValueOnce(new Error("window removal denied"));
|
|
const controller = new AbortController();
|
|
|
|
const pending = handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi, signal: controller.signal },
|
|
);
|
|
await vi.waitFor(() => expect(winSpies.create).toHaveBeenCalledOnce());
|
|
controller.abort();
|
|
resolveCreate({ id: 777 } as chrome.windows.Window);
|
|
|
|
await expect(pending).resolves.toMatchObject({
|
|
code: "protocol_error",
|
|
data: { reason: "cleanup_failed", resource_type: "window", resource_id: 777 },
|
|
message: expect.stringMatching(/fallback window 777.*window removal denied/),
|
|
});
|
|
expect(spies.move).not.toHaveBeenCalled();
|
|
expect(ctx.borrowedTabs.has(7)).toBe(true);
|
|
});
|
|
|
|
it("falls back to a new window when getLastFocused only returns the Agent Window", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([200]),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: 100,
|
|
createWindowId: 777,
|
|
});
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.returned_to_window_id).toBe(777);
|
|
expect(winSpies.create).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not fall back into another session's Agent Window (creates a new window instead)", async () => {
|
|
// Regression: a returned borrowed tab whose original window is gone
|
|
// must never be parked in *another* session's Agent Window. Agent
|
|
// Windows are `type: "normal"`, so getLastFocused({windowTypes:
|
|
// ["normal"]}) can return session bb22's window (200). Without the
|
|
// isAgentWindowId guard the tab would be moved into window 200,
|
|
// letting bb22 write to it and destroying it when bb22 stops.
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
|
|
const ctx = await sm.start("aa11");
|
|
await sm.start("bb22"); // owns Agent Window 200
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([300]),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: 200, // session bb22's Agent Window
|
|
createWindowId: 777,
|
|
});
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 7 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.returned_to_window_id).toBe(777);
|
|
expect(res.fallback).toBe(true);
|
|
expect(winSpies.create).toHaveBeenCalledOnce();
|
|
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 200 }));
|
|
});
|
|
|
|
it("returnBorrowedTab honours an explicit isAgentWindowId predicate", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set([300]),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
|
|
lastFocused: 555,
|
|
createWindowId: 777,
|
|
});
|
|
const res = await returnBorrowedTab(ctx, 7, {
|
|
tabs: api,
|
|
windows: windowsApi,
|
|
isAgentWindowId: (windowId) => windowId === 555,
|
|
});
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.toWindowId).toBe(777);
|
|
expect(res.fallback).toBe(true);
|
|
expect(winSpies.create).toHaveBeenCalledOnce();
|
|
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 555 }));
|
|
});
|
|
|
|
it("returns not_found when the tab is not borrowed by this session", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
await sm.start("aa11");
|
|
const state: FakeTabState = { tabs: new Map(), nextTabId: 50, windowsClosed: new Set() };
|
|
const { api } = makeTabMutationApi(state);
|
|
const { api: windowsApi } = makeWindowsApi(state);
|
|
const res = await handleTabReturn(
|
|
sm,
|
|
{ session_id: "aa11", tab_id: 9999 },
|
|
{ tabs: api, windows: windowsApi },
|
|
);
|
|
expect(res).toMatchObject({ code: "not_found" });
|
|
});
|
|
|
|
it("returnBorrowedTab surfaces a chrome.tabs.move failure as cdp_failed", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api } = makeTabMutationApi(state);
|
|
api.move = vi.fn(async () => {
|
|
throw new Error("simulated move failure");
|
|
});
|
|
const { api: windowsApi } = makeWindowsApi(state);
|
|
const res = await returnBorrowedTab(ctx, 7, { tabs: api, windows: windowsApi });
|
|
expect(res).toMatchObject({ code: "cdp_failed" });
|
|
});
|
|
|
|
it("falls back to a new window when moving back to the original position fails", async () => {
|
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
|
const ctx = await sm.start("aa11");
|
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
|
const state: FakeTabState = {
|
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
|
nextTabId: 50,
|
|
windowsClosed: new Set(),
|
|
};
|
|
const { api, spies } = makeTabMutationApi(state);
|
|
spies.move.mockImplementationOnce(async () => {
|
|
throw new Error("original index no longer valid");
|
|
});
|
|
const { api: windowsApi } = makeWindowsApi(state, {
|
|
lastFocused: 100,
|
|
createWindowId: 777,
|
|
});
|
|
|
|
const res = await returnBorrowedTab(ctx, 7, { tabs: api, windows: windowsApi });
|
|
|
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
|
expect(res.toWindowId).toBe(777);
|
|
expect(res.fallback).toBe(true);
|
|
expect(spies.move).toHaveBeenLastCalledWith(7, { windowId: 777, index: 0 });
|
|
});
|
|
});
|