Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
189 changes: 189 additions & 0 deletions packages/cli/src/ui/commands/tasksCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import type {
BackgroundShellEntry,
BackgroundTaskEntry,
MonitorEntry,
} from '@qwen-code/qwen-code-core';

type AgentTaskTestEntry = BackgroundTaskEntry & {
Expand Down Expand Up @@ -46,19 +47,40 @@ function agentEntry(
};
}

function monitorEntry(overrides: Partial<MonitorEntry> = {}): MonitorEntry {
return {
monitorId: 'mon-aaaaaaaa',
command: 'tail -f app.log',
description: 'watch app logs',
status: 'running',
startTime: Date.now() - 4_000,
abortController: new AbortController(),
eventCount: 0,
lastEventTime: 0,
maxEvents: 1000,
idleTimeoutMs: 300_000,
droppedLines: 0,
...overrides,
};
}

describe('tasksCommand', () => {
let context: CommandContext;
let getShells: ReturnType<typeof vi.fn>;
let getAgents: ReturnType<typeof vi.fn>;
let getMonitors: ReturnType<typeof vi.fn>;

beforeEach(() => {
getShells = vi.fn().mockReturnValue([]);
getAgents = vi.fn().mockReturnValue([]);
getMonitors = vi.fn().mockReturnValue([]);
context = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
getMonitorRegistry: () => ({ getAll: getMonitors }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
Expand Down Expand Up @@ -157,4 +179,171 @@ describe('tasksCommand', () => {
);
expect(result.content).toContain('[bg_shell] running');
});

Comment thread
wenshao marked this conversation as resolved.
Resolved
it('lists monitor entries with eventCount and exit / error suffixes', async () => {
getMonitors.mockReturnValue([
monitorEntry({
monitorId: 'mon_run',
description: 'tail dev server log',
status: 'running',
eventCount: 12,
pid: 9999,
}),
monitorEntry({
monitorId: 'mon_done',
description: 'tail short script',
status: 'completed',
exitCode: 0,
eventCount: 7,
endTime: Date.now() - 1_000,
}),
monitorEntry({
monitorId: 'mon_auto',
description: 'tail noisy log',
status: 'completed',
error: 'Max events reached',
eventCount: 1000,
endTime: Date.now() - 500,
}),
Comment thread
wenshao marked this conversation as resolved.
Resolved
monitorEntry({
monitorId: 'mon_fail',
description: 'tail bad path',
status: 'failed',
error: 'spawn ENOENT',
eventCount: 0,
endTime: Date.now() - 100,
}),
]);

const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') {
throw new Error('expected message result');
}

expect(result.content).toContain('Background tasks (4 total)');
// Pluralised eventCount + pid for the running monitor.
expect(result.content).toContain('[mon_run] running (12 events)');
expect(result.content).toContain('pid=9999');
expect(result.content).toContain('tail dev server log');
// Natural completion path uses exitCode + eventCount.
expect(result.content).toContain('[mon_done] completed (exit 0, 7 events)');
// Auto-stop path uses the error string instead of exit code (so users
// see WHY the monitor stopped, not just that it did).
expect(result.content).toContain(
'[mon_auto] completed (Max events reached, 1000 events)',
);
// Pin the full string (not just the prefix) so a regression that
// drops the `(N events)` suffix from monitor's failed branch fails
// loudly. The other status branches above already have full-string
// assertions; this one was incidentally shorter.
expect(result.content).toContain(
'[mon_fail] failed: spawn ENOENT (0 events)',
);
// No on-disk output file for monitors — events stream via
// task_notification, so the "output:" line should not appear for
// any monitor entry.
expect(result.content).not.toContain('output: ');
});

it('renders cancelled monitor with eventCount suffix', async () => {
getMonitors.mockReturnValue([
monitorEntry({
monitorId: 'mon_cancel',
description: 'tail noisy log',
status: 'cancelled',
eventCount: 3,
endTime: Date.now() - 200,
}),
]);
const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('[mon_cancel] cancelled (3 events)');
});

it('singular form: "1 event" not "1 events"', async () => {
getMonitors.mockReturnValue([
monitorEntry({ monitorId: 'mon_one', eventCount: 1 }),
]);
const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('running (1 event)');
// Guard against "1 event" matching the prefix of "1 events" by accident.
expect(result.content).not.toContain('1 events');
});

it('shows the dialog hint only in interactive mode', async () => {
getShells.mockReturnValue([entry({ shellId: 'bg_x' })]);

// non_interactive (default in beforeEach) — no hint.
const noHint = await tasksCommand.action!(context, '');
if (!noHint || noHint.type !== 'message') throw new Error('no result');
expect(noHint.content).not.toContain('Tip:');

// Re-bind the same config under an interactive context.
const interactiveCtx = createMockCommandContext({
executionMode: 'interactive',
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
getMonitorRegistry: () => ({ getAll: getMonitors }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
const withHint = await tasksCommand.action!(interactiveCtx, '');
if (!withHint || withHint.type !== 'message') throw new Error('no result');
expect(withHint.content).toContain('Tip:');
// Pin the actual key path so a regression that goes back to the
// wrong `Ctrl+T` text (which is bound to the MCP descriptions
// toggle, not the Background tasks dialog) fails loudly.
expect(withHint.content).toContain('↓');
expect(withHint.content).toContain('Enter');
expect(withHint.content).not.toContain('Ctrl+T');
expect(withHint.content).toContain('Background tasks (1 total)');
});

it('suppresses the dialog hint in acp mode (no dialog to point at)', async () => {
// ACP / IDE-bridge consumers (Zed, etc.) have no TTY for the
// dialog — same suppression rationale as non_interactive. Pinning
// this so a future regression that switches to `!== 'non_interactive'`
// (which would wrongly show the hint to ACP) fails loudly.
getShells.mockReturnValue([entry({ shellId: 'bg_x' })]);
const acpCtx = createMockCommandContext({
executionMode: 'acp',
services: {
config: {
getBackgroundShellRegistry: () => ({ getAll: getShells }),
getBackgroundTaskRegistry: () => ({ getAll: getAgents }),
getMonitorRegistry: () => ({ getAll: getMonitors }),
},
},
} as unknown as Parameters<typeof createMockCommandContext>[0]);
const result = await tasksCommand.action!(acpCtx, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).not.toContain('Tip:');
expect(result.content).toContain('Background tasks (1 total)');
});

it('merges all three kinds and orders them by startTime', async () => {
const now = Date.now();
getAgents.mockReturnValue([
agentEntry({ agentId: 'a_late', startTime: now - 1_000 }),
]);
getShells.mockReturnValue([
entry({ shellId: 'bg_early', startTime: now - 10_000 }),
]);
getMonitors.mockReturnValue([
monitorEntry({ monitorId: 'mon_mid', startTime: now - 5_000 }),
]);

const result = await tasksCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
const order = ['bg_early', 'mon_mid', 'a_late'].map((id) =>
result.content.indexOf(`[${id}]`),
);
// All present and strictly increasing — proves startTime sort.
expect(order.every((i) => i >= 0)).toBe(true);
expect(order[0]).toBeLessThan(order[1]);
expect(order[1]).toBeLessThan(order[2]);
});
});
Loading
Loading