feat: add workspace-aware browser sessions
This commit is contained in:
1 parent
0ea6e4a15c
commit
44f0bbe94d
13 files changed
+276
-150
No files matched your search
Vendored
+83
-57
@@ -160,30 +160,35 @@ function scheduleReconnect() {
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
let automationWindowId = null;
|
||||
let windowIdleTimer = null;
|
||||
const automationSessions = /* @__PURE__ */ new Map();
|
||||
const WINDOW_IDLE_TIMEOUT = 3e4;
|
||||
function resetWindowIdleTimer() {
|
||||
if (windowIdleTimer) clearTimeout(windowIdleTimer);
|
||||
windowIdleTimer = setTimeout(async () => {
|
||||
if (automationWindowId !== null) {
|
||||
try {
|
||||
await chrome.windows.remove(automationWindowId);
|
||||
console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`);
|
||||
} catch {
|
||||
}
|
||||
automationWindowId = null;
|
||||
function getWorkspaceKey(workspace) {
|
||||
return workspace?.trim() || "default";
|
||||
}
|
||||
function resetWindowIdleTimer(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
} catch {
|
||||
}
|
||||
windowIdleTimer = null;
|
||||
automationSessions.delete(workspace);
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
async function getAutomationWindow() {
|
||||
if (automationWindowId !== null) {
|
||||
async function getAutomationWindow(workspace) {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
try {
|
||||
await chrome.windows.get(automationWindowId);
|
||||
return automationWindowId;
|
||||
await chrome.windows.get(existing.windowId);
|
||||
return existing.windowId;
|
||||
} catch {
|
||||
automationWindowId = null;
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
const win = await chrome.windows.create({
|
||||
@@ -193,17 +198,22 @@ async function getAutomationWindow() {
|
||||
height: 900,
|
||||
type: "normal"
|
||||
});
|
||||
automationWindowId = win.id;
|
||||
console.log(`[opencli] Created automation window ${automationWindowId}`);
|
||||
return automationWindowId;
|
||||
const session = {
|
||||
windowId: win.id,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
return session.windowId;
|
||||
}
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
if (windowId === automationWindowId) {
|
||||
console.log("[opencli] Automation window closed");
|
||||
automationWindowId = null;
|
||||
if (windowIdleTimer) {
|
||||
clearTimeout(windowIdleTimer);
|
||||
windowIdleTimer = null;
|
||||
for (const [workspace, session] of automationSessions.entries()) {
|
||||
if (session.windowId === windowId) {
|
||||
console.log(`[opencli] Automation window closed (${workspace})`);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -226,21 +236,24 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "keepalive") connect();
|
||||
});
|
||||
async function handleCommand(cmd) {
|
||||
resetWindowIdleTimer();
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
resetWindowIdleTimer(workspace);
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case "exec":
|
||||
return await handleExec(cmd);
|
||||
return await handleExec(cmd, workspace);
|
||||
case "navigate":
|
||||
return await handleNavigate(cmd);
|
||||
return await handleNavigate(cmd, workspace);
|
||||
case "tabs":
|
||||
return await handleTabs(cmd);
|
||||
return await handleTabs(cmd, workspace);
|
||||
case "cookies":
|
||||
return await handleCookies(cmd);
|
||||
case "screenshot":
|
||||
return await handleScreenshot(cmd);
|
||||
return await handleScreenshot(cmd, workspace);
|
||||
case "close-window":
|
||||
return await handleCloseWindow(cmd);
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case "sessions":
|
||||
return await handleSessions(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
@@ -256,9 +269,9 @@ function isWebUrl(url) {
|
||||
if (!url) return false;
|
||||
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
|
||||
}
|
||||
async function resolveTabId(tabId) {
|
||||
async function resolveTabId(tabId, workspace) {
|
||||
if (tabId !== void 0) return tabId;
|
||||
const windowId = await getAutomationWindow();
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
|
||||
if (webTab?.id) return webTab.id;
|
||||
@@ -267,22 +280,23 @@ async function resolveTabId(tabId) {
|
||||
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
||||
return newTab.id;
|
||||
}
|
||||
async function listAutomationTabs() {
|
||||
if (automationWindowId === null) return [];
|
||||
async function listAutomationTabs(workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: automationWindowId });
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
automationWindowId = null;
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function listAutomationWebTabs() {
|
||||
const tabs = await listAutomationTabs();
|
||||
async function listAutomationWebTabs(workspace) {
|
||||
const tabs = await listAutomationTabs(workspace);
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
async function handleExec(cmd) {
|
||||
async function handleExec(cmd, workspace) {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await evaluateAsync(tabId, cmd.code);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
@@ -290,9 +304,9 @@ async function handleExec(cmd) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
async function handleNavigate(cmd) {
|
||||
async function handleNavigate(cmd, workspace) {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
await new Promise((resolve) => {
|
||||
chrome.tabs.get(tabId).then((tab2) => {
|
||||
@@ -316,10 +330,10 @@ async function handleNavigate(cmd) {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
}
|
||||
async function handleTabs(cmd) {
|
||||
async function handleTabs(cmd, workspace) {
|
||||
switch (cmd.op) {
|
||||
case "list": {
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const data = tabs.map((t, i) => ({
|
||||
index: i,
|
||||
tabId: t.id,
|
||||
@@ -330,20 +344,20 @@ async function handleTabs(cmd) {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case "new": {
|
||||
const windowId = await getAutomationWindow();
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? "about:blank", active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case "close": {
|
||||
if (cmd.index !== void 0) {
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
@@ -355,7 +369,7 @@ async function handleTabs(cmd) {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
@@ -381,8 +395,8 @@ async function handleCookies(cmd) {
|
||||
}));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
async function handleScreenshot(cmd) {
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
async function handleScreenshot(cmd, workspace) {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await screenshot(tabId, {
|
||||
format: cmd.format,
|
||||
@@ -394,13 +408,25 @@ async function handleScreenshot(cmd) {
|
||||
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
async function handleCloseWindow(cmd) {
|
||||
if (automationWindowId !== null) {
|
||||
async function handleCloseWindow(cmd, workspace) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(automationWindowId);
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
}
|
||||
automationWindowId = null;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
async function handleSessions(cmd) {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
workspace,
|
||||
windowId: session.windowId,
|
||||
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
|
||||
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
||||
})));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
@@ -104,9 +104,9 @@ describe('background tab isolation', () => {
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId(1);
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list' });
|
||||
const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual([
|
||||
@@ -125,11 +125,27 @@ describe('background tab isolation', () => {
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId(1);
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
|
||||
const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example' });
|
||||
const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter');
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
|
||||
});
|
||||
|
||||
it('reports sessions per workspace', async () => {
|
||||
const { chrome } = createChromeMock();
|
||||
vi.stubGlobal('chrome', chrome);
|
||||
|
||||
const mod = await import('./background');
|
||||
mod.__test__.setAutomationWindowId('site:twitter', 1);
|
||||
mod.__test__.setAutomationWindowId('site:zhihu', 2);
|
||||
|
||||
const result = await mod.__test__.handleSessions({ id: '3', action: 'sessions' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.data).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ workspace: 'site:twitter', windowId: 1 }),
|
||||
expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
+107
-59
@@ -90,36 +90,48 @@ function scheduleReconnect(): void {
|
||||
// user's active browsing session is never touched.
|
||||
// The window auto-closes after 30s of idle (no commands).
|
||||
|
||||
let automationWindowId: number | null = null;
|
||||
let windowIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
type AutomationSession = {
|
||||
windowId: number;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
idleDeadlineAt: number;
|
||||
};
|
||||
|
||||
const automationSessions = new Map<string, AutomationSession>();
|
||||
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
|
||||
|
||||
function resetWindowIdleTimer(): void {
|
||||
if (windowIdleTimer) clearTimeout(windowIdleTimer);
|
||||
windowIdleTimer = setTimeout(async () => {
|
||||
if (automationWindowId !== null) {
|
||||
try {
|
||||
await chrome.windows.remove(automationWindowId);
|
||||
console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`);
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
automationWindowId = null;
|
||||
function getWorkspaceKey(workspace?: string): string {
|
||||
return workspace?.trim() || 'default';
|
||||
}
|
||||
|
||||
function resetWindowIdleTimer(workspace: string): void {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
||||
session.idleTimer = setTimeout(async () => {
|
||||
const current = automationSessions.get(workspace);
|
||||
if (!current) return;
|
||||
try {
|
||||
await chrome.windows.remove(current.windowId);
|
||||
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
windowIdleTimer = null;
|
||||
automationSessions.delete(workspace);
|
||||
}, WINDOW_IDLE_TIMEOUT);
|
||||
}
|
||||
|
||||
/** Get or create the dedicated automation window. */
|
||||
async function getAutomationWindow(): Promise<number> {
|
||||
async function getAutomationWindow(workspace: string): Promise<number> {
|
||||
// Check if our window is still alive
|
||||
if (automationWindowId !== null) {
|
||||
const existing = automationSessions.get(workspace);
|
||||
if (existing) {
|
||||
try {
|
||||
await chrome.windows.get(automationWindowId);
|
||||
return automationWindowId;
|
||||
await chrome.windows.get(existing.windowId);
|
||||
return existing.windowId;
|
||||
} catch {
|
||||
// Window was closed by user
|
||||
automationWindowId = null;
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,17 +143,25 @@ async function getAutomationWindow(): Promise<number> {
|
||||
height: 900,
|
||||
type: 'normal',
|
||||
});
|
||||
automationWindowId = win.id!;
|
||||
console.log(`[opencli] Created automation window ${automationWindowId}`);
|
||||
return automationWindowId;
|
||||
const session: AutomationSession = {
|
||||
windowId: win.id!,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
};
|
||||
automationSessions.set(workspace, session);
|
||||
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
||||
resetWindowIdleTimer(workspace);
|
||||
return session.windowId;
|
||||
}
|
||||
|
||||
// Clean up when the automation window is closed
|
||||
chrome.windows.onRemoved.addListener((windowId) => {
|
||||
if (windowId === automationWindowId) {
|
||||
console.log('[opencli] Automation window closed');
|
||||
automationWindowId = null;
|
||||
if (windowIdleTimer) { clearTimeout(windowIdleTimer); windowIdleTimer = null; }
|
||||
for (const [workspace, session] of automationSessions.entries()) {
|
||||
if (session.windowId === windowId) {
|
||||
console.log(`[opencli] Automation window closed (${workspace})`);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,22 +193,25 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
// ─── Command dispatcher ─────────────────────────────────────────────
|
||||
|
||||
async function handleCommand(cmd: Command): Promise<Result> {
|
||||
const workspace = getWorkspaceKey(cmd.workspace);
|
||||
// Reset idle timer on every command (window stays alive while active)
|
||||
resetWindowIdleTimer();
|
||||
resetWindowIdleTimer(workspace);
|
||||
try {
|
||||
switch (cmd.action) {
|
||||
case 'exec':
|
||||
return await handleExec(cmd);
|
||||
return await handleExec(cmd, workspace);
|
||||
case 'navigate':
|
||||
return await handleNavigate(cmd);
|
||||
return await handleNavigate(cmd, workspace);
|
||||
case 'tabs':
|
||||
return await handleTabs(cmd);
|
||||
return await handleTabs(cmd, workspace);
|
||||
case 'cookies':
|
||||
return await handleCookies(cmd);
|
||||
case 'screenshot':
|
||||
return await handleScreenshot(cmd);
|
||||
return await handleScreenshot(cmd, workspace);
|
||||
case 'close-window':
|
||||
return await handleCloseWindow(cmd);
|
||||
return await handleCloseWindow(cmd, workspace);
|
||||
case 'sessions':
|
||||
return await handleSessions(cmd);
|
||||
default:
|
||||
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
||||
}
|
||||
@@ -214,11 +237,11 @@ function isWebUrl(url?: string): boolean {
|
||||
* If explicit tabId is given, use that directly.
|
||||
* Otherwise, find or create a tab in the dedicated automation window.
|
||||
*/
|
||||
async function resolveTabId(tabId?: number): Promise<number> {
|
||||
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
|
||||
if (tabId !== undefined) return tabId;
|
||||
|
||||
// Get (or create) the automation window
|
||||
const windowId = await getAutomationWindow();
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
|
||||
// Find the active tab in our automation window
|
||||
const tabs = await chrome.tabs.query({ windowId });
|
||||
@@ -234,24 +257,25 @@ async function resolveTabId(tabId?: number): Promise<number> {
|
||||
return newTab.id;
|
||||
}
|
||||
|
||||
async function listAutomationTabs(): Promise<chrome.tabs.Tab[]> {
|
||||
if (automationWindowId === null) return [];
|
||||
async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (!session) return [];
|
||||
try {
|
||||
return await chrome.tabs.query({ windowId: automationWindowId });
|
||||
return await chrome.tabs.query({ windowId: session.windowId });
|
||||
} catch {
|
||||
automationWindowId = null;
|
||||
automationSessions.delete(workspace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function listAutomationWebTabs(): Promise<chrome.tabs.Tab[]> {
|
||||
const tabs = await listAutomationTabs();
|
||||
async function listAutomationWebTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
|
||||
const tabs = await listAutomationTabs(workspace);
|
||||
return tabs.filter((tab) => isWebUrl(tab.url));
|
||||
}
|
||||
|
||||
async function handleExec(cmd: Command): Promise<Result> {
|
||||
async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.evaluateAsync(tabId, cmd.code);
|
||||
return { id: cmd.id, ok: true, data };
|
||||
@@ -260,9 +284,9 @@ async function handleExec(cmd: Command): Promise<Result> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNavigate(cmd: Command): Promise<Result> {
|
||||
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
|
||||
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.update(tabId, { url: cmd.url });
|
||||
|
||||
// Wait for page to finish loading, checking current status first to avoid race
|
||||
@@ -290,10 +314,10 @@ async function handleNavigate(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
||||
}
|
||||
|
||||
async function handleTabs(cmd: Command): Promise<Result> {
|
||||
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
||||
switch (cmd.op) {
|
||||
case 'list': {
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const data = tabs
|
||||
.map((t, i) => ({
|
||||
index: i,
|
||||
@@ -305,20 +329,20 @@ async function handleTabs(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
case 'new': {
|
||||
const windowId = await getAutomationWindow();
|
||||
const windowId = await getAutomationWindow(workspace);
|
||||
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? 'about:blank', active: true });
|
||||
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
||||
}
|
||||
case 'close': {
|
||||
if (cmd.index !== undefined) {
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.remove(target.id);
|
||||
executor.detach(target.id);
|
||||
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
||||
}
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
await chrome.tabs.remove(tabId);
|
||||
executor.detach(tabId);
|
||||
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
||||
@@ -330,7 +354,7 @@ async function handleTabs(cmd: Command): Promise<Result> {
|
||||
await chrome.tabs.update(cmd.tabId, { active: true });
|
||||
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
||||
}
|
||||
const tabs = await listAutomationWebTabs();
|
||||
const tabs = await listAutomationWebTabs(workspace);
|
||||
const target = tabs[cmd.index!];
|
||||
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
||||
await chrome.tabs.update(target.id, { active: true });
|
||||
@@ -358,8 +382,8 @@ async function handleCookies(cmd: Command): Promise<Result> {
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
async function handleScreenshot(cmd: Command): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId);
|
||||
async function handleScreenshot(cmd: Command, workspace: string): Promise<Result> {
|
||||
const tabId = await resolveTabId(cmd.tabId, workspace);
|
||||
try {
|
||||
const data = await executor.screenshot(tabId, {
|
||||
format: cmd.format,
|
||||
@@ -372,22 +396,46 @@ async function handleScreenshot(cmd: Command): Promise<Result> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseWindow(cmd: Command): Promise<Result> {
|
||||
if (automationWindowId !== null) {
|
||||
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session) {
|
||||
try {
|
||||
await chrome.windows.remove(automationWindowId);
|
||||
await chrome.windows.remove(session.windowId);
|
||||
} catch {
|
||||
// Window may already be closed
|
||||
}
|
||||
automationWindowId = null;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
}
|
||||
return { id: cmd.id, ok: true, data: { closed: true } };
|
||||
}
|
||||
|
||||
async function handleSessions(cmd: Command): Promise<Result> {
|
||||
const now = Date.now();
|
||||
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
||||
workspace,
|
||||
windowId: session.windowId,
|
||||
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
|
||||
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now),
|
||||
})));
|
||||
return { id: cmd.id, ok: true, data };
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
handleTabs,
|
||||
getAutomationWindowId: () => automationWindowId,
|
||||
setAutomationWindowId: (windowId: number | null) => {
|
||||
automationWindowId = windowId;
|
||||
handleSessions,
|
||||
getAutomationWindowId: (workspace: string = 'default') => automationSessions.get(workspace)?.windowId ?? null,
|
||||
setAutomationWindowId: (workspace: string, windowId: number | null) => {
|
||||
if (windowId === null) {
|
||||
const session = automationSessions.get(workspace);
|
||||
if (session?.idleTimer) clearTimeout(session.idleTimer);
|
||||
automationSessions.delete(workspace);
|
||||
return;
|
||||
}
|
||||
automationSessions.set(workspace, {
|
||||
windowId,
|
||||
idleTimer: null,
|
||||
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
* Everything else is just JS code sent via 'exec'.
|
||||
*/
|
||||
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
||||
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
||||
|
||||
export interface Command {
|
||||
/** Unique request ID */
|
||||
@@ -16,6 +16,8 @@ export interface Command {
|
||||
tabId?: number;
|
||||
/** JS code to evaluate in page context (exec action) */
|
||||
code?: string;
|
||||
/** Logical workspace for automation session reuse */
|
||||
workspace?: string;
|
||||
/** URL to navigate to (navigate action) */
|
||||
url?: string;
|
||||
/** Sub-operation for tabs: list, new, close, select */
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export class CDPBridge {
|
||||
private _idCounter = 0;
|
||||
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void }>();
|
||||
|
||||
async connect(opts?: { timeout?: number }): Promise<IPage> {
|
||||
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
||||
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ function generateId(): string {
|
||||
|
||||
export interface DaemonCommand {
|
||||
id: string;
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
||||
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
||||
tabId?: number;
|
||||
code?: string;
|
||||
workspace?: string;
|
||||
url?: string;
|
||||
op?: string;
|
||||
index?: number;
|
||||
@@ -111,3 +112,8 @@ export async function sendCommand(
|
||||
// Unreachable — the loop always returns or throws
|
||||
throw new Error('sendCommand: max retries exhausted');
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<any[]> {
|
||||
const result = await sendCommand('sessions');
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
+2
-2
@@ -26,7 +26,7 @@ export class BrowserBridge {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
||||
async connect(opts: { timeout?: number; workspace?: string } = {}): Promise<IPage> {
|
||||
if (this._state === 'connected' && this._page) return this._page;
|
||||
if (this._state === 'connecting') throw new Error('Already connecting');
|
||||
if (this._state === 'closing') throw new Error('Session is closing');
|
||||
@@ -36,7 +36,7 @@ export class BrowserBridge {
|
||||
|
||||
try {
|
||||
await this._ensureDaemon(opts.timeout);
|
||||
this._page = new Page();
|
||||
this._page = new Page(opts.workspace);
|
||||
this._state = 'connected';
|
||||
return this._page;
|
||||
} catch (err) {
|
||||
|
||||
+23
-14
@@ -19,6 +19,8 @@ import { wrapForEval } from './utils.js';
|
||||
* Page — implements IPage by talking to the daemon via HTTP.
|
||||
*/
|
||||
export class Page implements IPage {
|
||||
constructor(private readonly workspace: string = 'default') {}
|
||||
|
||||
/** Active tab ID, set after navigate and used in all subsequent commands */
|
||||
private _tabId: number | undefined;
|
||||
|
||||
@@ -27,9 +29,14 @@ export class Page implements IPage {
|
||||
return this._tabId !== undefined ? { tabId: this._tabId } : {};
|
||||
}
|
||||
|
||||
private _workspaceOpt(): { workspace: string } {
|
||||
return { workspace: this.workspace };
|
||||
}
|
||||
|
||||
async goto(url: string): Promise<void> {
|
||||
const result = await sendCommand('navigate', {
|
||||
url,
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
}) as { tabId?: number };
|
||||
// Remember the tabId for subsequent exec calls
|
||||
@@ -41,7 +48,7 @@ export class Page implements IPage {
|
||||
/** Close the automation window in the extension */
|
||||
async closeWindow(): Promise<void> {
|
||||
try {
|
||||
await sendCommand('close-window', {});
|
||||
await sendCommand('close-window', { ...this._workspaceOpt() });
|
||||
} catch {
|
||||
// Window may already be closed or daemon may be down
|
||||
}
|
||||
@@ -49,11 +56,11 @@ export class Page implements IPage {
|
||||
|
||||
async evaluate(js: string): Promise<any> {
|
||||
const code = wrapForEval(js);
|
||||
return sendCommand('exec', { code, ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
|
||||
const result = await sendCommand('cookies', opts);
|
||||
const result = await sendCommand('cookies', { ...this._workspaceOpt(), ...opts });
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
@@ -86,7 +93,7 @@ export class Page implements IPage {
|
||||
return buildTree(document.body, 0);
|
||||
})()
|
||||
`;
|
||||
const raw = await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
const raw = await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
if (opts.raw) return raw;
|
||||
if (typeof raw === 'string') return formatSnapshot(raw, opts);
|
||||
return raw;
|
||||
@@ -105,7 +112,7 @@ export class Page implements IPage {
|
||||
return 'clicked';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async typeText(ref: string, text: string): Promise<void> {
|
||||
@@ -124,7 +131,7 @@ export class Page implements IPage {
|
||||
return 'typed';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async pressKey(key: string): Promise<void> {
|
||||
@@ -136,7 +143,7 @@ export class Page implements IPage {
|
||||
return 'pressed';
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async wait(options: number | { text?: string; time?: number; timeout?: number }): Promise<void> {
|
||||
@@ -161,24 +168,24 @@ export class Page implements IPage {
|
||||
check();
|
||||
})
|
||||
`;
|
||||
await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
}
|
||||
|
||||
async tabs(): Promise<any> {
|
||||
return sendCommand('tabs', { op: 'list' });
|
||||
return sendCommand('tabs', { op: 'list', ...this._workspaceOpt() });
|
||||
}
|
||||
|
||||
async closeTab(index?: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'close', ...(index !== undefined ? { index } : {}) });
|
||||
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
|
||||
}
|
||||
|
||||
async newTab(): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'new' });
|
||||
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
|
||||
}
|
||||
|
||||
async selectTab(index: number): Promise<void> {
|
||||
await sendCommand('tabs', { op: 'select', index });
|
||||
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
|
||||
}
|
||||
|
||||
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
||||
@@ -195,7 +202,7 @@ export class Page implements IPage {
|
||||
}));
|
||||
})()
|
||||
`;
|
||||
return sendCommand('exec', { code, ...this._tabOpt() });
|
||||
return sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +228,7 @@ export class Page implements IPage {
|
||||
path?: string;
|
||||
} = {}): Promise<string> {
|
||||
const base64 = await sendCommand('screenshot', {
|
||||
...this._workspaceOpt(),
|
||||
format: options.format,
|
||||
quality: options.quality,
|
||||
fullPage: options.fullPage,
|
||||
@@ -243,6 +251,7 @@ export class Page implements IPage {
|
||||
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
||||
await sendCommand('exec', {
|
||||
code: `window.scrollBy(${dx}, ${dy})`,
|
||||
...this._workspaceOpt(),
|
||||
...this._tabOpt(),
|
||||
});
|
||||
}
|
||||
@@ -270,7 +279,7 @@ export class Page implements IPage {
|
||||
}
|
||||
})()
|
||||
`;
|
||||
await sendCommand('exec', { code, ...this._tabOpt() });
|
||||
await sendCommand('exec', { code, ...this._workspaceOpt(), ...this._tabOpt() });
|
||||
}
|
||||
|
||||
async installInterceptor(pattern: string): Promise<void> {
|
||||
|
||||
+6
-5
@@ -64,13 +64,13 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
});
|
||||
|
||||
program.command('explore').alias('probe').description('Explore a website: discover APIs, stores, and recommend strategies').argument('<url>').option('--site <name>').option('--goal <text>').option('--wait <s>', '', '3').option('--auto', 'Enable interactive fuzzing (simulate clicks to trigger lazy APIs)').option('--click <labels>', 'Comma-separated labels to click before fuzzing (e.g. "字幕,CC,评论")')
|
||||
.action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory as any, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels }))); });
|
||||
.action(async (url, opts) => { const { exploreUrl, renderExploreSummary } = await import('./explore.js'); const clickLabels = opts.click ? opts.click.split(',').map((s: string) => s.trim()) : undefined; const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const workspace = `explore:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}`; console.log(renderExploreSummary(await exploreUrl(url, { BrowserFactory: BrowserFactory as any, site: opts.site, goal: opts.goal, waitSeconds: parseFloat(opts.wait), auto: opts.auto, clickLabels, workspace }))); });
|
||||
|
||||
program.command('synthesize').description('Synthesize CLIs from explore').argument('<target>').option('--top <n>', '', '3')
|
||||
.action(async (target, opts) => { const { synthesizeFromExplore, renderSynthesizeSummary } = await import('./synthesize.js'); console.log(renderSynthesizeSummary(synthesizeFromExplore(target, { top: parseInt(opts.top) }))); });
|
||||
|
||||
program.command('generate').description('One-shot: explore → synthesize → register').argument('<url>').option('--goal <text>').option('--site <name>')
|
||||
.action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory as any, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; });
|
||||
.action(async (url, opts) => { const { generateCliFromUrl, renderGenerateSummary } = await import('./generate.js'); const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge; const workspace = `generate:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}`; const r = await generateCliFromUrl({ url, BrowserFactory: BrowserFactory as any, builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, goal: opts.goal, site: opts.site, workspace }); console.log(renderGenerateSummary(r)); process.exitCode = r.ok ? 0 : 1; });
|
||||
|
||||
program.command('cascade').description('Strategy cascade: find simplest working strategy').argument('<url>').option('--site <name>')
|
||||
.action(async (url, opts) => {
|
||||
@@ -80,16 +80,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
// Navigate to the site first for cookie context
|
||||
try { const siteUrl = new URL(url); await page.goto(`${siteUrl.protocol}//${siteUrl.host}`); await page.wait(2); } catch {}
|
||||
return cascadeProbe(page, url);
|
||||
});
|
||||
}, { workspace: `cascade:${opts.site ?? (() => { try { return new URL(url).host; } catch { return 'default'; } })()}` });
|
||||
console.log(renderCascadeResult(result));
|
||||
});
|
||||
|
||||
program.command('doctor')
|
||||
.description('Diagnose opencli browser bridge connectivity')
|
||||
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
||||
.option('--sessions', 'Show active automation sessions', false)
|
||||
.action(async (opts) => {
|
||||
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
|
||||
const report = await runBrowserDoctor({ live: opts.live, cliVersion: PKG_VERSION });
|
||||
const report = await runBrowserDoctor({ live: opts.live, sessions: opts.sessions, cliVersion: PKG_VERSION });
|
||||
console.log(renderBrowserDoctorReport(report));
|
||||
});
|
||||
|
||||
@@ -161,7 +162,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
||||
const BrowserFactory = process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge;
|
||||
result = await browserSession(BrowserFactory as any, async (page) => {
|
||||
return runWithTimeout(executeCommand(cmd, page, kwargs, actionOpts.verbose), { timeout: cmd.timeoutSeconds ?? DEFAULT_BROWSER_COMMAND_TIMEOUT, label: fullName(cmd) });
|
||||
});
|
||||
}, { workspace: `site:${cmd.site}` });
|
||||
} else { result = await executeCommand(cmd, null, kwargs, actionOpts.verbose); }
|
||||
if (actionOpts.verbose && (!result || (Array.isArray(result) && result.length === 0))) {
|
||||
console.error(chalk.yellow(`[Verbose] Warning: Command returned an empty result. If the website structural API changed or requires authentication, check the network or update the adapter.`));
|
||||
|
||||
+18
-2
@@ -8,12 +8,13 @@
|
||||
import chalk from 'chalk';
|
||||
import { checkDaemonStatus } from './browser/discover.js';
|
||||
import { BrowserBridge } from './browser/index.js';
|
||||
import { browserSession } from './runtime.js';
|
||||
import { listSessions } from './browser/daemon-client.js';
|
||||
|
||||
export type DoctorOptions = {
|
||||
fix?: boolean;
|
||||
yes?: boolean;
|
||||
live?: boolean;
|
||||
sessions?: boolean;
|
||||
cliVersion?: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ export type DoctorReport = {
|
||||
daemonRunning: boolean;
|
||||
extensionConnected: boolean;
|
||||
connectivity?: ConnectivityResult;
|
||||
sessions?: Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
@@ -55,6 +57,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
if (opts.live) {
|
||||
connectivity = await checkConnectivity();
|
||||
}
|
||||
const sessions = opts.sessions && status.running && status.extensionConnected
|
||||
? await listSessions() as Array<{ workspace: string; windowId: number; tabCount: number; idleMsRemaining: number }>
|
||||
: undefined;
|
||||
|
||||
const issues: string[] = [];
|
||||
if (!status.running) {
|
||||
@@ -78,6 +83,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
||||
daemonRunning: status.running,
|
||||
extensionConnected: status.extensionConnected,
|
||||
connectivity,
|
||||
sessions,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -104,6 +110,17 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
|
||||
}
|
||||
|
||||
if (report.sessions) {
|
||||
lines.push('', chalk.bold('Sessions:'));
|
||||
if (report.sessions.length === 0) {
|
||||
lines.push(chalk.dim(' • no active automation sessions'));
|
||||
} else {
|
||||
for (const session of report.sessions) {
|
||||
lines.push(chalk.dim(` • ${session.workspace} → window ${session.windowId}, tabs=${session.tabCount}, idle=${Math.ceil(session.idleMsRemaining / 1000)}s`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (report.issues.length) {
|
||||
lines.push('', chalk.yellow('Issues:'));
|
||||
for (const issue of report.issues) {
|
||||
@@ -115,4 +132,3 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
+2
-2
@@ -231,7 +231,7 @@ export async function exploreUrl(
|
||||
BrowserFactory: new () => any;
|
||||
site?: string; goal?: string; authenticated?: boolean;
|
||||
outDir?: string; waitSeconds?: number; query?: string;
|
||||
clickLabels?: string[]; auto?: boolean;
|
||||
clickLabels?: string[]; auto?: boolean; workspace?: string;
|
||||
},
|
||||
): Promise<Record<string, any>> {
|
||||
const waitSeconds = opts.waitSeconds ?? 3.0;
|
||||
@@ -438,7 +438,7 @@ export async function exploreUrl(
|
||||
|
||||
return { ...result, out_dir: targetDir };
|
||||
})(), { timeout: exploreTimeout, label: `Explore ${url}` });
|
||||
});
|
||||
}, { workspace: opts.workspace });
|
||||
}
|
||||
|
||||
export function renderExploreSummary(result: Record<string, any>): string {
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function generateCliFromUrl(opts: any): Promise<any> {
|
||||
site: opts.site,
|
||||
goal: normalizeGoal(opts.goal) ?? opts.goal,
|
||||
waitSeconds: opts.waitSeconds ?? 3,
|
||||
workspace: opts.workspace,
|
||||
});
|
||||
|
||||
// Step 2: Synthesize candidates
|
||||
|
||||
+3
-2
@@ -30,17 +30,18 @@ export function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message
|
||||
|
||||
/** Interface for browser factory (BrowserBridge or test mocks) */
|
||||
export interface IBrowserFactory {
|
||||
connect(opts?: { timeout?: number }): Promise<IPage>;
|
||||
connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function browserSession<T>(
|
||||
BrowserFactory: new () => IBrowserFactory,
|
||||
fn: (page: IPage) => Promise<T>,
|
||||
opts: { workspace?: string } = {},
|
||||
): Promise<T> {
|
||||
const mcp = new BrowserFactory();
|
||||
try {
|
||||
const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT });
|
||||
const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, workspace: opts.workspace });
|
||||
return await fn(page);
|
||||
} finally {
|
||||
await mcp.close().catch(() => {});
|
||||
|
||||
Reference in new issue
Block a user