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
104 changes: 104 additions & 0 deletions src/__tests__/utils/spinner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProgressLine, createSpinner } from '../../utils/spinner';

describe('terminal progress output', () => {
let writes: string[];
const originalIsTTY = process.stderr.isTTY;

const setTTY = (value: boolean) => {
Object.defineProperty(process.stderr, 'isTTY', {
value,
configurable: true,
});
};

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('TERM', 'xterm-256color');
writes = [];
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
writes.push(String(chunk));
return true;
});
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
setTTY(originalIsTTY);
});

describe('when stderr is not a terminal', () => {
beforeEach(() => setTTY(false));

it('prints each distinct spinner message once, with no frames or escapes', () => {
const spinner = createSpinner('Starting agent...');
spinner.start();
spinner.update('Agent running... (Job ID: abc)');
vi.advanceTimersByTime(5000);
spinner.update('Agent running... (Job ID: abc)');
spinner.update('Agent running... (Job ID: abc)');
spinner.succeed('Agent completed');

expect(writes).toEqual([
'Starting agent...\n',
'Agent running... (Job ID: abc)\n',
'✓ Agent completed\n',
]);
});

it('prints a progress line only when it changes', () => {
const progress = createProgressLine();
progress.update('Progress: 0/2 pages (scraping)');
progress.update('Progress: 0/2 pages (scraping)');
progress.update('Progress: 2/2 pages (completed)');
progress.end();

expect(writes).toEqual([
'Progress: 0/2 pages (scraping)\n',
'Progress: 2/2 pages (completed)\n',
]);
});
});

it('treats TERM=dumb as non-interactive', () => {
setTTY(true);
vi.stubEnv('TERM', 'dumb');
const spinner = createSpinner('Working...');
spinner.start();
vi.advanceTimersByTime(1000);
spinner.stop();

expect(writes).toEqual(['Working...\n']);
});

describe('when stderr is a terminal', () => {
beforeEach(() => setTTY(true));

it('animates the spinner in place and clears it on stop', () => {
const spinner = createSpinner('Working...');
spinner.start();
vi.advanceTimersByTime(160);
spinner.succeed('Done');

expect(writes).toHaveLength(5);
expect(writes[0]).toBe('\r\x1b[K⠋ Working...');
expect(writes.at(-2)).toBe('\r\x1b[K');
expect(writes.at(-1)).toBe('✓ Done\n');
});

it('redraws the progress line in place and ends it with a newline', () => {
const progress = createProgressLine();
progress.update('Progress: 0/2 pages (scraping)');
progress.update('Progress: 2/2 pages (completed)');
progress.end();

expect(writes).toEqual([
'\r\x1b[KProgress: 0/2 pages (scraping)',
'\r\x1b[KProgress: 2/2 pages (completed)',
'\n',
]);
});
});
});
10 changes: 6 additions & 4 deletions src/commands/batch-scrape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import { getClient } from '../utils/client';
import { parseFormats } from '../utils/options';
import { writeOutput } from '../utils/output';
import { createProgressLine } from '../utils/spinner';
import { normalizeUrl } from '../utils/url';

const TERMINAL_STATUSES = ['completed', 'failed', 'cancelled'];
Expand Down Expand Up @@ -187,26 +188,27 @@ export async function executeBatchScrape(
process.stderr.write(`Job ID: ${response.id}\n`);

const startTime = Date.now();
const progress = createProgressLine();
while (true) {
await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));

