Add unit tests for dashboard-integration module#258
Add unit tests for dashboard-integration module#258nikolasdehor wants to merge 1 commit intoSynkraAI:mainfrom
Conversation
|
@nikolasdehor is attempting to deploy a commit to the Pedro Valério Lopez's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive unit tests for the dashboard-integration orchestration module, which provides real-time monitoring capabilities for the orchestrator through status files, history tracking, and notifications.
Changes:
- Add 42 unit tests (not 43 as claimed) covering DashboardIntegration class functionality
- Mock fs-extra and core/events dependencies appropriately
- Use fake timers for interval-based functionality testing
- Create helper function for mock orchestrator generation
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Unit tests for dashboard-integration module | ||
| * | ||
| * Tests the DashboardIntegration class that provides real-time | ||
| * orchestrator monitoring with status files, history, and notifications. | ||
| */ | ||
|
|
||
| jest.mock('fs-extra'); | ||
| jest.mock('../../../.aios-core/core/events', () => ({ | ||
| getDashboardEmitter: () => ({ | ||
| emitStoryStatusChange: jest.fn(), | ||
| emitCommandStart: jest.fn(), | ||
| emitCommandComplete: jest.fn(), | ||
| emitCommandError: jest.fn(), | ||
| emitAgentActivated: jest.fn(), | ||
| emitAgentDeactivated: jest.fn(), | ||
| }), | ||
| })); | ||
|
|
||
| const fs = require('fs-extra'); | ||
| const { DashboardIntegration, NotificationType } = require('../../../.aios-core/core/orchestration/dashboard-integration'); | ||
|
|
||
| describe('DashboardIntegration', () => { | ||
| let dashboard; | ||
|
|
||
| beforeEach(() => { | ||
| jest.resetAllMocks(); | ||
| jest.useFakeTimers(); | ||
| fs.ensureDir.mockResolvedValue(); | ||
| fs.writeJson.mockResolvedValue(); | ||
| dashboard = new DashboardIntegration({ projectRoot: '/project' }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| dashboard.stop(); | ||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // NotificationType | ||
| // ============================================================ | ||
| describe('NotificationType', () => { | ||
| test('has all expected types', () => { | ||
| expect(NotificationType.INFO).toBe('info'); | ||
| expect(NotificationType.SUCCESS).toBe('success'); | ||
| expect(NotificationType.WARNING).toBe('warning'); | ||
| expect(NotificationType.ERROR).toBe('error'); | ||
| expect(NotificationType.BLOCKED).toBe('blocked'); | ||
| expect(NotificationType.COMPLETE).toBe('complete'); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Constructor | ||
| // ============================================================ | ||
| describe('constructor', () => { | ||
| test('sets project root', () => { | ||
| expect(dashboard.projectRoot).toBe('/project'); | ||
| }); | ||
|
|
||
| test('sets dashboard paths', () => { | ||
| expect(dashboard.statusPath).toContain('dashboard'); | ||
| expect(dashboard.statusPath).toContain('status.json'); | ||
| }); | ||
|
|
||
| test('initializes empty state', () => { | ||
| expect(dashboard.history).toEqual([]); | ||
| expect(dashboard.notifications).toEqual([]); | ||
| expect(dashboard.isRunning).toBe(false); | ||
| }); | ||
|
|
||
| test('defaults autoUpdate to true', () => { | ||
| expect(dashboard.autoUpdate).toBe(true); | ||
| }); | ||
|
|
||
| test('accepts custom options', () => { | ||
| const custom = new DashboardIntegration({ | ||
| projectRoot: '/custom', | ||
| autoUpdate: false, | ||
| updateInterval: 10000, | ||
| }); | ||
| expect(custom.projectRoot).toBe('/custom'); | ||
| expect(custom.autoUpdate).toBe(false); | ||
| expect(custom.updateInterval).toBe(10000); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // start / stop | ||
| // ============================================================ | ||
| describe('start / stop', () => { | ||
| test('start creates directories and marks running', async () => { | ||
| await dashboard.start(); | ||
|
|
||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| expect(dashboard.isRunning).toBe(true); | ||
| }); | ||
|
|
||
| test('start emits started event', async () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('started', handler); | ||
|
|
||
| await dashboard.start(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('start is idempotent', async () => { | ||
| await dashboard.start(); | ||
| await dashboard.start(); | ||
|
|
||
| // ensureDir should only be called once (from first start) | ||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| test('stop clears timer and marks not running', async () => { | ||
| await dashboard.start(); | ||
| dashboard.stop(); | ||
|
|
||
| expect(dashboard.isRunning).toBe(false); | ||
| expect(dashboard.updateTimer).toBeNull(); | ||
| }); | ||
|
|
||
| test('stop emits stopped event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('stopped', handler); | ||
|
|
||
| dashboard.stop(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // updateStatus | ||
| // ============================================================ | ||
| describe('updateStatus', () => { | ||
| test('returns undefined when no orchestrator', async () => { | ||
| const result = await dashboard.updateStatus(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('writes status to file when orchestrator exists', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(fs.writeJson).toHaveBeenCalledWith( | ||
| dashboard.statusPath, | ||
| expect.any(Object), | ||
| { spaces: 2 } | ||
| ); | ||
| }); | ||
|
|
||
| test('emits statusUpdated event', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| const handler = jest.fn(); | ||
| dashboard.on('statusUpdated', handler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.any(Object)); | ||
| }); | ||
|
|
||
| test('handles write errors gracefully with error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(errorHandler).toHaveBeenCalledWith( | ||
| expect.objectContaining({ type: 'statusUpdate' }) | ||
| ); | ||
| }); | ||
|
|
||
| test('handles write errors with console.warn when no error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| jest.spyOn(console, 'warn').mockImplementation(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(console.warn).toHaveBeenCalledWith( | ||
| expect.stringContaining('statusUpdate') | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // buildStatus | ||
| // ============================================================ | ||
| describe('buildStatus', () => { | ||
| test('returns empty object when no orchestrator', () => { | ||
| expect(dashboard.buildStatus()).toEqual({}); | ||
| }); | ||
|
|
||
| test('builds status with orchestrator data', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
|
|
||
| expect(status.orchestrator).toBeDefined(); | ||
| expect(status.orchestrator['story-1']).toBeDefined(); | ||
| expect(status.orchestrator['story-1'].status).toBe('running'); | ||
| expect(status.orchestrator['story-1'].currentEpic).toBe(3); | ||
| }); | ||
|
|
||
| test('includes progress information', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.progress).toBeDefined(); | ||
| expect(storyStatus.progress.overall).toBe(50); | ||
| }); | ||
|
|
||
| test('includes history and notifications', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.history.length).toBe(1); | ||
| expect(storyStatus.notifications.length).toBe(1); | ||
| }); | ||
|
|
||
| test('includes logs path', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.logsPath).toContain('story-1.log'); | ||
| }); | ||
|
|
||
| test('includes blocked flag', () => { | ||
| const orch = createMockOrchestrator(); | ||
| orch.state = 'blocked'; | ||
| dashboard.orchestrator = orch; | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| expect(status.orchestrator['story-1'].blocked).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // addToHistory / getHistory | ||
| // ============================================================ | ||
| describe('history', () => { | ||
| test('addToHistory adds entry with id', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| expect(dashboard.history[0].id).toMatch(/^hist-/); | ||
| expect(dashboard.history[0].type).toBe('epicComplete'); | ||
| }); | ||
|
|
||
| test('getHistory returns copy', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
|
|
||
| const history = dashboard.getHistory(); | ||
| history.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('getHistoryForEpic filters by epicNum', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
| dashboard.addToHistory({ type: 'epicFailed', epicNum: 4 }); | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.getHistoryForEpic(3)).toHaveLength(2); | ||
| expect(dashboard.getHistoryForEpic(4)).toHaveLength(1); | ||
| expect(dashboard.getHistoryForEpic(5)).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('history is capped at 100 entries', () => { | ||
| for (let i = 0; i < 110; i++) { | ||
| dashboard.addToHistory({ type: 'test', index: i }); | ||
| } | ||
|
|
||
| expect(dashboard.history).toHaveLength(100); | ||
| // Oldest entries should be trimmed | ||
| expect(dashboard.history[0].index).toBe(10); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Notifications | ||
| // ============================================================ | ||
| describe('notifications', () => { | ||
| test('addNotification adds with id and read=false', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| expect(dashboard.notifications[0].id).toMatch(/^notif-/); | ||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('addNotification emits notification event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('notification', handler); | ||
|
|
||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test' })); | ||
| }); | ||
|
|
||
| test('getNotifications returns all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| expect(dashboard.getNotifications()).toHaveLength(2); | ||
| }); | ||
|
|
||
| test('getNotifications unread only', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
| dashboard.notifications[0].read = true; | ||
|
|
||
| expect(dashboard.getNotifications(true)).toHaveLength(1); | ||
| expect(dashboard.getNotifications(true)[0].title).toBe('B'); | ||
| }); | ||
|
|
||
| test('getNotifications returns copy', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| const notifs = dashboard.getNotifications(); | ||
| notifs.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('markNotificationRead marks specific notification', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| const id = dashboard.notifications[0].id; | ||
|
|
||
| dashboard.markNotificationRead(id); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(true); | ||
| }); | ||
|
|
||
| test('markNotificationRead ignores unknown id', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.markNotificationRead('nonexistent'); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('markAllNotificationsRead marks all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.markAllNotificationsRead(); | ||
|
|
||
| expect(dashboard.notifications.every(n => n.read)).toBe(true); | ||
| }); | ||
|
|
||
| test('clearNotifications removes all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.clearNotifications(); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('notifications are capped at 50', () => { | ||
| for (let i = 0; i < 55; i++) { | ||
| dashboard.addNotification({ type: 'info', title: `N${i}` }); | ||
| } | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getProgressPercentage | ||
| // ============================================================ | ||
| describe('getProgressPercentage', () => { | ||
| test('returns 0 when no orchestrator', () => { | ||
| expect(dashboard.getProgressPercentage()).toBe(0); | ||
| }); | ||
|
|
||
| test('delegates to orchestrator', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| expect(dashboard.getProgressPercentage()).toBe(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getStatusPath / readStatus | ||
| // ============================================================ | ||
| describe('getStatusPath / readStatus', () => { | ||
| test('getStatusPath returns status file path', () => { | ||
| expect(dashboard.getStatusPath()).toBe(dashboard.statusPath); | ||
| }); | ||
|
|
||
| test('readStatus returns JSON when file exists', async () => { | ||
| fs.pathExists.mockResolvedValue(true); | ||
| fs.readJson.mockResolvedValue({ status: 'ok' }); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toEqual({ status: 'ok' }); | ||
| }); | ||
|
|
||
| test('readStatus returns null when file missing', async () => { | ||
| fs.pathExists.mockResolvedValue(false); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| test('readStatus emits error and returns null on failure', async () => { | ||
| fs.pathExists.mockRejectedValue(new Error('read error')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
|
|
||
| expect(result).toBeNull(); | ||
| expect(errorHandler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // clear | ||
| // ============================================================ | ||
| describe('clear', () => { | ||
| test('clears history and notifications', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.clear(); | ||
|
|
||
| expect(dashboard.history).toHaveLength(0); | ||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Helpers | ||
| // ============================================================ | ||
| function createMockOrchestrator() { | ||
| return { | ||
| storyId: 'story-1', | ||
| state: 'running', | ||
| executionState: { | ||
| currentEpic: 3, | ||
| startedAt: '2026-01-01T00:00:00Z', | ||
| epics: { | ||
| 1: { status: 'completed' }, | ||
| 2: { status: 'completed' }, | ||
| 3: { status: 'in_progress' }, | ||
| 4: { status: 'pending' }, | ||
| }, | ||
| errors: [], | ||
| }, | ||
| constructor: { | ||
| EPIC_CONFIG: { | ||
| 3: { name: 'Epic 3 - Implementation' }, | ||
| }, | ||
| }, | ||
| getProgressPercentage: jest.fn().mockReturnValue(50), | ||
| on: jest.fn(), | ||
| }; | ||
| } |
There was a problem hiding this comment.
The PR description claims "Add 43 unit tests" but the test file contains only 42 tests. Please update the PR description to accurately reflect the actual number of tests (42), or add the missing test if one was intended.
|
|
||
| expect(result).toBeNull(); | ||
| expect(errorHandler).toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The test for readStatus error handling doesn't fully test the error handling pattern. The implementation uses this.emit('error', ...) directly on line 498 of dashboard-integration.js, which will throw an uncaught exception if no error listener is attached. This is inconsistent with updateStatus() which uses _emitSafeError() to gracefully handle missing listeners. Consider adding a test case similar to line 178-188 that verifies behavior when no error listener is attached, or update the implementation to use _emitSafeError() for consistency.
| }); | |
| }); | |
| test('readStatus rejects when error emitted with no listener', async () => { | |
| fs.pathExists.mockRejectedValue(new Error('read error no listener')); | |
| await expect(dashboard.readStatus()).rejects.toThrow('read error no listener'); | |
| }); |
| await dashboard.start(); | ||
| await dashboard.start(); | ||
|
|
||
| // ensureDir should only be called once (from first start) |
There was a problem hiding this comment.
The comment "ensureDir should only be called once (from first start)" is misleading. The start() method calls ensureDir twice per start (once for dashboardDir, once for logsDir), so the total of 2 calls after calling start() twice is correct due to idempotent behavior preventing the second start. Consider updating the comment to: "ensureDir should only be called from first start (2 directories)" or similar to clarify the expectation.
| // ensureDir should only be called once (from first start) | |
| // ensureDir should only be called from first start (2 directories) |
| dashboard.stop(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The tests don't verify the auto-update timer behavior. Consider adding tests to verify: (1) when autoUpdate is true, the timer is set and periodically calls updateStatus(), (2) when autoUpdate is false, no timer is set after start(), and (3) the timer respects the updateInterval setting. Since fake timers are already being used, you can use jest.advanceTimersByTime() to test the periodic behavior.
| }); | |
| }); | |
| test('autoUpdate schedules periodic status updates when enabled', async () => { | |
| jest.useFakeTimers(); | |
| dashboard.autoUpdate = true; | |
| dashboard.updateInterval = 5000; | |
| const updateSpy = jest | |
| .spyOn(dashboard, 'updateStatus') | |
| .mockResolvedValue(undefined); | |
| await dashboard.start(); | |
| // Timer should be created when autoUpdate is enabled | |
| expect(dashboard.updateTimer).not.toBeNull(); | |
| // No calls before any time has passed | |
| expect(updateSpy).not.toHaveBeenCalled(); | |
| // Advance time by three intervals | |
| jest.advanceTimersByTime(15000); | |
| // Allow any pending promises scheduled by the timer callbacks to resolve | |
| await Promise.resolve(); | |
| expect(updateSpy).toHaveBeenCalledTimes(3); | |
| dashboard.stop(); | |
| updateSpy.mockRestore(); | |
| jest.useRealTimers(); | |
| }); | |
| test('start does not create timer when autoUpdate is false', async () => { | |
| jest.useFakeTimers(); | |
| dashboard.autoUpdate = false; | |
| const updateSpy = jest | |
| .spyOn(dashboard, 'updateStatus') | |
| .mockResolvedValue(undefined); | |
| await dashboard.start(); | |
| // When autoUpdate is disabled, no timer should be created | |
| expect(dashboard.updateTimer).toBeNull(); | |
| // Even if we advance timers, updateStatus should not be called | |
| jest.advanceTimersByTime(60000); | |
| await Promise.resolve(); | |
| expect(updateSpy).not.toHaveBeenCalled(); | |
| dashboard.stop(); | |
| updateSpy.mockRestore(); | |
| jest.useRealTimers(); | |
| }); | |
| test('autoUpdate timer respects updateInterval setting', async () => { | |
| jest.useFakeTimers(); | |
| dashboard.autoUpdate = true; | |
| dashboard.updateInterval = 2000; | |
| const updateSpy = jest | |
| .spyOn(dashboard, 'updateStatus') | |
| .mockResolvedValue(undefined); | |
| await dashboard.start(); | |
| // Advance time by five intervals | |
| jest.advanceTimersByTime(10000); | |
| await Promise.resolve(); | |
| expect(updateSpy).toHaveBeenCalledTimes(5); | |
| dashboard.stop(); | |
| updateSpy.mockRestore(); | |
| jest.useRealTimers(); | |
| }); |
| describe('DashboardIntegration', () => { | ||
| let dashboard; | ||
|
|
||
| beforeEach(() => { | ||
| jest.resetAllMocks(); | ||
| jest.useFakeTimers(); | ||
| fs.ensureDir.mockResolvedValue(); | ||
| fs.writeJson.mockResolvedValue(); | ||
| dashboard = new DashboardIntegration({ projectRoot: '/project' }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| dashboard.stop(); | ||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // NotificationType | ||
| // ============================================================ | ||
| describe('NotificationType', () => { | ||
| test('has all expected types', () => { | ||
| expect(NotificationType.INFO).toBe('info'); | ||
| expect(NotificationType.SUCCESS).toBe('success'); | ||
| expect(NotificationType.WARNING).toBe('warning'); | ||
| expect(NotificationType.ERROR).toBe('error'); | ||
| expect(NotificationType.BLOCKED).toBe('blocked'); | ||
| expect(NotificationType.COMPLETE).toBe('complete'); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Constructor | ||
| // ============================================================ | ||
| describe('constructor', () => { | ||
| test('sets project root', () => { | ||
| expect(dashboard.projectRoot).toBe('/project'); | ||
| }); | ||
|
|
||
| test('sets dashboard paths', () => { | ||
| expect(dashboard.statusPath).toContain('dashboard'); | ||
| expect(dashboard.statusPath).toContain('status.json'); | ||
| }); | ||
|
|
||
| test('initializes empty state', () => { | ||
| expect(dashboard.history).toEqual([]); | ||
| expect(dashboard.notifications).toEqual([]); | ||
| expect(dashboard.isRunning).toBe(false); | ||
| }); | ||
|
|
||
| test('defaults autoUpdate to true', () => { | ||
| expect(dashboard.autoUpdate).toBe(true); | ||
| }); | ||
|
|
||
| test('accepts custom options', () => { | ||
| const custom = new DashboardIntegration({ | ||
| projectRoot: '/custom', | ||
| autoUpdate: false, | ||
| updateInterval: 10000, | ||
| }); | ||
| expect(custom.projectRoot).toBe('/custom'); | ||
| expect(custom.autoUpdate).toBe(false); | ||
| expect(custom.updateInterval).toBe(10000); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // start / stop | ||
| // ============================================================ | ||
| describe('start / stop', () => { | ||
| test('start creates directories and marks running', async () => { | ||
| await dashboard.start(); | ||
|
|
||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| expect(dashboard.isRunning).toBe(true); | ||
| }); | ||
|
|
||
| test('start emits started event', async () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('started', handler); | ||
|
|
||
| await dashboard.start(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('start is idempotent', async () => { | ||
| await dashboard.start(); | ||
| await dashboard.start(); | ||
|
|
||
| // ensureDir should only be called once (from first start) | ||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| test('stop clears timer and marks not running', async () => { | ||
| await dashboard.start(); | ||
| dashboard.stop(); | ||
|
|
||
| expect(dashboard.isRunning).toBe(false); | ||
| expect(dashboard.updateTimer).toBeNull(); | ||
| }); | ||
|
|
||
| test('stop emits stopped event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('stopped', handler); | ||
|
|
||
| dashboard.stop(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // updateStatus | ||
| // ============================================================ | ||
| describe('updateStatus', () => { | ||
| test('returns undefined when no orchestrator', async () => { | ||
| const result = await dashboard.updateStatus(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('writes status to file when orchestrator exists', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(fs.writeJson).toHaveBeenCalledWith( | ||
| dashboard.statusPath, | ||
| expect.any(Object), | ||
| { spaces: 2 } | ||
| ); | ||
| }); | ||
|
|
||
| test('emits statusUpdated event', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| const handler = jest.fn(); | ||
| dashboard.on('statusUpdated', handler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.any(Object)); | ||
| }); | ||
|
|
||
| test('handles write errors gracefully with error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(errorHandler).toHaveBeenCalledWith( | ||
| expect.objectContaining({ type: 'statusUpdate' }) | ||
| ); | ||
| }); | ||
|
|
||
| test('handles write errors with console.warn when no error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| jest.spyOn(console, 'warn').mockImplementation(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(console.warn).toHaveBeenCalledWith( | ||
| expect.stringContaining('statusUpdate') | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // buildStatus | ||
| // ============================================================ | ||
| describe('buildStatus', () => { | ||
| test('returns empty object when no orchestrator', () => { | ||
| expect(dashboard.buildStatus()).toEqual({}); | ||
| }); | ||
|
|
||
| test('builds status with orchestrator data', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
|
|
||
| expect(status.orchestrator).toBeDefined(); | ||
| expect(status.orchestrator['story-1']).toBeDefined(); | ||
| expect(status.orchestrator['story-1'].status).toBe('running'); | ||
| expect(status.orchestrator['story-1'].currentEpic).toBe(3); | ||
| }); | ||
|
|
||
| test('includes progress information', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.progress).toBeDefined(); | ||
| expect(storyStatus.progress.overall).toBe(50); | ||
| }); | ||
|
|
||
| test('includes history and notifications', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.history.length).toBe(1); | ||
| expect(storyStatus.notifications.length).toBe(1); | ||
| }); | ||
|
|
||
| test('includes logs path', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.logsPath).toContain('story-1.log'); | ||
| }); | ||
|
|
||
| test('includes blocked flag', () => { | ||
| const orch = createMockOrchestrator(); | ||
| orch.state = 'blocked'; | ||
| dashboard.orchestrator = orch; | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| expect(status.orchestrator['story-1'].blocked).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // addToHistory / getHistory | ||
| // ============================================================ | ||
| describe('history', () => { | ||
| test('addToHistory adds entry with id', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| expect(dashboard.history[0].id).toMatch(/^hist-/); | ||
| expect(dashboard.history[0].type).toBe('epicComplete'); | ||
| }); | ||
|
|
||
| test('getHistory returns copy', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
|
|
||
| const history = dashboard.getHistory(); | ||
| history.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('getHistoryForEpic filters by epicNum', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
| dashboard.addToHistory({ type: 'epicFailed', epicNum: 4 }); | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.getHistoryForEpic(3)).toHaveLength(2); | ||
| expect(dashboard.getHistoryForEpic(4)).toHaveLength(1); | ||
| expect(dashboard.getHistoryForEpic(5)).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('history is capped at 100 entries', () => { | ||
| for (let i = 0; i < 110; i++) { | ||
| dashboard.addToHistory({ type: 'test', index: i }); | ||
| } | ||
|
|
||
| expect(dashboard.history).toHaveLength(100); | ||
| // Oldest entries should be trimmed | ||
| expect(dashboard.history[0].index).toBe(10); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Notifications | ||
| // ============================================================ | ||
| describe('notifications', () => { | ||
| test('addNotification adds with id and read=false', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| expect(dashboard.notifications[0].id).toMatch(/^notif-/); | ||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('addNotification emits notification event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('notification', handler); | ||
|
|
||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test' })); | ||
| }); | ||
|
|
||
| test('getNotifications returns all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| expect(dashboard.getNotifications()).toHaveLength(2); | ||
| }); | ||
|
|
||
| test('getNotifications unread only', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
| dashboard.notifications[0].read = true; | ||
|
|
||
| expect(dashboard.getNotifications(true)).toHaveLength(1); | ||
| expect(dashboard.getNotifications(true)[0].title).toBe('B'); | ||
| }); | ||
|
|
||
| test('getNotifications returns copy', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| const notifs = dashboard.getNotifications(); | ||
| notifs.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('markNotificationRead marks specific notification', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| const id = dashboard.notifications[0].id; | ||
|
|
||
| dashboard.markNotificationRead(id); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(true); | ||
| }); | ||
|
|
||
| test('markNotificationRead ignores unknown id', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.markNotificationRead('nonexistent'); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('markAllNotificationsRead marks all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.markAllNotificationsRead(); | ||
|
|
||
| expect(dashboard.notifications.every(n => n.read)).toBe(true); | ||
| }); | ||
|
|
||
| test('clearNotifications removes all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.clearNotifications(); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('notifications are capped at 50', () => { | ||
| for (let i = 0; i < 55; i++) { | ||
| dashboard.addNotification({ type: 'info', title: `N${i}` }); | ||
| } | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getProgressPercentage | ||
| // ============================================================ | ||
| describe('getProgressPercentage', () => { | ||
| test('returns 0 when no orchestrator', () => { | ||
| expect(dashboard.getProgressPercentage()).toBe(0); | ||
| }); | ||
|
|
||
| test('delegates to orchestrator', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| expect(dashboard.getProgressPercentage()).toBe(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getStatusPath / readStatus | ||
| // ============================================================ | ||
| describe('getStatusPath / readStatus', () => { | ||
| test('getStatusPath returns status file path', () => { | ||
| expect(dashboard.getStatusPath()).toBe(dashboard.statusPath); | ||
| }); | ||
|
|
||
| test('readStatus returns JSON when file exists', async () => { | ||
| fs.pathExists.mockResolvedValue(true); | ||
| fs.readJson.mockResolvedValue({ status: 'ok' }); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toEqual({ status: 'ok' }); | ||
| }); | ||
|
|
||
| test('readStatus returns null when file missing', async () => { | ||
| fs.pathExists.mockResolvedValue(false); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| test('readStatus emits error and returns null on failure', async () => { | ||
| fs.pathExists.mockRejectedValue(new Error('read error')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
|
|
||
| expect(result).toBeNull(); | ||
| expect(errorHandler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // clear | ||
| // ============================================================ | ||
| describe('clear', () => { | ||
| test('clears history and notifications', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.clear(); | ||
|
|
||
| expect(dashboard.history).toHaveLength(0); | ||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Helpers | ||
| // ============================================================ | ||
| function createMockOrchestrator() { | ||
| return { | ||
| storyId: 'story-1', | ||
| state: 'running', | ||
| executionState: { | ||
| currentEpic: 3, | ||
| startedAt: '2026-01-01T00:00:00Z', | ||
| epics: { | ||
| 1: { status: 'completed' }, | ||
| 2: { status: 'completed' }, | ||
| 3: { status: 'in_progress' }, | ||
| 4: { status: 'pending' }, | ||
| }, | ||
| errors: [], | ||
| }, | ||
| constructor: { | ||
| EPIC_CONFIG: { | ||
| 3: { name: 'Epic 3 - Implementation' }, | ||
| }, | ||
| }, | ||
| getProgressPercentage: jest.fn().mockReturnValue(50), | ||
| on: jest.fn(), | ||
| }; | ||
| } |
There was a problem hiding this comment.
The tests don't verify the orchestrator event binding functionality (_bindOrchestratorEvents). This is a critical integration point where the dashboard listens to orchestrator events like 'epicStart', 'epicComplete', 'epicFailed', 'stateChange', etc. Consider adding tests that: (1) verify events are bound when orchestrator is provided in constructor, (2) simulate orchestrator events and verify the dashboard responds appropriately (updates history, sends notifications, calls updateStatus), and (3) verify emitter methods are called with correct parameters.
| test('has all expected types', () => { | ||
| expect(NotificationType.INFO).toBe('info'); | ||
| expect(NotificationType.SUCCESS).toBe('success'); | ||
| expect(NotificationType.WARNING).toBe('warning'); | ||
| expect(NotificationType.ERROR).toBe('error'); | ||
| expect(NotificationType.BLOCKED).toBe('blocked'); | ||
| expect(NotificationType.COMPLETE).toBe('complete'); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Constructor | ||
| // ============================================================ | ||
| describe('constructor', () => { | ||
| test('sets project root', () => { | ||
| expect(dashboard.projectRoot).toBe('/project'); | ||
| }); | ||
|
|
||
| test('sets dashboard paths', () => { | ||
| expect(dashboard.statusPath).toContain('dashboard'); | ||
| expect(dashboard.statusPath).toContain('status.json'); | ||
| }); | ||
|
|
||
| test('initializes empty state', () => { | ||
| expect(dashboard.history).toEqual([]); | ||
| expect(dashboard.notifications).toEqual([]); | ||
| expect(dashboard.isRunning).toBe(false); | ||
| }); | ||
|
|
||
| test('defaults autoUpdate to true', () => { | ||
| expect(dashboard.autoUpdate).toBe(true); | ||
| }); | ||
|
|
||
| test('accepts custom options', () => { | ||
| const custom = new DashboardIntegration({ | ||
| projectRoot: '/custom', | ||
| autoUpdate: false, | ||
| updateInterval: 10000, | ||
| }); | ||
| expect(custom.projectRoot).toBe('/custom'); | ||
| expect(custom.autoUpdate).toBe(false); | ||
| expect(custom.updateInterval).toBe(10000); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // start / stop | ||
| // ============================================================ | ||
| describe('start / stop', () => { | ||
| test('start creates directories and marks running', async () => { | ||
| await dashboard.start(); | ||
|
|
||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| expect(dashboard.isRunning).toBe(true); | ||
| }); | ||
|
|
||
| test('start emits started event', async () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('started', handler); | ||
|
|
||
| await dashboard.start(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('start is idempotent', async () => { | ||
| await dashboard.start(); | ||
| await dashboard.start(); | ||
|
|
||
| // ensureDir should only be called once (from first start) | ||
| expect(fs.ensureDir).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| test('stop clears timer and marks not running', async () => { | ||
| await dashboard.start(); | ||
| dashboard.stop(); | ||
|
|
||
| expect(dashboard.isRunning).toBe(false); | ||
| expect(dashboard.updateTimer).toBeNull(); | ||
| }); | ||
|
|
||
| test('stop emits stopped event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('stopped', handler); | ||
|
|
||
| dashboard.stop(); | ||
|
|
||
| expect(handler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // updateStatus | ||
| // ============================================================ | ||
| describe('updateStatus', () => { | ||
| test('returns undefined when no orchestrator', async () => { | ||
| const result = await dashboard.updateStatus(); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('writes status to file when orchestrator exists', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(fs.writeJson).toHaveBeenCalledWith( | ||
| dashboard.statusPath, | ||
| expect.any(Object), | ||
| { spaces: 2 } | ||
| ); | ||
| }); | ||
|
|
||
| test('emits statusUpdated event', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| const handler = jest.fn(); | ||
| dashboard.on('statusUpdated', handler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.any(Object)); | ||
| }); | ||
|
|
||
| test('handles write errors gracefully with error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(errorHandler).toHaveBeenCalledWith( | ||
| expect.objectContaining({ type: 'statusUpdate' }) | ||
| ); | ||
| }); | ||
|
|
||
| test('handles write errors with console.warn when no error listener', async () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| fs.writeJson.mockRejectedValue(new Error('write failed')); | ||
| jest.spyOn(console, 'warn').mockImplementation(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(console.warn).toHaveBeenCalledWith( | ||
| expect.stringContaining('statusUpdate') | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // buildStatus | ||
| // ============================================================ | ||
| describe('buildStatus', () => { | ||
| test('returns empty object when no orchestrator', () => { | ||
| expect(dashboard.buildStatus()).toEqual({}); | ||
| }); | ||
|
|
||
| test('builds status with orchestrator data', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
|
|
||
| expect(status.orchestrator).toBeDefined(); | ||
| expect(status.orchestrator['story-1']).toBeDefined(); | ||
| expect(status.orchestrator['story-1'].status).toBe('running'); | ||
| expect(status.orchestrator['story-1'].currentEpic).toBe(3); | ||
| }); | ||
|
|
||
| test('includes progress information', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.progress).toBeDefined(); | ||
| expect(storyStatus.progress.overall).toBe(50); | ||
| }); | ||
|
|
||
| test('includes history and notifications', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.history.length).toBe(1); | ||
| expect(storyStatus.notifications.length).toBe(1); | ||
| }); | ||
|
|
||
| test('includes logs path', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| const storyStatus = status.orchestrator['story-1']; | ||
|
|
||
| expect(storyStatus.logsPath).toContain('story-1.log'); | ||
| }); | ||
|
|
||
| test('includes blocked flag', () => { | ||
| const orch = createMockOrchestrator(); | ||
| orch.state = 'blocked'; | ||
| dashboard.orchestrator = orch; | ||
|
|
||
| const status = dashboard.buildStatus(); | ||
| expect(status.orchestrator['story-1'].blocked).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // addToHistory / getHistory | ||
| // ============================================================ | ||
| describe('history', () => { | ||
| test('addToHistory adds entry with id', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| expect(dashboard.history[0].id).toMatch(/^hist-/); | ||
| expect(dashboard.history[0].type).toBe('epicComplete'); | ||
| }); | ||
|
|
||
| test('getHistory returns copy', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
|
|
||
| const history = dashboard.getHistory(); | ||
| history.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.history).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('getHistoryForEpic filters by epicNum', () => { | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
| dashboard.addToHistory({ type: 'epicFailed', epicNum: 4 }); | ||
| dashboard.addToHistory({ type: 'epicComplete', epicNum: 3 }); | ||
|
|
||
| expect(dashboard.getHistoryForEpic(3)).toHaveLength(2); | ||
| expect(dashboard.getHistoryForEpic(4)).toHaveLength(1); | ||
| expect(dashboard.getHistoryForEpic(5)).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('history is capped at 100 entries', () => { | ||
| for (let i = 0; i < 110; i++) { | ||
| dashboard.addToHistory({ type: 'test', index: i }); | ||
| } | ||
|
|
||
| expect(dashboard.history).toHaveLength(100); | ||
| // Oldest entries should be trimmed | ||
| expect(dashboard.history[0].index).toBe(10); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // Notifications | ||
| // ============================================================ | ||
| describe('notifications', () => { | ||
| test('addNotification adds with id and read=false', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| expect(dashboard.notifications[0].id).toMatch(/^notif-/); | ||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('addNotification emits notification event', () => { | ||
| const handler = jest.fn(); | ||
| dashboard.on('notification', handler); | ||
|
|
||
| dashboard.addNotification({ type: 'info', title: 'Test' }); | ||
|
|
||
| expect(handler).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test' })); | ||
| }); | ||
|
|
||
| test('getNotifications returns all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| expect(dashboard.getNotifications()).toHaveLength(2); | ||
| }); | ||
|
|
||
| test('getNotifications unread only', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
| dashboard.notifications[0].read = true; | ||
|
|
||
| expect(dashboard.getNotifications(true)).toHaveLength(1); | ||
| expect(dashboard.getNotifications(true)[0].title).toBe('B'); | ||
| }); | ||
|
|
||
| test('getNotifications returns copy', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| const notifs = dashboard.getNotifications(); | ||
| notifs.push({ type: 'extra' }); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(1); | ||
| }); | ||
|
|
||
| test('markNotificationRead marks specific notification', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| const id = dashboard.notifications[0].id; | ||
|
|
||
| dashboard.markNotificationRead(id); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(true); | ||
| }); | ||
|
|
||
| test('markNotificationRead ignores unknown id', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.markNotificationRead('nonexistent'); | ||
|
|
||
| expect(dashboard.notifications[0].read).toBe(false); | ||
| }); | ||
|
|
||
| test('markAllNotificationsRead marks all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.markAllNotificationsRead(); | ||
|
|
||
| expect(dashboard.notifications.every(n => n.read)).toBe(true); | ||
| }); | ||
|
|
||
| test('clearNotifications removes all', () => { | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
| dashboard.addNotification({ type: 'error', title: 'B' }); | ||
|
|
||
| dashboard.clearNotifications(); | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
|
|
||
| test('notifications are capped at 50', () => { | ||
| for (let i = 0; i < 55; i++) { | ||
| dashboard.addNotification({ type: 'info', title: `N${i}` }); | ||
| } | ||
|
|
||
| expect(dashboard.notifications).toHaveLength(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getProgressPercentage | ||
| // ============================================================ | ||
| describe('getProgressPercentage', () => { | ||
| test('returns 0 when no orchestrator', () => { | ||
| expect(dashboard.getProgressPercentage()).toBe(0); | ||
| }); | ||
|
|
||
| test('delegates to orchestrator', () => { | ||
| dashboard.orchestrator = createMockOrchestrator(); | ||
| expect(dashboard.getProgressPercentage()).toBe(50); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // getStatusPath / readStatus | ||
| // ============================================================ | ||
| describe('getStatusPath / readStatus', () => { | ||
| test('getStatusPath returns status file path', () => { | ||
| expect(dashboard.getStatusPath()).toBe(dashboard.statusPath); | ||
| }); | ||
|
|
||
| test('readStatus returns JSON when file exists', async () => { | ||
| fs.pathExists.mockResolvedValue(true); | ||
| fs.readJson.mockResolvedValue({ status: 'ok' }); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toEqual({ status: 'ok' }); | ||
| }); | ||
|
|
||
| test('readStatus returns null when file missing', async () => { | ||
| fs.pathExists.mockResolvedValue(false); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| test('readStatus emits error and returns null on failure', async () => { | ||
| fs.pathExists.mockRejectedValue(new Error('read error')); | ||
| const errorHandler = jest.fn(); | ||
| dashboard.on('error', errorHandler); | ||
|
|
||
| const result = await dashboard.readStatus(); | ||
|
|
||
| expect(result).toBeNull(); | ||
| expect(errorHandler).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| // ============================================================ | ||
| // clear | ||
| // ============================================================ | ||
| describe('clear', () => { | ||
| test('clears history and notifications', () => { | ||
| dashboard.addToHistory({ type: 'test' }); | ||
| dashboard.addNotification({ type: 'info', title: 'A' }); | ||
|
|
||
| dashboard.clear(); | ||
|
|
||
| expect(dashboard.history).toHaveLength(0); | ||
| expect(dashboard.notifications).toHaveLength(0); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The test cases use test() syntax, but the codebase convention is to use it() syntax. All other test files in the orchestration module (bob-orchestrator.test.js, data-lifecycle-manager.test.js, lock-manager.test.js, etc.) use it() for test cases. Please update all test() calls to it() for consistency with the codebase conventions.
| jest.spyOn(console, 'warn').mockImplementation(); | ||
|
|
||
| await dashboard.updateStatus(); | ||
|
|
||
| expect(console.warn).toHaveBeenCalledWith( | ||
| expect.stringContaining('statusUpdate') | ||
| ); |
There was a problem hiding this comment.
The console.warn spy is not restored after the test. This could cause the mock to leak to subsequent tests. Store the spy in a variable and call mockRestore() after the assertion, similar to the pattern used in data-lifecycle-manager.test.js line 342. Alternatively, you can add a general afterEach hook to restore all mocks.
| jest.spyOn(console, 'warn').mockImplementation(); | |
| await dashboard.updateStatus(); | |
| expect(console.warn).toHaveBeenCalledWith( | |
| expect.stringContaining('statusUpdate') | |
| ); | |
| const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); | |
| await dashboard.updateStatus(); | |
| expect(console.warn).toHaveBeenCalledWith( | |
| expect.stringContaining('statusUpdate') | |
| ); | |
| warnSpy.mockRestore(); |
|
Consolidated into #426 |
Summary
dashboard-integrationorchestration moduleTest Coverage
Test plan
Closes #304