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
234 changes: 234 additions & 0 deletions src/__tests__/utils/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Tests for config fallback priority
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
initializeConfig,
getConfig,
resetConfig,
updateConfig,
} from '../../utils/config';
import { getClient, resetClient } from '../../utils/client';
import * as credentials from '../../utils/credentials';

// Mock credentials module
vi.mock('../../utils/credentials', () => ({
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
}));

describe('Config Fallback Priority', () => {
const originalEnv = process.env;

beforeEach(() => {
// Reset everything before each test
resetClient();
resetConfig();
vi.clearAllMocks();

// Clear env vars
delete process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_API_URL;

// Mock loadCredentials to return null by default
vi.mocked(credentials.loadCredentials).mockReturnValue(null);
});

afterEach(() => {
// Restore original env
process.env = originalEnv;
});

describe('initializeConfig fallback priority', () => {
it('should prioritize provided config over env vars', () => {
process.env.FIRECRAWL_API_KEY = 'env-api-key';
process.env.FIRECRAWL_API_URL = 'https://env-api-url.com';

initializeConfig({
apiKey: 'provided-api-key',
apiUrl: 'https://provided-api-url.com',
});

const config = getConfig();
expect(config.apiKey).toBe('provided-api-key');
expect(config.apiUrl).toBe('https://provided-api-url.com');
});

it('should use env vars when provided config is not set', () => {
process.env.FIRECRAWL_API_KEY = 'env-api-key';
process.env.FIRECRAWL_API_URL = 'https://env-api-url.com';

initializeConfig({});

const config = getConfig();
expect(config.apiKey).toBe('env-api-key');
expect(config.apiUrl).toBe('https://env-api-url.com');
});

it('should fallback to stored credentials when env vars are not set', () => {
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiKey: 'stored-api-key',
apiUrl: 'https://stored-api-url.com',
});

initializeConfig({});

const config = getConfig();
expect(config.apiKey).toBe('stored-api-key');
expect(config.apiUrl).toBe('https://stored-api-url.com');
});

it('should prioritize provided config > env vars > stored credentials', () => {
process.env.FIRECRAWL_API_KEY = 'env-api-key';
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiKey: 'stored-api-key',
});

// Provided config should win
initializeConfig({ apiKey: 'provided-api-key' });
expect(getConfig().apiKey).toBe('provided-api-key');

// Reset and test env var priority
resetConfig();
initializeConfig({});
expect(getConfig().apiKey).toBe('env-api-key');

// Reset and test stored credentials fallback
resetConfig();
delete process.env.FIRECRAWL_API_KEY;
initializeConfig({});
expect(getConfig().apiKey).toBe('stored-api-key');
});
});

describe('getClient fallback priority', () => {
beforeEach(() => {
// Set up base config
initializeConfig({
apiKey: 'global-api-key',
apiUrl: 'https://global-url.com',
});
});

it('should prioritize options over global config', () => {
const client = getClient({ apiKey: 'option-api-key' });

// Verify client was created with option API key
// We can't directly inspect the client, but we can check the config was updated
const config = getConfig();
expect(config.apiKey).toBe('option-api-key');
});

it('should use global config when options not provided', () => {
getClient();

const config = getConfig();
expect(config.apiKey).toBe('global-api-key');
expect(config.apiUrl).toBe('https://global-url.com');
});

it('should merge options with global config', () => {
initializeConfig({
apiKey: 'global-api-key',
apiUrl: 'https://global-url.com',
timeoutMs: 30000,
});

getClient({ apiKey: 'option-api-key' });

const config = getConfig();
expect(config.apiKey).toBe('option-api-key'); // Option overrides
expect(config.apiUrl).toBe('https://global-url.com'); // Global preserved
expect(config.timeoutMs).toBe(30000); // Global preserved
});

it('should handle undefined options gracefully', () => {
initializeConfig({ apiKey: 'global-api-key' });

getClient({ apiKey: undefined });

// When undefined is passed, it should not override
const config = getConfig();
expect(config.apiKey).toBe('global-api-key');
});
});

describe('Combined fallback chain', () => {
it('should follow: options > global config > env vars > stored credentials', () => {
// Set up stored credentials
vi.mocked(credentials.loadCredentials).mockReturnValue({
apiKey: 'stored-api-key',
});

// Set up env vars
process.env.FIRECRAWL_API_KEY = 'env-api-key';

// Initialize with env vars (should use env > stored)
initializeConfig({});
expect(getConfig().apiKey).toBe('env-api-key');

// Options should override everything
getClient({ apiKey: 'option-api-key' });
expect(getConfig().apiKey).toBe('option-api-key');

// After reset, should fall back to env
resetConfig();
initializeConfig({});
expect(getConfig().apiKey).toBe('env-api-key');

// After clearing env, should fall back to stored
resetConfig();
delete process.env.FIRECRAWL_API_KEY;
initializeConfig({});
expect(getConfig().apiKey).toBe('stored-api-key');
});

it('should update global config when getClient is called with options', () => {
process.env.FIRECRAWL_API_KEY = 'env-api-key';
initializeConfig({});

// Initially should use env var
expect(getConfig().apiKey).toBe('env-api-key');

// Call getClient with option
getClient({ apiKey: 'option-api-key' });

// Global config should now be updated
expect(getConfig().apiKey).toBe('option-api-key');

// Subsequent getClient calls without options should use updated global config
resetClient(); // Reset client instance
getClient();
expect(getConfig().apiKey).toBe('option-api-key');
});
});

