Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
branches:
- main
- rc
- '*-RC'
push:
branches: [main]
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/main/cue/cue-activity-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Tests for the Cue activity log ring buffer.
*/

import { describe, it, expect } from 'vitest';
import { createCueActivityLog } from '../../../main/cue/cue-activity-log';
import type { CueRunResult } from '../../../main/cue/cue-types';

function makeResult(id: string): CueRunResult {
return {
runId: id,
sessionId: 'session-1',
sessionName: 'Test',
subscriptionName: 'sub',
event: { id: 'e1', type: 'time.heartbeat', timestamp: '', triggerName: 'sub', payload: {} },
status: 'completed',
stdout: '',
stderr: '',
exitCode: 0,
durationMs: 100,
startedAt: '',
endedAt: '',
};
}

describe('createCueActivityLog', () => {
it('stores and retrieves results', () => {
const log = createCueActivityLog();
log.push(makeResult('r1'));
log.push(makeResult('r2'));
expect(log.getAll()).toHaveLength(2);
expect(log.getAll()[0].runId).toBe('r1');
});

it('respects limit parameter on getAll', () => {
const log = createCueActivityLog();
log.push(makeResult('r1'));
log.push(makeResult('r2'));
log.push(makeResult('r3'));
const last2 = log.getAll(2);
expect(last2).toHaveLength(2);
expect(last2[0].runId).toBe('r2');
expect(last2[1].runId).toBe('r3');
});

it('evicts oldest entries when exceeding maxSize', () => {
const log = createCueActivityLog(3);
log.push(makeResult('r1'));
log.push(makeResult('r2'));
log.push(makeResult('r3'));
log.push(makeResult('r4'));
const all = log.getAll();
expect(all).toHaveLength(3);
expect(all[0].runId).toBe('r2');
expect(all[2].runId).toBe('r4');
});

it('clear empties the log', () => {
const log = createCueActivityLog();
log.push(makeResult('r1'));
log.clear();
expect(log.getAll()).toHaveLength(0);
});

it('returns a copy from getAll, not a reference', () => {
const log = createCueActivityLog();
log.push(makeResult('r1'));
const snapshot = log.getAll();
log.push(makeResult('r2'));
expect(snapshot).toHaveLength(1);
});
});
43 changes: 1 addition & 42 deletions src/__tests__/main/cue/cue-completion-chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { CueConfig, CueEvent } from '../../../main/cue/cue-types';
import type { SessionInfo } from '../../../shared/types';

// Mock the yaml loader
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
Expand Down Expand Up @@ -52,47 +51,7 @@ vi.mock('crypto', () => ({
}));

import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine';

function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id: 'session-1',
name: 'Test Session',
toolType: 'claude-code',
cwd: '/projects/test',
projectRoot: '/projects/test',
...overrides,
};
}

function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
return {
subscriptions: [],
settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 },
...overrides,
};
}

function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
return {
getSessions: vi.fn(() => [createMockSession()]),
onCueRun: vi.fn(async () => ({
runId: 'run-1',
sessionId: 'session-1',
sessionName: 'Test Session',
subscriptionName: 'test',
event: {} as CueEvent,
status: 'completed' as const,
stdout: 'output',
stderr: '',
exitCode: 0,
durationMs: 100,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
})) as CueEngineDeps['onCueRun'],
onLog: vi.fn(),
...overrides,
};
}
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';

describe('CueEngine completion chains', () => {
beforeEach(() => {
Expand Down
95 changes: 48 additions & 47 deletions src/__tests__/main/cue/cue-concurrency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types';
import type { SessionInfo } from '../../../shared/types';

// Mock the yaml loader
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
Expand All @@ -35,52 +34,7 @@ vi.mock('crypto', () => ({
}));

import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine';

function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id: 'session-1',
name: 'Test Session',
toolType: 'claude-code',
cwd: '/projects/test',
projectRoot: '/projects/test',
...overrides,
};
}

function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
return {
subscriptions: [],
settings: {
timeout_minutes: 30,
timeout_on_fail: 'break',
max_concurrent: 1,
queue_size: 10,
},
...overrides,
};
}

function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
return {
getSessions: vi.fn(() => [createMockSession()]),
onCueRun: vi.fn(async () => ({
runId: 'run-1',
sessionId: 'session-1',
sessionName: 'Test Session',
subscriptionName: 'test',
event: {} as CueEvent,
status: 'completed' as const,
stdout: 'output',
stderr: '',
exitCode: 0,
durationMs: 100,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
})),
onLog: vi.fn(),
...overrides,
};
}
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';

describe('CueEngine Concurrency Control', () => {
let yamlWatcherCleanup: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -503,6 +457,53 @@ describe('CueEngine Concurrency Control', () => {
});
});

