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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,30 +317,26 @@ function setWorkspaceSession(workspace: string, session: Pick<AutomationSession,
});
}

type ResolvedTab = { tabId: number; tab: chrome.tabs.Tab | null };

/**
* Resolve target tab in the automation window.
* If explicit tabId is given, use that directly.
* Otherwise, find or create a tab in the dedicated automation window.
* @param initialUrl — passed to getAutomationWindow for first-time window creation.
* Resolve target tab in the automation window, returning both the tabId and
* the Tab object (when available) so callers can skip a redundant chrome.tabs.get().
*/
async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> {
async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<ResolvedTab> {
// Even when an explicit tabId is provided, validate it is still debuggable.
// This prevents issues when extensions hijack the tab URL to chrome-extension://
// or when the tab has been closed by the user.
if (tabId !== undefined) {
try {
const tab = await chrome.tabs.get(tabId);
const session = automationSessions.get(workspace);
const matchesSession = session ? tab.windowId === session.windowId : false;
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab };
if (session && !matchesSession) {
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
} else if (!isDebuggableUrl(tab.url)) {
// Tab exists but URL is not debuggable — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
}
} catch {
// Tab was closed — fall through to auto-resolve
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
}
}
Expand All @@ -351,17 +347,16 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
// Prefer an existing debuggable tab
const tabs = await chrome.tabs.query({ windowId });
const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab };

// No debuggable tab — another extension may have hijacked the tab URL.
// Try to reuse by navigating to a data: URI (not interceptable by New Tab Override).
const reuseTab = tabs.find(t => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
await new Promise(resolve => setTimeout(resolve, 300));
try {
const updated = await chrome.tabs.get(reuseTab.id);
if (isDebuggableUrl(updated.url)) return reuseTab.id;
if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated };
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
} catch {
// Tab was closed during navigation
Expand All @@ -371,7 +366,13 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia
// Fallback: create a new tab
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
if (!newTab.id) throw new Error('Failed to create tab in automation window');
return newTab.id;
return { tabId: newTab.id, tab: newTab };
}

/** Convenience wrapper returning just the tabId (used by most handlers) */
async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise<number> {
const resolved = await resolveTab(tabId, workspace, initialUrl);
return resolved.tabId;
}

async function listAutomationTabs(workspace: string): Promise<chrome.tabs.Tab[]> {
Expand Down Expand Up @@ -408,9 +409,10 @@ async function handleNavigate(cmd: Command, workspace: string): Promise<Result>
return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' };
}
// Pass target URL so that first-time window creation can start on the right domain
const tabId = await resolveTabId(cmd.tabId, workspace, cmd.url);
const resolved = await resolveTab(cmd.tabId, workspace, cmd.url);
const tabId = resolved.tabId;

const beforeTab = await chrome.tabs.get(tabId);
const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
const targetUrl = cmd.url;

Expand Down
13 changes: 12 additions & 1 deletion src/browser/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { formatSnapshot } from '../snapshotFormatter.js';

export abstract class BasePage implements IPage {
protected _lastUrl: string | null = null;
/** Cached previous snapshot hashes for incremental diff marking */
private _prevSnapshotHashes: string | null = null;

// ── Transport-specific methods (must be implemented by subclasses) ──

Expand Down Expand Up @@ -137,10 +139,19 @@ export abstract class BasePage implements IPage {
maxTextLength: opts.maxTextLength ?? 120,
includeScrollInfo: true,
bboxDedup: true,
previousHashes: this._prevSnapshotHashes,
});

try {
return await this.evaluate(snapshotJs);
const result = await this.evaluate(snapshotJs);
// Read back the hashes stored by the snapshot for next diff
try {
const hashes = await this.evaluate('window.__opencli_prev_hashes') as string | null;
this._prevSnapshotHashes = typeof hashes === 'string' ? hashes : null;
} catch {
// Non-fatal: diff is best-effort
}
return result;
} catch {
return this._basicSnapshot(opts);
}
Expand Down
Loading