Skip to content

Add unit tests for dashboard-integration module#258

Closed
nikolasdehor wants to merge 1 commit intoSynkraAI:mainfrom
nikolasdehor:test/dashboard-integration-coverage
Closed

Add unit tests for dashboard-integration module#258
nikolasdehor wants to merge 1 commit intoSynkraAI:mainfrom
nikolasdehor:test/dashboard-integration-coverage

Conversation

@nikolasdehor
Copy link
Contributor

@nikolasdehor nikolasdehor commented Feb 18, 2026

Summary

  • Add 43 unit tests for the dashboard-integration orchestration module
  • Cover DashboardIntegration class: lifecycle, status building, history management, notification system
  • Properly mock fs-extra and core/events dependencies

Test Coverage

Area Tests Key Scenarios
NotificationType 1 All types
constructor 5 Paths, state, defaults
start/stop 5 Lifecycle, events, idempotency
updateStatus 5 Write, events, error handling
buildStatus 6 Full status object structure
history 4 CRUD, filtering, cap
notifications 9 CRUD, read/unread, cap
readStatus 4 File read, missing, errors
Total 43

Test plan

  • All 43 tests pass
  • fs-extra and events properly mocked
  • Fake timers used for interval testing

Closes #304

Copilot AI review requested due to automatic review settings February 18, 2026 19:49
@vercel
Copy link

vercel bot commented Feb 18, 2026

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

@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Warning

Rate limit exceeded

@nikolasdehor has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 23 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +474
/**
* 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(),
};
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

expect(result).toBeNull();
expect(errorHandler).toHaveBeenCalled();
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
});
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');
});

Copilot uses AI. Check for mistakes.
await dashboard.start();
await dashboard.start();

// ensureDir should only be called once (from first start)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// ensureDir should only be called once (from first start)
// ensureDir should only be called from first start (2 directories)

Copilot uses AI. Check for mistakes.
dashboard.stop();

expect(handler).toHaveBeenCalled();
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
});
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();
});

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +474
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(),
};
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +445
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);
});
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +187
jest.spyOn(console, 'warn').mockImplementation();

await dashboard.updateStatus();

expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('statusUpdate')
);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
@nikolasdehor
Copy link
Contributor Author

Consolidated into #426

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add unit tests for dashboard-integration module

2 participants