describe('stopRun concurrency slot release', () => {
it('stopRun frees the concurrency slot so queued events dispatch immediately', async () => {
const deps = createMockDeps({
onCueRun: vi.fn(() => new Promise<CueRunResult>(() => {})), // Never resolves
});
const config = createMockConfig({
settings: {
timeout_minutes: 30,
timeout_on_fail: 'break',
max_concurrent: 1,
queue_size: 10,
},
subscriptions: [
{
name: 'timer',
event: 'time.heartbeat',
enabled: true,
prompt: 'test',
interval_minutes: 1,
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const engine = new CueEngine(deps);
engine.start();

// First run starts immediately
await vi.advanceTimersByTimeAsync(10);
expect(engine.getActiveRuns()).toHaveLength(1);

// Second event gets queued (max_concurrent = 1)
vi.advanceTimersByTime(1 * 60 * 1000);
expect(engine.getQueueStatus().get('session-1')).toBe(1);

// Stop the active run — should free the slot and drain the queue
const activeRun = engine.getActiveRuns()[0];
engine.stopRun(activeRun.runId);

// The queued event should have been dispatched (onCueRun called again)
expect(deps.onCueRun).toHaveBeenCalledTimes(2);
expect(engine.getQueueStatus().size).toBe(0);

engine.stopAll();
engine.stop();
});
});

describe('clearQueue', () => {
it('clears queued events for a specific session', async () => {
const deps = createMockDeps({
Expand Down
118 changes: 75 additions & 43 deletions src/__tests__/main/cue/cue-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types';
import type { SessionInfo } from '../../../shared/types';

// Mock the yaml loader
const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>();
Expand Down Expand Up @@ -53,48 +52,7 @@ import {
calculateNextScheduledTime,
type CueEngineDeps,
} from '../../../main/cue/cue-engine';

function createMockSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
id: 'session-1',
name: 'Test Session',
toolType: 'claude-code',
cwd: '/projects/test',
projectRoot: '/projects/test',
...overrides,
};
}

function createMockConfig(overrides: Partial<CueConfig> = {}): CueConfig {
return {
subscriptions: [],
settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 },
...overrides,
};
}

function createMockDeps(overrides: Partial<CueEngineDeps> = {}): CueEngineDeps {
return {
getSessions: vi.fn(() => [createMockSession()]),
onCueRun: vi.fn(async (request: Parameters<CueEngineDeps['onCueRun']>[0]) => ({
runId: 'run-1',
sessionId: 'session-1',
sessionName: 'Test Session',
subscriptionName: request.subscriptionName,
event: request.event,
status: 'completed' as const,
stdout: 'output',
stderr: '',
exitCode: 0,
durationMs: 100,
startedAt: new Date().toISOString(),
endedAt: new Date().toISOString(),
})),
onStopCueRun: vi.fn(() => true),
onLog: vi.fn(),
...overrides,
};
}
import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers';

describe('CueEngine', () => {
let yamlWatcherCleanup: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -930,6 +888,39 @@ describe('CueEngine', () => {
engine.stop();
});

it('stopRun adds the stopped run to the activity log', async () => {
const deps = createMockDeps({
onCueRun: vi.fn(() => new Promise<CueRunResult>(() => {})),
});
const config = createMockConfig({
subscriptions: [
{
name: 'timer',
event: 'time.heartbeat',
enabled: true,
prompt: 'test',
interval_minutes: 60,
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const engine = new CueEngine(deps);
engine.start();

await vi.advanceTimersByTimeAsync(10);

const activeRun = engine.getActiveRuns()[0];
expect(activeRun).toBeDefined();
engine.stopRun(activeRun.runId);

const log = engine.getActivityLog();
expect(log).toHaveLength(1);
expect(log[0].runId).toBe(activeRun.runId);
expect(log[0].status).toBe('stopped');

engine.stop();
});

it('stopAll clears all active runs', async () => {
// Use a slow-resolving onCueRun to keep runs active
const deps = createMockDeps({
Expand Down Expand Up @@ -1897,6 +1888,47 @@ describe('CueEngine', () => {
engine.stop();
});

it('refreshes nextTriggers after time.scheduled fires', async () => {
// Monday 2026-03-09 at 08:59 — next trigger should be 09:00 today
vi.setSystemTime(new Date('2026-03-09T08:59:00'));

const config = createMockConfig({
subscriptions: [
{
name: 'refresh-schedule',
event: 'time.scheduled',
enabled: true,
prompt: 'check',
schedule_times: ['09:00'],
},
],
});
mockLoadCueConfig.mockReturnValue(config);
const deps = createMockDeps();
const engine = new CueEngine(deps);
engine.start();

const statusBefore = engine.getStatus();
const subBefore = statusBefore.find((s) => s.sessionId === 'session-1');
const nextBefore = subBefore!.nextTrigger!;
// nextTrigger should be pointing at 09:00 today (ISO string)
const nextBeforeDate = new Date(nextBefore);
expect(nextBeforeDate.getHours()).toBe(9);
expect(nextBeforeDate.getMinutes()).toBe(0);

// Advance to 09:00 — the subscription fires
vi.advanceTimersByTime(60_000);
await vi.advanceTimersByTimeAsync(10);

// After firing, nextTrigger should have advanced to a future time (tomorrow 09:00)
const statusAfter = engine.getStatus();
const subAfter = statusAfter.find((s) => s.sessionId === 'session-1');
expect(subAfter!.nextTrigger).toBeDefined();
expect(new Date(subAfter!.nextTrigger!).getTime()).toBeGreaterThan(nextBeforeDate.getTime());

engine.stop();
});

it('uses prompt_file when configured', async () => {
// Monday at 08:59 — fires at 09:00
vi.setSystemTime(new Date('2026-03-09T08:59:00'));
Expand Down
Loading