Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
110 changes: 110 additions & 0 deletions src/__tests__/cli/commands/paseo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';

vi.mock('../../../cli/services/paseo', () => ({
createPaseoSchedule: vi.fn(),
listPaseoSchedules: vi.fn(),
getPaseoScheduleLogs: vi.fn(),
}));

vi.mock('../../../cli/output/formatter', () => ({
formatError: vi.fn((message: string) => `Error: ${message}`),
}));

import {
paseoScheduleCreate,
paseoScheduleList,
paseoScheduleLogs,
} from '../../../cli/commands/paseo';
import {
createPaseoSchedule,
getPaseoScheduleLogs,
listPaseoSchedules,
} from '../../../cli/services/paseo';

describe('paseo command', () => {
let consoleSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let processExitSpy: MockInstance;

beforeEach(() => {
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code?: string | number | null | undefined) => {
throw new Error(`process.exit(${code})`);
});
});

afterEach(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
processExitSpy.mockRestore();
});

it('creates a Paseo schedule and prints stdout', async () => {
vi.mocked(createPaseoSchedule).mockResolvedValue({
stdout: 'ID NAME\nabc demo\n',
stderr: '',
});

await paseoScheduleCreate('do work', {
every: '2m',
name: 'demo',
provider: 'codex',
cwd: '/repo',
maxRuns: '2',
expiresIn: '10m',
});

expect(createPaseoSchedule).toHaveBeenCalledWith('do work', {
every: '2m',
name: 'demo',
provider: 'codex',
cwd: '/repo',
maxRuns: '2',
expiresIn: '10m',
});
expect(consoleSpy).toHaveBeenCalledWith('ID NAME\nabc demo');
expect(processExitSpy).not.toHaveBeenCalled();
});

it('lists schedules', async () => {
vi.mocked(listPaseoSchedules).mockResolvedValue({ stdout: 'schedules\n', stderr: '' });

await paseoScheduleList({ json: true, host: '127.0.0.1:6767' });

expect(listPaseoSchedules).toHaveBeenCalledWith({
json: true,
host: '127.0.0.1:6767',
});
expect(consoleSpy).toHaveBeenCalledWith('schedules');
});

it('shows schedule logs', async () => {
vi.mocked(getPaseoScheduleLogs).mockResolvedValue({ stdout: 'logs\n', stderr: '' });

await paseoScheduleLogs('abc123', { cliPath: '/bin/paseo' });

expect(getPaseoScheduleLogs).toHaveBeenCalledWith('abc123', { cliPath: '/bin/paseo' });
expect(consoleSpy).toHaveBeenCalledWith('logs');
});

it('prints JSON errors when json mode is enabled', async () => {
vi.mocked(listPaseoSchedules).mockRejectedValue(new Error('daemon unavailable'));

await expect(paseoScheduleList({ json: true })).rejects.toThrow('process.exit(1)');

const output = JSON.parse(consoleErrorSpy.mock.calls[0][0]);
expect(output).toEqual({ success: false, error: 'daemon unavailable' });
});

it('prints human-readable errors otherwise', async () => {
vi.mocked(listPaseoSchedules).mockRejectedValue(new Error('daemon unavailable'));

await expect(paseoScheduleList({})).rejects.toThrow('process.exit(1)');

expect(consoleErrorSpy).toHaveBeenCalledWith('Error: daemon unavailable');
});
});
161 changes: 161 additions & 0 deletions src/__tests__/cli/services/paseo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('fs', () => ({
statSync: vi.fn(),
accessSync: vi.fn(),
constants: { X_OK: 1 },
}));

vi.mock('os', () => ({
platform: vi.fn(() => 'darwin'),
}));

vi.mock('child_process', () => ({
spawn: vi.fn(),
}));

import { EventEmitter } from 'events';
import { Readable } from 'stream';
import * as fs from 'fs';
import * as os from 'os';
import { spawn } from 'child_process';
import {
createPaseoSchedule,
getPaseoScheduleLogs,
listPaseoSchedules,
resolvePaseoCliPath,
runPaseoCommand,
} from '../../../cli/services/paseo';

function mockSpawnResult(code: number, stdout = '', stderr = ''): void {
const child = new EventEmitter() as EventEmitter & {
stdout: Readable;
stderr: Readable;
};
child.stdout = new Readable({ read() {} });
child.stderr = new Readable({ read() {} });

vi.mocked(spawn).mockReturnValue(child as any);

setImmediate(() => {
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
child.emit('close', code);
});
}

describe('paseo service', () => {
const originalEnv = process.env.PASEO_CLI_PATH;

beforeEach(() => {
vi.clearAllMocks();
delete process.env.PASEO_CLI_PATH;
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any);
vi.mocked(fs.accessSync).mockReturnValue(undefined);
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.PASEO_CLI_PATH;
} else {
process.env.PASEO_CLI_PATH = originalEnv;
}
});

it('prefers an explicit CLI path', () => {
expect(resolvePaseoCliPath('/tmp/paseo')).toBe('/tmp/paseo');
});

it('uses PASEO_CLI_PATH before bundled defaults', () => {
process.env.PASEO_CLI_PATH = '/env/paseo';
expect(resolvePaseoCliPath()).toBe('/env/paseo');
});

it('uses the macOS bundled Paseo CLI when executable', () => {
expect(resolvePaseoCliPath()).toBe('/Applications/Paseo.app/Contents/Resources/bin/paseo');
});

it('falls back to PATH command when bundled CLI is unavailable', () => {
vi.mocked(fs.statSync).mockImplementation(() => {
throw new Error('missing');
});
expect(resolvePaseoCliPath()).toBe('paseo');
});