describe('updateConfig behavior', () => {
it('should merge with existing config', () => {
initializeConfig({
apiKey: 'initial-key',
apiUrl: 'https://initial-url.com',
});

updateConfig({ apiKey: 'updated-key' });

const config = getConfig();
expect(config.apiKey).toBe('updated-key');
expect(config.apiUrl).toBe('https://initial-url.com'); // Should be preserved
});

it('should allow partial updates', () => {
initializeConfig({
apiKey: 'key1',
apiUrl: 'https://url1.com',
});

updateConfig({ apiUrl: 'https://url2.com' });

const config = getConfig();
expect(config.apiKey).toBe('key1'); // Should be preserved
expect(config.apiUrl).toBe('https://url2.com'); // Should be updated
});
});
});
10 changes: 2 additions & 8 deletions src/commands/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type {
CrawlStatusResult,
} from '../types/crawl';
import { getClient } from '../utils/client';
import { updateConfig } from '../utils/config';
import { isJobId } from '../utils/job';
import { writeOutput } from '../utils/output';

Expand All @@ -20,7 +19,7 @@ async function checkCrawlStatus(
options: CrawlOptions
): Promise<CrawlStatusResult> {
try {
const app = getClient();
const app = getClient({ apiKey: options.apiKey });
const status = await app.getCrawlStatus(jobId);

return {
Expand Down Expand Up @@ -49,12 +48,7 @@ export async function executeCrawl(
options: CrawlOptions
): Promise<CrawlResult | CrawlStatusResult> {
try {
// Update global config if API key is provided
if (options.apiKey) {
updateConfig({ apiKey: options.apiKey });
}

const app = getClient();
const app = getClient({ apiKey: options.apiKey });
const { urlOrJobId, status, wait, pollInterval, timeout } = options;

// If status flag is set or input looks like a job ID, check status
Expand Down
11 changes: 6 additions & 5 deletions src/commands/credit-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import * as fs from 'fs';
import * as path from 'path';
import { getConfig, updateConfig, validateConfig } from '../utils/config';
import { getConfig, validateConfig } from '../utils/config';
import { getClient } from '../utils/client';

export interface CreditUsageResult {
success: boolean;
Expand Down Expand Up @@ -35,17 +36,17 @@ export async function executeCreditUsage(
options: CreditUsageOptions = {}
): Promise<CreditUsageResult> {
try {
// Update global config if API key is provided via options
// Update config if API key provided (via getClient)
if (options.apiKey) {
updateConfig({ apiKey: options.apiKey });
getClient({ apiKey: options.apiKey });
}

// Get config and validate API key
const config = getConfig();
validateConfig(config.apiKey);
const apiKey = options.apiKey || config.apiKey;
validateConfig(apiKey);

const apiUrl = config.apiUrl || 'https://api.firecrawl.dev';
const apiKey = config.apiKey!;

// Make the API call to /v2/team/credit-usage
const url = `${apiUrl.replace(/\/$/, '')}/v2/team/credit-usage`;
Expand Down
8 changes: 1 addition & 7 deletions src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,14 @@

import type { MapOptions, MapResult } from '../types/map';
import { getClient } from '../utils/client';
import { updateConfig } from '../utils/config';
import { writeOutput } from '../utils/output';

/**
* Execute map command
*/
export async function executeMap(options: MapOptions): Promise<MapResult> {
try {
// Update global config if API key is provided
if (options.apiKey) {
updateConfig({ apiKey: options.apiKey });
}

const app = getClient();
const app = getClient({ apiKey: options.apiKey });
const { urlOrJobId } = options;

// Build map options
Expand Down
10 changes: 2 additions & 8 deletions src/commands/scrape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import type { FormatOption } from '@mendable/firecrawl-js';
import type { ScrapeOptions, ScrapeResult } from '../types/scrape';
import { getClient } from '../utils/client';
import { updateConfig } from '../utils/config';
import { handleScrapeOutput } from '../utils/output';

/**
Expand All @@ -15,13 +14,8 @@ export async function executeScrape(
options: ScrapeOptions
): Promise<ScrapeResult> {
try {
// Update global config if API key is provided via options
if (options.apiKey) {
updateConfig({ apiKey: options.apiKey });
}

// Get client instance (uses global config)
const app = getClient();
// Get client instance (updates global config if apiKey provided)
const app = getClient({ apiKey: options.apiKey });

// Build scrape options
const formats: FormatOption[] = [];
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

import { Command } from 'commander';
import { handleScrapeCommand } from './commands/scrape';
import { initializeConfig, updateConfig } from './utils/config';
import { initializeConfig } from './utils/config';
import { getClient } from './utils/client';
import { configure } from './commands/config';
import { handleCreditUsageCommand } from './commands/credit-usage';
import { handleCrawlCommand } from './commands/crawl';
Expand Down Expand Up @@ -36,7 +37,7 @@ program
// Update global config if API key is provided via global option
const globalOptions = thisCommand.opts();
if (globalOptions.apiKey) {
updateConfig({ apiKey: globalOptions.apiKey });
getClient({ apiKey: globalOptions.apiKey });
}
});

Expand Down
Loading