Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('TriggerDrawer', () => {
expect(screen.getByText('Heartbeat')).toBeInTheDocument();
expect(screen.getByText('Scheduled')).toBeInTheDocument();
expect(screen.getByText('File Change')).toBeInTheDocument();
expect(screen.getByText('Agent Done')).toBeInTheDocument();
expect(screen.queryByText('Agent Done')).not.toBeInTheDocument();
expect(screen.getByText('Pull Request')).toBeInTheDocument();
expect(screen.getByText('Issue')).toBeInTheDocument();
expect(screen.getByText('Pending Task')).toBeInTheDocument();
Expand All @@ -43,7 +43,7 @@ describe('TriggerDrawer', () => {
expect(screen.getByText('Run every N minutes')).toBeInTheDocument();
expect(screen.getByText('Run at specific times & days')).toBeInTheDocument();
expect(screen.getByText('Watch for file modifications')).toBeInTheDocument();
expect(screen.getByText('After an agent finishes')).toBeInTheDocument();
expect(screen.queryByText('After an agent finishes')).not.toBeInTheDocument();
});

it('should filter triggers by label', () => {
Expand Down Expand Up @@ -112,6 +112,26 @@ describe('TriggerDrawer', () => {
expect(drawer.style.transform).toBe('translateX(0)');
});

it('should render exactly 6 trigger types (no agent.completed)', () => {
const { container } = render(
<TriggerDrawer isOpen={true} onClose={() => {}} theme={mockTheme} />
);

// Each trigger item is a draggable div; count them
const draggableItems = container.querySelectorAll('[draggable="true"]');
expect(draggableItems.length).toBe(6);
});

it('should not show agent.completed when filtering by "agent"', () => {
render(<TriggerDrawer isOpen={true} onClose={() => {}} theme={mockTheme} />);

const input = screen.getByPlaceholderText('Filter triggers...');
fireEvent.change(input, { target: { value: 'agent' } });

// No trigger items should match since agent.completed was removed
expect(screen.getByText('No triggers match')).toBeInTheDocument();
});

it('should make trigger items draggable', () => {
render(<TriggerDrawer isOpen={true} onClose={() => {}} theme={mockTheme} />);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TriggerNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode';
import { ReactFlowProvider } from 'reactflow';
import type { NodeProps } from 'reactflow';
import type { TriggerNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode';

const defaultData: TriggerNodeDataProps = {
compositeId: 'pipeline-1:trigger-0',
eventType: 'time.heartbeat',
label: 'Heartbeat',
configSummary: 'every 5min',
};

function renderTriggerNode(overrides: Partial<TriggerNodeDataProps> = {}, selected = false) {
const data = { ...defaultData, ...overrides };
const props = {
id: 'test-trigger',
data,
type: 'trigger',
selected,
isConnectable: true,
xPos: 0,
yPos: 0,
zIndex: 0,
dragging: false,
} as NodeProps<TriggerNodeDataProps>;

return render(
<ReactFlowProvider>
<TriggerNode {...props} />
</ReactFlowProvider>
);
}

describe('TriggerNode', () => {
it('should render label and config summary', () => {
renderTriggerNode();

expect(screen.getByText('Heartbeat')).toBeInTheDocument();
expect(screen.getByText('every 5min')).toBeInTheDocument();
});

it('should render a drag handle', () => {
const { container } = renderTriggerNode();
const dragHandle = container.querySelector('.drag-handle');
expect(dragHandle).not.toBeNull();
});

it('should render a gear icon for configuration', () => {
const { container } = renderTriggerNode();
const gearButton = container.querySelector('[title="Configure"]');
expect(gearButton).not.toBeNull();
});

it('should show title tooltip on the label span', () => {
renderTriggerNode({ label: 'My Custom Label' });

const labelSpan = screen.getByText('My Custom Label');
expect(labelSpan).toHaveAttribute('title', 'My Custom Label');
});

it('should show title tooltip on the config summary span', () => {
renderTriggerNode({ configSummary: 'every 10min' });

const summarySpan = screen.getByText('every 10min');
expect(summarySpan).toHaveAttribute('title', 'every 10min');
});

it('should show tooltip with full text for long labels', () => {
const longLabel = 'This is a very long trigger label that will be truncated';
renderTriggerNode({ label: longLabel });

const labelSpan = screen.getByText(longLabel);
expect(labelSpan).toHaveAttribute('title', longLabel);
});

it('should show tooltip with full text for long config summaries', () => {
const longSummary = 'Mon, Tue, Wed, Thu, Fri at 09:00, 12:00, 15:00, 18:00';
renderTriggerNode({ configSummary: longSummary });

const summarySpan = screen.getByText(longSummary);
expect(summarySpan).toHaveAttribute('title', longSummary);
});

it('should use minWidth and maxWidth instead of fixed width', () => {
const { container } = renderTriggerNode();

const rootDiv = container.querySelector('div[style*="min-width: 220px"]') as HTMLElement;
expect(rootDiv).not.toBeNull();
expect(rootDiv.style.maxWidth).toBe('320px');
// Ensure no fixed width is set
expect(rootDiv.style.width).toBe('');
});

it('should not render config summary when empty', () => {
renderTriggerNode({ configSummary: '' });

// The summary span should not be in the DOM
expect(screen.queryByText('every 5min')).not.toBeInTheDocument();
});

it('should call onConfigure when gear icon is clicked', () => {
const onConfigure = vi.fn();
const { container } = renderTriggerNode({
onConfigure,
compositeId: 'pipeline-1:trigger-0',
});

const gearButton = container.querySelector('[title="Configure"]') as HTMLElement;
gearButton.click();

expect(onConfigure).toHaveBeenCalledWith('pipeline-1:trigger-0');
});

it('should apply selection styling when selected', () => {
const { container: selectedContainer } = renderTriggerNode({}, true);
const { container: unselectedContainer } = renderTriggerNode({}, false);

const selectedRoot = selectedContainer.querySelector(
'div[style*="min-width: 220px"]'
) as HTMLElement;
const unselectedRoot = unselectedContainer.querySelector(
'div[style*="min-width: 220px"]'
) as HTMLElement;

// Selected and unselected should have different border colors
expect(selectedRoot.style.borderColor).not.toBe(unselectedRoot.style.borderColor);
// Selected should have a box shadow, unselected should not
expect(selectedRoot.style.boxShadow).toBeTruthy();
expect(unselectedRoot.style.boxShadow).toBeFalsy();
});

it('should use correct color for each event type', () => {
const eventColors: Record<string, string> = {
'time.heartbeat': '#f59e0b',
'time.scheduled': '#8b5cf6',
'file.changed': '#3b82f6',
'agent.completed': '#22c55e',
'github.pull_request': '#a855f7',
'github.issue': '#f97316',
'task.pending': '#06b6d4',
};

for (const [eventType, expectedColor] of Object.entries(eventColors)) {
const { unmount } = renderTriggerNode({
eventType: eventType as TriggerNodeDataProps['eventType'],
label: eventType,
});

const labelSpan = screen.getByText(eventType);
expect(labelSpan).toHaveStyle({ color: expectedColor });

unmount();
}
});
});
157 changes: 156 additions & 1 deletion src/__tests__/renderer/components/History/ActivityGraph.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { render, screen, fireEvent, act, within } from '@testing-library/react';
import { ActivityGraph } from '../../../../renderer/components/History';
import type { Theme, HistoryEntry, HistoryEntryType } from '../../../../renderer/types';

Expand Down Expand Up @@ -325,4 +325,159 @@ describe('ActivityGraph', () => {
const graphContainer = screen.getByTitle(/1 auto, 1 user/);
expect(graphContainer).toBeInTheDocument();
});

it('counts CUE entries in buckets', () => {
const entries = [
createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }),
createMockEntry({ type: 'CUE', timestamp: NOW - 45 * 60 * 1000 }),
createMockEntry({ type: 'AUTO', timestamp: NOW - 35 * 60 * 1000 }),
];

const { container } = render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={24}
onLookbackChange={vi.fn()}
/>
);

// Hover over the last bucket to see tooltip
const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end');
const lastBar = bars[bars.length - 1];
fireEvent.mouseEnter(lastBar);

// Should show Cue row in tooltip with count scoped to the Cue row
const cueLabel = screen.getByText('Cue');
expect(cueLabel).toBeInTheDocument();
const cueRow = cueLabel.closest('div')!;
expect(within(cueRow).getByText('2')).toBeInTheDocument();
});

it('does not show Cue row in tooltip when bucket has no CUE entries', () => {
const entries = [
createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }),
createMockEntry({ type: 'USER', timestamp: NOW - 35 * 60 * 1000 }),
];

const { container } = render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={24}
onLookbackChange={vi.fn()}
/>
);

const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end');
const lastBar = bars[bars.length - 1];
fireEvent.mouseEnter(lastBar);

// Auto and User should appear, but not Cue
expect(screen.getByText('Auto')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.queryByText('Cue')).not.toBeInTheDocument();
});

it('includes CUE count in summary title when present', () => {
const entries = [
createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 }),
createMockEntry({ type: 'CUE', timestamp: NOW - 2 * 60 * 60 * 1000 }),
];

render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={168}
onLookbackChange={vi.fn()}
/>
);

