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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.9.0",
"version": "1.9.1",
"description": "Command-line interface for Firecrawl. Scrape, crawl, and extract data from any website directly from your terminal.",
"main": "dist/index.js",
"bin": {
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/commands/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,93 @@ describe('Browser Commands', () => {
expect.objectContaining({ id: 'new-session' })
);
});

it('quick execute retries with new session on 403 Forbidden', async () => {
const { clearBrowserSession, saveBrowserSession } =
await import('../../utils/browser-session');

// First execute call fails with 403 (stale cached session)
const forbiddenError = Object.assign(new Error('Forbidden'), {
status: 403,
});
mockClient.browserExecute
.mockRejectedValueOnce(forbiddenError)
.mockResolvedValueOnce({
success: true,
stdout: 'page loaded',
});

// Launch new session succeeds
mockClient.browser.mockResolvedValue({
success: true,
id: 'fresh-session',
cdpUrl: 'wss://fresh',
});

await handleBrowserQuickExecute({ code: 'open https://example.com' });

expect(clearBrowserSession).toHaveBeenCalled();
expect(mockClient.browser).toHaveBeenCalledTimes(1);
expect(saveBrowserSession).toHaveBeenCalledWith(
expect.objectContaining({ id: 'fresh-session' })
);
expect(mockClient.browserExecute).toHaveBeenCalledTimes(2);
});

it('quick execute retries with new session on 410 Gone', async () => {
const { clearBrowserSession } = await import('../../utils/browser-session');

const goneError = Object.assign(new Error('Gone'), { status: 410 });
mockClient.browserExecute
.mockRejectedValueOnce(goneError)
.mockResolvedValueOnce({
success: true,
stdout: 'page loaded',
});

mockClient.browser.mockResolvedValue({
success: true,
id: 'fresh-session',
cdpUrl: 'wss://fresh',
});

await handleBrowserQuickExecute({ code: 'open https://example.com' });

expect(clearBrowserSession).toHaveBeenCalled();
expect(mockClient.browser).toHaveBeenCalledTimes(1);
expect(mockClient.browserExecute).toHaveBeenCalledTimes(2);
});

it('quick execute retries on "session destroyed" error message', async () => {
const { clearBrowserSession } = await import('../../utils/browser-session');

mockClient.browserExecute
.mockRejectedValueOnce(new Error('session has been destroyed'))
.mockResolvedValueOnce({
success: true,
stdout: 'ok',
});

mockClient.browser.mockResolvedValue({
success: true,
id: 'fresh-session',
cdpUrl: 'wss://fresh',
});

await handleBrowserQuickExecute({ code: 'open https://example.com' });

expect(clearBrowserSession).toHaveBeenCalled();
expect(mockClient.browserExecute).toHaveBeenCalledTimes(2);
});

it('quick execute exits on non-session errors without retry', async () => {
mockClient.browserExecute.mockRejectedValueOnce(
new Error('Network timeout')
);

await handleBrowserQuickExecute({ code: 'open https://example.com' });

expect(mockClient.browser).not.toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(1);
});
});
131 changes: 94 additions & 37 deletions src/commands/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ function isSessionExpiredError(error: unknown): boolean {
? error.message.toLowerCase()
: String(error).toLowerCase();
const status = (error as { status?: number }).status;
if (status === 410 || status === 404) return true;
if (status === 403 || status === 410 || status === 404) return true;
return /destroyed|expired|not found|gone|session.*closed/i.test(msg);
}

Expand Down Expand Up @@ -282,42 +282,44 @@ export async function handleBrowserList(
export async function handleBrowserQuickExecute(
options: BrowserQuickExecuteOptions
): Promise<void> {
// Auto-launch if no active session
const existing = loadBrowserSession();
if (!existing) {
try {
const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl });

const launchArgs: {
profile?: {
name: string;
saveChanges?: boolean;
};
} = {};
if (options.profile) {
launchArgs.profile = {
name: options.profile,
saveChanges: options.saveChanges,
};
}
async function launchNewSession(): Promise<void> {
const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl });

const data = await app.browser(
launchArgs as Parameters<typeof app.browser>[0]
);
const launchArgs: {
profile?: {
name: string;
saveChanges?: boolean;
};
} = {};
if (options.profile) {
launchArgs.profile = {
name: options.profile,
saveChanges: options.saveChanges,
};
}

if (!data.success) {
console.error('Error:', data.error || 'Failed to launch session');
process.exit(1);
}
const data = await app.browser(
launchArgs as Parameters<typeof app.browser>[0]
);

saveBrowserSession({
id: data.id!,
cdpUrl: data.cdpUrl!,
createdAt: new Date().toISOString(),
});
if (!data.success) {
console.error('Error:', data.error || 'Failed to launch session');
process.exit(1);
}

saveBrowserSession({
id: data.id!,
cdpUrl: data.cdpUrl!,
createdAt: new Date().toISOString(),
});

console.error(`Session launched: ${data.id}`);
}

// Print session info to stderr so it doesn't mix with command output
console.error(`Session launched: ${data.id}`);
// Auto-launch if no active session
if (!loadBrowserSession()) {
try {
await launchNewSession();
} catch (error) {
console.error(
'Error:',
Expand All @@ -333,15 +335,70 @@ export async function handleBrowserQuickExecute(
finalCode = `agent-browser ${finalCode}`;
}

// Execute via the standard handler
await handleBrowserExecute({
const executeOptions = {
code: finalCode,
language: 'bash',
language: 'bash' as const,
apiKey: options.apiKey,
apiUrl: options.apiUrl,
output: options.output,
json: options.json,
});
};

// Try executing; if the cached session is stale, launch a new one and retry
try {
const sessionId = getSessionId();
const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl });

const data = await app.browserExecute(sessionId, {
code: executeOptions.code,
language: executeOptions.language,
});

if (!data.success) {
console.error('Error:', data.error || 'Unknown error');
process.exit(1);
}

if (data.error) {
process.stderr.write(`Code error: ${data.error}\n`);
}

if (options.json) {
const output = JSON.stringify(data, null, 2);
writeOutput(output, options.output, !!options.output);
} else {
const result = data.stdout || data.result || '';
if (result) {
writeOutput(result.trimEnd(), options.output, !!options.output);
}
}
} catch (error) {
if (isSessionExpiredError(error)) {
console.error('Cached session expired, launching a new one...');
clearBrowserSession();

try {
await launchNewSession();
} catch (launchError) {
console.error(
'Error:',
launchError instanceof Error
? launchError.message
: 'Failed to launch session'
);
process.exit(1);
}

// Retry with the new session
await handleBrowserExecute(executeOptions);
} else {
console.error(
'Error:',
error instanceof Error ? error.message : 'Unknown error occurred'
);
process.exit(1);
}
}
}

/**
Expand Down