fix(browser): keep tab stable across snapshot and act

This commit is contained in:
Peter Steinberger
2026-01-15 09:36:48 +00:00
parent 415ff7f483
commit f9170c5d02
4 changed files with 134 additions and 11 deletions

View File

@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import type { BrowserServerState } from "./server-context.js";
import { createBrowserRouteContext } from "./server-context.js";
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => true),
isChromeReachable: vi.fn(async () => true),
launchClawdChrome: vi.fn(async () => {
throw new Error("unexpected launch");
}),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {}),
}));
describe("browser server-context ensureTabAvailable", () => {
it("sticks to the last selected target when targetId is omitted", async () => {
const fetchMock = vi.fn();
// 1st call (snapshot): stable ordering A then B (twice)
// 2nd call (act): reversed ordering B then A (twice)
const responses = [
[
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
],
[
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
],
[
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
],
[
{ id: "B", type: "page", url: "https://b.example", webSocketDebuggerUrl: "ws://x/b" },
{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" },
],
];
fetchMock.mockImplementation(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
const next = responses.shift();
if (!next) {
throw new Error("no more responses");
}
return {
ok: true,
json: async () => next,
} as unknown as Response;
});
// @ts-expect-error test override
global.fetch = fetchMock;
const state: BrowserServerState = {
// unused in these tests
// biome-ignore lint/suspicious/noExplicitAny: test stub
server: null as any,
port: 0,
resolved: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: false,
defaultProfile: "chrome",
profiles: {
chrome: {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
cdpPort: 18792,
color: "#00AA00",
},
clawd: { cdpPort: 18800, color: "#FF4500" },
},
},
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
});
const chrome = ctx.forProfile("chrome");
const first = await chrome.ensureTabAvailable();
expect(first.targetId).toBe("A");
const second = await chrome.ensureTabAvailable();
expect(second.targetId).toBe("A");
});
});

View File

@@ -86,7 +86,7 @@ function createProfileContext(
const current = state();
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null };
profileState = { profile, running: null, lastTargetId: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
@@ -158,6 +158,8 @@ function createProfileContext(
});
if (!created.id) throw new Error("Failed to open tab (missing id)");
const profileState = getProfileState();
profileState.lastTargetId = created.id;
return {
targetId: created.id,
title: created.title ?? "",
@@ -276,27 +278,43 @@ function createProfileContext(
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
const tabs1 = await listTabs();
if (tabs1.length === 0) {
if (profile.driver === "extension") {
throw new Error("tab not found");
}
await openTab("about:blank");
}
const tabs = await listTabs();
const chosen = targetId
? (() => {
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") return "AMBIGUOUS" as const;
return null;
}
return tabs.find((t) => t.targetId === resolved.targetId) ?? null;
})()
: (tabs.at(0) ?? null);
const candidates = tabs.filter((t) => Boolean(t.wsUrl));
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") return "AMBIGUOUS" as const;
return null;
}
return candidates.find((t) => t.targetId === resolved.targetId) ?? null;
};
const pickDefault = () => {
const last = profileState.lastTargetId?.trim() || "";
const lastResolved = last ? resolveById(last) : null;
if (lastResolved && lastResolved !== "AMBIGUOUS") return lastResolved;
// Prefer a real page tab first (avoid service workers/background targets).
const page = candidates.find((t) => (t.type ?? "page") === "page");
return page ?? (candidates.at(0) ?? null);
};
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix");
}
if (!chosen?.wsUrl) throw new Error("tab not found");
profileState.lastTargetId = chosen.targetId;
return chosen;
};
@@ -311,6 +329,8 @@ function createProfileContext(
throw new Error("tab not found");
}
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
const profileState = getProfileState();
profileState.lastTargetId = resolved.targetId;
};
const closeTab = async (targetId: string): Promise<void> => {

View File

@@ -12,6 +12,8 @@ export type { BrowserTab };
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
lastTargetId?: string | null;
};
export type BrowserServerState = {