feat(phase-11): CF11-006 Execution History Panel UI

- ExecutionHistoryPanel React component registered in sidebar (order 8.8)
- ExecutionList + ExecutionItem: scrollable list with status dots, duration, trigger type
- ExecutionDetail: summary, error display, trigger data, node steps
- NodeStepList + NodeStepItem: expandable rows with input/output JSON
- ExecutionFilters: status dropdown filter with clear button
- useExecutionHistory hook: IPC-based data fetching with filter support
- useExecutionDetail hook: single execution fetch with steps
- All styles use design tokens (no hardcoded colors)
- Unit tests: formatDuration, formatRelativeTime, buildExecutionQuery (Jasmine)
- CF11-007 canvas overlay integration point: onPinToCanvas prop stub

IPC channels expected from backend:
  execution-history:list (ExecutionQuery) -> WorkflowExecution[]
  execution-history:get (id) -> ExecutionWithSteps
This commit is contained in:
dishant-kumar-thakur
2026-02-18 21:34:45 +05:30
parent 10cf761d52
commit 7d373e0e50
20 changed files with 1229 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
/**
* CF11-006: Execution History Panel — Unit Tests
*
* Tests the pure utility functions used by the ExecutionHistoryPanel hooks.
* Hook integration tests require a running Electron renderer — covered by manual testing.
*/
// ─── Utility functions under test (inlined to avoid React/IPC deps in test env) ───
function formatRelativeTime(timestamp: number): string {
const diff = Date.now() - timestamp;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return new Date(timestamp).toLocaleDateString();
}
function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function buildExecutionQuery(filters: {
status?: string;
startDate?: Date;
endDate?: Date;
}) {
return {
status: filters.status,
startedAfter: filters.startDate?.getTime(),
startedBefore: filters.endDate?.getTime(),
limit: 100,
orderBy: 'started_at',
orderDir: 'desc'
};
}
// ─── Tests ───
describe('ExecutionHistoryPanel — formatDuration', () => {
it('returns em-dash for undefined', () => {
expect(formatDuration(undefined)).toBe('—');
});
it('returns em-dash for null', () => {
expect(formatDuration(null as unknown as number)).toBe('—');
});
it('formats sub-second durations as ms', () => {
expect(formatDuration(0)).toBe('0ms');
expect(formatDuration(500)).toBe('500ms');
expect(formatDuration(999)).toBe('999ms');
});
it('formats 1000ms as 1.0s', () => {
expect(formatDuration(1000)).toBe('1.0s');
});
it('formats multi-second durations', () => {
expect(formatDuration(2500)).toBe('2.5s');
expect(formatDuration(10000)).toBe('10.0s');
});
});
describe('ExecutionHistoryPanel — formatRelativeTime', () => {
it('formats recent timestamps as seconds ago', () => {
const result = formatRelativeTime(Date.now() - 30000); // 30s ago
expect(result).toBe('30s ago');
});
it('formats timestamps as minutes ago', () => {
const result = formatRelativeTime(Date.now() - 5 * 60 * 1000); // 5m ago
expect(result).toBe('5m ago');
});
it('formats timestamps as hours ago', () => {
const result = formatRelativeTime(Date.now() - 3 * 60 * 60 * 1000); // 3h ago
expect(result).toBe('3h ago');
});
it('formats old timestamps as locale date string', () => {
const oldDate = new Date('2025-01-01').getTime();
const result = formatRelativeTime(oldDate);
// Should be a date string, not "Xh ago"
expect(result).not.toContain('ago');
});
});
describe('ExecutionHistoryPanel — buildExecutionQuery', () => {
it('builds query with no filters', () => {
const query = buildExecutionQuery({});
expect(query.status).toBeUndefined();
expect(query.startedAfter).toBeUndefined();
expect(query.startedBefore).toBeUndefined();
expect(query.limit).toBe(100);
expect(query.orderBy).toBe('started_at');
expect(query.orderDir).toBe('desc');
});
it('includes status filter when provided', () => {
const query = buildExecutionQuery({ status: 'error' });
expect(query.status).toBe('error');
});
it('converts Date objects to timestamps', () => {
const startDate = new Date('2025-01-01T00:00:00Z');
const endDate = new Date('2025-12-31T23:59:59Z');
const query = buildExecutionQuery({ startDate, endDate });
expect(query.startedAfter).toBe(startDate.getTime());
expect(query.startedBefore).toBe(endDate.getTime());
});
it('always orders by started_at descending', () => {
const query = buildExecutionQuery({ status: 'success' });
expect(query.orderBy).toBe('started_at');
expect(query.orderDir).toBe('desc');
});
it('always limits to 100 results', () => {
const query = buildExecutionQuery({});
expect(query.limit).toBe(100);
});
});