const status = await app.getBatchScrapeStatus(response.id, {
autoPaginate: false,
});
process.stderr.write(
`\rProgress: ${status.completed}/${status.total} pages (${status.status})`
progress.update(
`Progress: ${status.completed}/${status.total} pages (${status.status})`
);

if (TERMINAL_STATUSES.includes(status.status)) {
process.stderr.write('\n');
progress.end();
return {
success: true,
data: await app.getBatchScrapeStatus(response.id),
};
}

if (timeout !== undefined && Date.now() - startTime > timeout * 1000) {
process.stderr.write('\n');
progress.end();
return {
success: false,
error: `Timeout after ${timeout} seconds. Batch scrape still in progress; check it with: firecrawl batch-scrape ${response.id}`,
Expand Down
11 changes: 6 additions & 5 deletions src/commands/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import { getClient } from '../utils/client';
import { isJobId } from '../utils/job';
import { writeOutput } from '../utils/output';
import { createProgressLine } from '../utils/spinner';

/**
* Execute crawl status check
Expand Down Expand Up @@ -150,23 +151,23 @@ export async function executeCrawl(
const pollMs = waiterOptions.pollInterval * 1000;
const startTime = Date.now();
const timeoutMs = timeout !== undefined ? timeout * 1000 : undefined;
const progress = createProgressLine();

while (true) {
await new Promise((resolve) => setTimeout(resolve, pollMs));

const status = await app.getCrawlStatus(jobId);

// Show progress
process.stderr.write(
`\rProgress: ${status.completed}/${status.total} pages (${status.status})`
progress.update(
`Progress: ${status.completed}/${status.total} pages (${status.status})`
);

if (
status.status === 'completed' ||
status.status === 'failed' ||
status.status === 'cancelled'
) {
process.stderr.write('\n');
progress.end();
return {
success: true,
data: status,
Expand All @@ -175,7 +176,7 @@ export async function executeCrawl(

// Check timeout
if (timeoutMs && Date.now() - startTime > timeoutMs) {
process.stderr.write('\n');
progress.end();
return {
success: false,
error: `Timeout after ${timeout} seconds. Crawl still in progress.`,
Expand Down
29 changes: 9 additions & 20 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ import { detectAgents, type AgentDetection } from '../utils/agents';
import { compareVersions, getLatestVersion } from '../utils/npm-registry';
import { getAuthSource, type AuthSource } from './status';
import { integrationHeaders } from '../utils/integration';
import {
bold,
dim,
green,
orange,
red,
reset,
yellow,
} from '../utils/terminal';

type CheckStatus = 'pass' | 'warn' | 'fail';
type DoctorAuthSource = AuthSource | 'flag';
Expand Down Expand Up @@ -45,26 +54,6 @@ const DEFAULT_API_URL = 'https://api.firecrawl.dev';
const REACHABILITY_WARN_MS = 2000;
const MIN_NODE_MAJOR = 18;

function shouldUseColor(): boolean {
if (process.env.FORCE_COLOR !== undefined) {
return process.env.FORCE_COLOR !== '0';
}
if (process.env.NO_COLOR !== undefined) return false;
if (process.env.TERM === 'dumb') return false;
return Boolean(process.stdout.isTTY);
}

const colorEnabled = shouldUseColor();
const color = (code: string): string => (colorEnabled ? code : '');

const orange = color('\x1b[38;5;208m');
const reset = color('\x1b[0m');
const dim = color('\x1b[2m');
const bold = color('\x1b[1m');
const green = color('\x1b[32m');
const red = color('\x1b[31m');
const yellow = color('\x1b[33m');

function statusIcon(status: CheckStatus): string {
switch (status) {
case 'pass':
Expand Down
16 changes: 9 additions & 7 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ import {
type WebAgent,
} from '../utils/web-defaults';
import { installMcp } from './setup';
import {
bold,
dim,
green,
isInteractive,
orange,
reset,
} from '../utils/terminal';

export interface InitOptions {
global?: boolean;
Expand All @@ -50,12 +58,6 @@ export interface InitOptions {
template?: string;
}

const orange = '\x1b[38;5;208m';
const reset = '\x1b[0m';
const dim = '\x1b[2m';
const bold = '\x1b[1m';
const green = '\x1b[32m';

interface TemplateEntry {
name: string;
description: string;
Expand Down Expand Up @@ -156,7 +158,7 @@ async function installSkillRepoQuiet(
skills,
});

const isTty = process.stdout.isTTY;
const isTty = isInteractive(process.stdout);
if (isTty) {
process.stdout.write(` ${dim}↓ Installing ${label}...${reset}`);
}
Expand Down
4 changes: 1 addition & 3 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
type SkillSelection,
} from './skills-install';
import { hasNpx, installSkillsNative } from './skills-native';
import { dim, green, reset } from '../utils/terminal';
import {
configureWebDefaults,
WEB_AGENTS,
Expand Down Expand Up @@ -89,9 +90,6 @@ export interface SetupOptions {
skipAuthOffer?: boolean;
}

const green = '\x1b[32m';
const dim = '\x1b[2m';
const reset = '\x1b[0m';
const ADD_MCP_PACKAGE = 'add-mcp@1.14.0';
const ENV_API_KEY = 'FIRECRAWL_API_KEY';
const ADD_MCP_LAUNCH_AGENTS: Record<string, string> = {
Expand Down
6 changes: 1 addition & 5 deletions src/commands/skills-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import path from 'path';
import os from 'os';
import type { SkillFamily } from '../utils/skill-catalog';
import { rememberCatalogNames } from '../utils/skill-update-notice';

const green = '\x1b[32m';
const dim = '\x1b[2m';
const bold = '\x1b[1m';
const reset = '\x1b[0m';
import { bold, dim, green, reset } from '../utils/terminal';

const DEFAULT_REPO = 'firecrawl/cli';
const SKILLS_SUBDIR = 'skills';
Expand Down
8 changes: 1 addition & 7 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getConfig, validateConfig } from '../utils/config';
import { loadCredentials } from '../utils/credentials';
import { isSearchFeedbackDisabledLocally } from './search-feedback';
import { integrationHeaders } from '../utils/integration';
import { bold, dim, green, orange, red, reset } from '../utils/terminal';

export type AuthSource = 'env' | 'stored' | 'none';

Expand Down Expand Up @@ -246,13 +247,6 @@ async function getLocalStatus(cwd: string): Promise<LocalStatus> {
* Handle status command output
*/
export async function handleStatusCommand(): Promise<void> {
const orange = '\x1b[38;5;208m';
const reset = '\x1b[0m';
const dim = '\x1b[2m';
const bold = '\x1b[1m';
const green = '\x1b[32m';
const red = '\x1b[31m';

const status = await getStatus();
const localStatus = await getLocalStatus(process.cwd());

Expand Down
20 changes: 12 additions & 8 deletions src/utils/agent-scaffold/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
* agent repo's CLI output style so scaffold messages match docs verbatim.
*/

export const orange = '\x1b[38;5;208m';
export const reset = '\x1b[0m';
export const dim = '\x1b[2m';
export const bold = '\x1b[1m';
export const green = '\x1b[32m';
export const red = '\x1b[31m';
export const cyan = '\x1b[36m';
export const yellow = '\x1b[33m';
import {
bold,
cyan,
dim,
green,
orange,
red,
reset,
yellow,
} from '../terminal';

export { bold, cyan, dim, green, orange, red, reset, yellow };

export function printBanner(): void {
console.log('');
Expand Down
Loading
Loading