it('runs Paseo commands and returns output', async () => {
mockSpawnResult(0, 'ok\n', 'warn\n');

const result = await runPaseoCommand(['schedule', 'ls'], { cliPath: '/bin/paseo' });

expect(spawn).toHaveBeenCalledWith('/bin/paseo', ['schedule', 'ls'], {
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
expect(result).toEqual({ stdout: 'ok\n', stderr: 'warn\n' });
});

it('rejects failed Paseo commands with stderr and exit code', async () => {
mockSpawnResult(2, '', 'bad option\n');

await expect(runPaseoCommand(['bad'], { cliPath: '/bin/paseo' })).rejects.toThrow('bad option');
});

it('builds schedule create arguments', async () => {
mockSpawnResult(0, 'created\n');

await createPaseoSchedule('do work', {
cliPath: '/bin/paseo',
every: '2m',
name: 'demo',
provider: 'codex',
cwd: '/repo',
maxRuns: '2',
expiresIn: '10m',
json: true,
});

expect(spawn).toHaveBeenCalledWith(
'/bin/paseo',
[
'schedule',
'create',
'--every',
'2m',
'--name',
'demo',
'--provider',
'codex',
'--cwd',
'/repo',
'--max-runs',
'2',
'--expires-in',
'10m',
'--json',
'do work',
],
expect.any(Object)
);
});

it('builds schedule list and logs arguments', async () => {
mockSpawnResult(0, '');
await listPaseoSchedules({ cliPath: '/bin/paseo', host: '127.0.0.1:6767' });

expect(spawn).toHaveBeenLastCalledWith(
'/bin/paseo',
['schedule', 'ls', '--host', '127.0.0.1:6767'],
expect.any(Object)
);

mockSpawnResult(0, '');
await getPaseoScheduleLogs('abc123', { cliPath: '/bin/paseo', json: true });

expect(spawn).toHaveBeenLastCalledWith(
'/bin/paseo',
['schedule', 'logs', '--json', 'abc123'],
expect.any(Object)
);
});
});
81 changes: 81 additions & 0 deletions src/cli/commands/paseo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Paseo command group for Maestro CLI

import {
createPaseoSchedule,
getPaseoScheduleLogs,
listPaseoSchedules,
type PaseoCommandResult,
} from '../services/paseo';
import { formatError } from '../output/formatter';

interface PaseoBaseOptions {
cliPath?: string;
host?: string;
json?: boolean;
}

interface PaseoScheduleCreateCommandOptions extends PaseoBaseOptions {
every?: string;
cron?: string;
name?: string;
target?: string;
provider?: string;
mode?: string;
cwd?: string;
maxRuns?: string;
expiresIn?: string;
runNow?: boolean;
noRunNow?: boolean;
}

function printResult(result: PaseoCommandResult): void {
if (result.stdout.trim()) {
console.log(result.stdout.trimEnd());
}
if (result.stderr.trim()) {
console.error(result.stderr.trimEnd());
}
}

function printError(error: unknown, json?: boolean): void {
const message = error instanceof Error ? error.message : 'Unknown error';
if (json) {
console.error(JSON.stringify({ success: false, error: message }, null, 2));
} else {
console.error(formatError(message));
}
process.exit(1);
}

export async function paseoScheduleCreate(
prompt: string,
options: PaseoScheduleCreateCommandOptions
): Promise<void> {
try {
const result = await createPaseoSchedule(prompt, options);
printResult(result);
} catch (error) {
printError(error, options.json);
}
Comment on lines +74 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Integrate Sentry for error tracking as required by coding guidelines.

All three command handlers catch errors without using Sentry utilities for production error tracking. As per coding guidelines, unexpected errors should be reported to Sentry for observability, even in CLI contexts.

Consider:

  1. Import captureException from src/utils/sentry.ts
  2. Distinguish expected operational errors (daemon unavailable, invalid schedule ID) from unexpected errors (bugs, crashes)
  3. For unexpected errors, call captureException(error) with appropriate context before printError
  4. For expected errors, proceed with current handling

Alternatively, if all CLI errors should be tracked in production, add captureException(error) at the start of each catch block.

As per coding guidelines: "Use Sentry utilities (captureException, captureMessage) from src/utils/sentry.ts for explicit error reporting with context."

Also applies to: 63-68, 75-80

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/commands/paseo.ts` around lines 54 - 59, The catch blocks in the CLI
command handlers (around createPaseoSchedule, the other two handlers on the same
file) currently only call printError; import captureException from
src/utils/sentry.ts and report unexpected errors to Sentry before printing: in
each catch, detect expected operational errors (e.g., known custom error
types/messages such as "daemon unavailable" or "invalid schedule ID") and skip
Sentry, otherwise call captureException(error) supplying any available context
(e.g., the command name and options) and then call printError(error,
options.json); ensure the import for captureException is added at the top of the
file and that the catch blocks around createPaseoSchedule, the handler at lines
~63–68, and the handler at ~75–80 follow this pattern.

}

export async function paseoScheduleList(options: PaseoBaseOptions): Promise<void> {
try {
const result = await listPaseoSchedules(options);
printResult(result);
} catch (error) {
printError(error, options.json);
}
}

export async function paseoScheduleLogs(
scheduleId: string,
options: PaseoBaseOptions
): Promise<void> {
try {
const result = await getPaseoScheduleLogs(scheduleId, options);
printResult(result);
} catch (error) {
printError(error, options.json);
}
}
Loading