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
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