const graphContainer = screen.getByTitle(/1 auto, 0 user, 1 cue/);
expect(graphContainer).toBeInTheDocument();
});

it('excludes CUE count from summary title when zero', () => {
const entries = [createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 })];

render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={168}
onLookbackChange={vi.fn()}
/>
);

// Title should NOT contain "cue"
const graphContainer = screen.getByTitle(/1 auto, 0 user \(right-click/);
expect(graphContainer).toBeInTheDocument();
});

it('makes CUE-only bars clickable', () => {
const onBarClick = vi.fn();
const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })];

const { container } = render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={24}
onLookbackChange={vi.fn()}
onBarClick={onBarClick}
/>
);

const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end');
const lastBar = bars[bars.length - 1];
fireEvent.click(lastBar);

expect(onBarClick).toHaveBeenCalledWith(expect.any(Number), expect.any(Number));
});

it('renders CUE bar segment with correct color in tooltip', () => {
const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })];

const { container } = render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={24}
onLookbackChange={vi.fn()}
/>
);

// Hover to show tooltip, then verify CUE label uses the cyan color
const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end');
const lastBar = bars[bars.length - 1];
fireEvent.mouseEnter(lastBar);

const cueLabel = screen.getByText('Cue');
expect(cueLabel).toHaveStyle({ color: '#06b6d4' });
});

it('scales bar height correctly with mixed AUTO, USER, and CUE entries', () => {
const entries = [
createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }),
createMockEntry({ type: 'USER', timestamp: NOW - 30 * 60 * 1000 }),
createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }),
];

const { container } = render(
<ActivityGraph
entries={entries}
theme={mockTheme}
lookbackHours={24}
onLookbackChange={vi.fn()}
/>
);

// The bar with all 3 entries should be at 100% height (it's the max)
const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end');
const lastBar = bars[bars.length - 1];
const barInner = lastBar.querySelector('.w-full.rounded-t-sm') as HTMLElement;
// height should be 100% since this is the max bucket
expect(barInner.style.height).toBe('100%');
});
});
Loading