Improve auto-loop event emission and add ntfy notifications (#821)

This commit is contained in:
gsxdsm
2026-03-01 00:12:22 -08:00
committed by GitHub
parent 63b0a4fb38
commit 57bcb2802d
53 changed files with 4620 additions and 255 deletions

View File

@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Implement login feature',
description: 'Add user authentication with OAuth',
},
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
projectPath: '/home/user/other-project',
projectName: 'other-project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Fix navigation bug',
description: undefined,
},
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: undefined,
provider: undefined,
title: undefined,
description: undefined,
},
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
projectPath: `/project-${i}`,
projectName: `project-${i}`,
isAutoMode: i % 2 === 0,
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
provider: 'claude',
title: `Feature ${i}`,
description: `Description ${i}`,
}));
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-alpha',
projectName: 'project-alpha',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Feature A',
description: 'In project alpha',
},
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
projectPath: '/workspace/project-beta',
projectName: 'project-beta',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Feature B',
description: 'In project beta',
},
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
});
it('should include model and provider information for running agents', async () => {
// Arrange
const runningAgents = [
{
featureId: 'feature-claude',
projectPath: '/project',
projectName: 'project',
isAutoMode: true,
model: 'claude-sonnet-4-20250514',
provider: 'claude',
title: 'Claude Feature',
description: 'Using Claude model',
},
{
featureId: 'feature-codex',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'codex-gpt-5.1',
provider: 'codex',
title: 'Codex Feature',
description: 'Using Codex model',
},
{
featureId: 'feature-cursor',
projectPath: '/project',
projectName: 'project',
isAutoMode: false,
model: 'cursor-auto',
provider: 'cursor',
title: 'Cursor Feature',
description: 'Using Cursor model',
},
];
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
// Act
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
await handler(req, res);
// Assert
const response = vi.mocked(res.json).mock.calls[0][0];
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
expect(response.runningAgents[0].provider).toBe('claude');
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
expect(response.runningAgents[1].provider).toBe('codex');
expect(response.runningAgents[2].model).toBe('cursor-auto');
expect(response.runningAgents[2].provider).toBe('cursor');
});
});
});

View File

@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
);
});
});
describe('auto_mode_idle emission timing (idle check fix)', () => {
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration and idle event
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
// No pending features (backlog/ready)
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// But there are features in in_progress status
const inProgressFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'In Progress Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
// No running features in concurrency manager (they were released during status update)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('emits auto_mode_idle after in_progress feature completes', async () => {
const completedFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'completed',
title: 'Completed Feature',
};
// Initially has in_progress feature
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle because all features are completed
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in main worktree has no branchName
const mainWorktreeFeature: Feature = {
...testFeature,
id: 'feature-main',
status: 'in_progress',
title: 'Main Worktree Feature',
branchName: undefined, // Main worktree feature
};
// Feature in branch worktree has branchName
const branchFeature: Feature = {
...testFeature,
id: 'feature-branch',
status: 'in_progress',
title: 'Branch Feature',
branchName: 'feature/some-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for main worktree
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: null,
})
);
});
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Feature in matching branch
const matchingBranchFeature: Feature = {
...testFeature,
id: 'feature-matching',
status: 'in_progress',
title: 'Matching Branch Feature',
branchName: 'feature/test-branch',
};
// Feature in different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
matchingBranchFeature,
differentBranchFeature,
]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
'auto_mode_idle',
expect.objectContaining({
projectPath: '/test/project',
branchName: 'feature/test-branch',
})
);
});
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
// Only feature is in a different branch
const differentBranchFeature: Feature = {
...testFeature,
id: 'feature-different',
status: 'in_progress',
title: 'Different Branch Feature',
branchName: 'feature/other-branch',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
// Start auto mode for feature/test-branch
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
// Should emit auto_mode_idle because the in_progress feature is in a different branch
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: 'feature/test-branch',
});
});
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
const backlogFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'backlog',
title: 'Backlog Feature',
};
const readyFeature: Feature = {
...testFeature,
id: 'feature-2',
status: 'ready',
title: 'Ready Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT emit auto_mode_idle because there are backlog/ready features
// (even though they're not in_progress, the idle check only looks at in_progress status)
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
// so this should emit idle since runningCount=0 and no in_progress features
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
// Create coordinator without loadAllFeaturesFn
const coordWithoutLoadAll = new AutoLoopCoordinator(
mockEventBus,
mockConcurrencyManager,
mockSettingsService,
mockExecuteFeature,
mockLoadPendingFeatures,
mockSaveExecutionState,
mockClearExecutionState,
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
// loadAllFeaturesFn omitted
);
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time multiple times to trigger multiple loop iterations
await vi.advanceTimersByTimeAsync(11000); // First idle check
await vi.advanceTimersByTimeAsync(11000); // Second idle check
await vi.advanceTimersByTimeAsync(11000); // Third idle check
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should only emit auto_mode_idle once despite multiple iterations
const idleCalls = vi
.mocked(mockEventBus.emitAutoModeEvent)
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
expect(idleCalls.length).toBe(1);
});
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
// This test reproduces the exact bug scenario described in the feature:
// When a feature completes, there's a brief window where:
// 1. The feature has been released from runningFeatures (so runningCount = 0)
// 2. The feature's status is still 'in_progress' during the status update transition
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
// The fix ensures auto_mode_idle is NOT emitted in this window
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
// Feature is still in in_progress status (during status update transition)
const transitioningFeature: Feature = {
...testFeature,
id: 'feature-1',
status: 'in_progress',
title: 'Transitioning Feature',
};
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
// Feature has been released from concurrency manager (runningCount = 0)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
// Clear the initial event mock calls
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
// Advance time to trigger loop iteration
await vi.advanceTimersByTimeAsync(11000);
// Stop the loop
await coordinator.stopAutoLoopForProject('/test/project', null);
// The fix prevents auto_mode_idle from being emitted in this scenario
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
message: 'No pending features - auto mode idle',
projectPath: '/test/project',
branchName: null,
});
});
});
});

View File

@@ -58,7 +58,7 @@ describe('AutoModeServiceFacade Agent Runner', () => {
// Helper to access the private createRunAgentFn via factory creation
facade = AutoModeServiceFacade.create('/project', {
events: { on: vi.fn(), emit: vi.fn() } as any,
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
settingsService: mockSettingsService,
sharedServices: {
eventBus: { emitAutoModeEvent: vi.fn() } as any,

View File

@@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js'
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
// Mock global fetch for ntfy tests
const originalFetch = global.fetch;
/**
* Create a mock EventEmitter for testing
*/
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
/**
* Create a mock SettingsService
*/
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
function createMockSettingsService(
hooks: unknown[] = [],
ntfyEndpoints: unknown[] = []
): SettingsService {
return {
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
getGlobalSettings: vi.fn().mockResolvedValue({
eventHooks: hooks,
ntfyEndpoints: ntfyEndpoints,
}),
} as unknown as SettingsService;
}
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new EventHookService();
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
mockSettingsService = createMockSettingsService();
mockEventHistoryService = createMockEventHistoryService();
mockFeatureLoader = createMockFeatureLoader();
// Set up mock fetch for ntfy tests
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
service.destroy();
global.fetch = originalFetch;
});
describe('initialize', () => {
@@ -832,4 +846,628 @@ describe('EventHookService', () => {
expect(storeCall.error).toBe('Feature stopped by user');
});
});
describe('ntfy hook execution', () => {
const mockNtfyEndpoint = {
id: 'endpoint-1',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none' as const,
enabled: true,
};
it('should execute ntfy hook when endpoint is configured', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Success Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: 'Feature {{featureName}} completed!',
priority: 3,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Title']).toBe('Feature Test Feature completed!');
});
it('should NOT execute ntfy hook when endpoint is not found', async () => {
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Missing Endpoint',
action: {
type: 'ntfy',
endpointId: 'non-existent-endpoint',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should NOT have been called since endpoint doesn't exist
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use ntfy endpoint default values when hook does not override', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'tada',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_error',
name: 'Ntfy Error Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
// No title, tags, or emoji - should use endpoint defaults
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Failed Feature',
passes: false,
message: 'Something went wrong',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Should use default tags and emoji from endpoint
expect(options.headers['Tags']).toBe('tada,default-tag');
// Click URL gets deep-link query param when feature context is available
expect(options.headers['Click']).toContain('https://default.example.com/board');
expect(options.headers['Click']).toContain('featureId=feat-1');
});
it('should send ntfy notification with authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithAuth = {
...mockNtfyEndpoint,
authType: 'token' as const,
token: 'tk_test_token',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Authenticated Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token');
});
it('should handle ntfy notification failure gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook That Will Fail',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
// Should not throw - error should be caught gracefully
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
// Event should still be stored even if ntfy hook fails
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
it('should substitute variables in ntfy title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Variables',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-123',
featureName: 'Cool Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/my-project',
projectName: 'my-project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[my-project] Cool Feature');
expect(options.body).toContain('feat-123');
});
it('should NOT execute ntfy hook when endpoint is disabled', async () => {
const disabledEndpoint = {
...mockNtfyEndpoint,
enabled: false,
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Disabled Endpoint',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
});
// Fetch should not be called because endpoint is disabled
expect(mockFetch).not.toHaveBeenCalled();
});
it('should use hook-specific values over endpoint defaults', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaults = {
...mockNtfyEndpoint,
defaultTags: 'default-tag',
defaultEmoji: 'default-emoji',
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Overrides',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
tags: 'override-tag',
emoji: 'override-emoji',
clickUrl: 'https://override.example.com',
priority: 5,
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-1',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
// Hook values should override endpoint defaults
expect(options.headers['Tags']).toBe('override-emoji,override-tag');
expect(options.headers['Click']).toBe('https://override.example.com');
expect(options.headers['Priority']).toBe('5');
});
describe('click URL deep linking', () => {
it('should generate board URL with featureId query param when feature context is available', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'test-feature-123',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use /board path with featureId query param
expect(clickUrl).toContain('/board');
expect(clickUrl).toContain('featureId=test-feature-123');
// Should NOT use the old path-based format
expect(clickUrl).not.toContain('/feature/');
});
it('should generate board URL without featureId when no feature context', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'auto_mode_complete',
name: 'Auto Mode Complete Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
// Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete)
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_idle',
executionMode: 'auto',
projectPath: '/test/project',
totalFeatures: 5,
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should navigate to board without featureId
expect(clickUrl).toContain('/board');
expect(clickUrl).not.toContain('featureId=');
});
it('should use hook-specific click URL overriding default with featureId', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://default.example.com',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook with Custom Click URL',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
clickUrl: 'https://custom.example.com/custom-page',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-789',
featureName: 'Custom URL Test',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should use the hook-specific click URL (not modified with featureId since it's a custom URL)
expect(clickUrl).toBe('https://custom.example.com/custom-page');
});
it('should preserve existing query params when adding featureId', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpointWithDefaultClickUrl = {
...mockNtfyEndpoint,
defaultClickUrl: 'https://app.example.com/board?view=list',
};
const hooks = [
{
id: 'ntfy-hook-1',
enabled: true,
trigger: 'feature_success',
name: 'Ntfy Hook',
action: {
type: 'ntfy',
endpointId: 'endpoint-1',
},
},
];
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
service.initialize(
mockEmitter,
mockSettingsService,
mockEventHistoryService,
mockFeatureLoader
);
mockEmitter.simulateEvent('auto-mode:event', {
type: 'auto_mode_feature_complete',
executionMode: 'auto',
featureId: 'feat-456',
featureName: 'Test Feature',
passes: true,
message: 'Feature completed',
projectPath: '/test/project',
});
await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const options = mockFetch.mock.calls[0][1];
const clickUrl = options.headers['Click'];
// Should preserve existing query params and add featureId
expect(clickUrl).toContain('view=list');
expect(clickUrl).toContain('featureId=feat-456');
// Should be properly formatted URL
expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/);
});
});
});
});

View File

@@ -279,6 +279,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for waiting_approval status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'My Awesome Feature Title',
message: 'Feature Ready for Review',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_waiting_approval',
title: 'feature-123',
message: 'Feature Ready for Review',
})
);
});
it('should create notification for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
@@ -298,6 +373,81 @@ describe('FeatureStateManager', () => {
);
});
it('should use feature.title as notification title for verified status', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithTitle: Feature = {
...mockFeature,
title: 'My Awesome Feature Title',
name: 'old-name-property', // name property exists but should not be used
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'My Awesome Feature Title',
message: 'Feature Verified',
})
);
});
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithoutTitle: Feature = {
...mockFeature,
title: undefined,
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithoutTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
const featureWithEmptyTitle: Feature = {
...mockFeature,
title: '',
name: 'old-name-property',
};
(readJsonWithRecovery as Mock).mockResolvedValue({
data: featureWithEmptyTitle,
recovered: false,
source: 'main',
});
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_verified',
title: 'feature-123',
message: 'Feature Verified',
})
);
});
it('should sync to app_spec for completed status', async () => {
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature },
@@ -1211,4 +1361,179 @@ describe('FeatureStateManager', () => {
expect(callOrder).toEqual(['persist', 'emit']);
});
});
describe('handleAutoModeEventError', () => {
let subscribeCallback: (type: string, payload: unknown) => void;
beforeEach(() => {
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
}
});
it('should ignore events with no type', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should ignore non-error events', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Something went wrong',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
title: 'Auto Mode Error',
message: 'Something went wrong',
projectPath: '/project',
})
);
});
it('should use error field instead of message when available', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Some message',
error: 'The actual error',
projectPath: '/project',
});
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'auto_mode_error',
message: 'The actual error',
})
);
});
it('should use feature title as notification title for feature error with featureId', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...mockFeature, title: 'Login Page Feature' },
recovered: false,
source: 'main',
});
subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: false,
featureId: 'feature-123',
error: 'Build failed',
projectPath: '/project',
});
// Wait for async handleAutoModeEventError to complete
await vi.waitFor(() => {
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'feature_error',
title: 'Login Page Feature',
message: 'Feature Failed: Build failed',
featureId: 'feature-123',
})
);
});
});
it('should ignore auto_mode_feature_complete without passes=false', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_feature_complete',
passes: true,
projectPath: '/project',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle missing projectPath gracefully', async () => {
const mockNotificationService = { createNotification: vi.fn() };
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
await subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error occurred',
});
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
});
it('should handle notification service failures gracefully', async () => {
(getNotificationService as Mock).mockImplementation(() => {
throw new Error('Service unavailable');
});
// Should not throw - the callback returns void so we just call it and wait for async work
subscribeCallback('auto-mode:event', {
type: 'auto_mode_error',
message: 'Error',
projectPath: '/project',
});
// Give async handleAutoModeEventError time to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
describe('destroy', () => {
it('should unsubscribe from event subscription', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
// Create a new manager to get a fresh subscription
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy
newManager.destroy();
// Verify unsubscribe was called
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle destroy being called multiple times', () => {
const unsubscribeFn = vi.fn();
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
// Call destroy multiple times
newManager.destroy();
newManager.destroy();
// Should only unsubscribe once
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,642 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NtfyService } from '../../../src/services/ntfy-service.js';
import type { NtfyEndpointConfig } from '@automaker/types';
// Mock global fetch
const originalFetch = global.fetch;
describe('NtfyService', () => {
let service: NtfyService;
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
service = new NtfyService();
mockFetch = vi.fn();
global.fetch = mockFetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
/**
* Create a valid endpoint config for testing
*/
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: 'test-endpoint-id',
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
/**
* Create a basic context for testing
*/
function createContext() {
return {
featureId: 'feat-123',
featureName: 'Test Feature',
projectPath: '/test/project',
projectName: 'test-project',
timestamp: '2024-01-15T10:30:00.000Z',
eventType: 'feature_success',
};
}
describe('validateEndpoint', () => {
it('should return null for valid endpoint with no auth', () => {
const endpoint = createEndpoint();
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with basic auth', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return null for valid endpoint with token auth', () => {
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_123456',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBeNull();
});
it('should return error when serverUrl is missing', () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Server URL is required');
});
it('should return error when serverUrl is invalid', () => {
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Invalid server URL format');
});
it('should return error when topic is missing', () => {
const endpoint = createEndpoint({ topic: '' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic is required');
});
it('should return error when topic contains spaces', () => {
const endpoint = createEndpoint({ topic: 'invalid topic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when topic contains tabs', () => {
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Topic cannot contain spaces');
});
it('should return error when basic auth is missing username', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: '',
password: 'pass',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when basic auth is missing password', () => {
const endpoint = createEndpoint({
authType: 'basic',
username: 'user',
password: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Username and password are required for basic authentication');
});
it('should return error when token auth is missing token', () => {
const endpoint = createEndpoint({
authType: 'token',
token: '',
});
const result = service.validateEndpoint(endpoint);
expect(result).toBe('Access token is required for token authentication');
});
});
describe('sendNotification', () => {
it('should return error when endpoint is disabled', async () => {
const endpoint = createEndpoint({ enabled: false });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Endpoint is disabled');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should return error when endpoint validation fails', async () => {
const endpoint = createEndpoint({ serverUrl: '' });
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Server URL is required');
expect(mockFetch).not.toHaveBeenCalled();
});
it('should send notification with default values', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('https://ntfy.sh/test-topic');
expect(options.method).toBe('POST');
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
expect(options.headers['Title']).toContain('Feature Completed');
expect(options.headers['Priority']).toBe('3');
});
it('should send notification with custom title and body', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
title: 'Custom Title',
body: 'Custom body message',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Custom Title');
expect(options.body).toBe('Custom body message');
});
it('should send notification with tags and emoji', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{
tags: 'warning,skull',
emoji: 'tada',
},
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada,warning,skull');
});
it('should send notification with priority', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Priority']).toBe('5');
});
it('should send notification with click URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint();
const result = await service.sendNotification(
endpoint,
{ clickUrl: 'https://example.com/feature/123' },
createContext()
);
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://example.com/feature/123');
});
it('should use endpoint default tags and emoji when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultTags: 'default-tag',
defaultEmoji: 'rocket',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('rocket,default-tag');
});
it('should use endpoint default click URL when not specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
defaultClickUrl: 'https://default.example.com',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Click']).toBe('https://default.example.com');
});
it('should send notification with basic authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'basic',
username: 'testuser',
password: 'testpass',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
// Basic auth should be base64 encoded
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
});
it('should send notification with token authentication', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({
authType: 'token',
token: 'tk_test_token_123',
});
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(true);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
});
it('should return error on HTTP error response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve('Forbidden - invalid token'),
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toContain('403');
expect(result.error).toContain('Forbidden');
});
it('should return error on timeout', async () => {
mockFetch.mockImplementationOnce(() => {
const error = new Error('Aborted');
error.name = 'AbortError';
throw error;
});
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Request timed out');
});
it('should return error on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const endpoint = createEndpoint();
const result = await service.sendNotification(endpoint, {}, createContext());
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
it('should handle server URL with trailing slash', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toBe('https://ntfy.sh/test-topic');
});
it('should URL encode the topic', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
const endpoint = createEndpoint({ topic: 'test/topic#special' });
await service.sendNotification(endpoint, {}, createContext());
const url = mockFetch.mock.calls[0][0];
expect(url).toContain('test%2Ftopic%23special');
});
});
describe('variable substitution', () => {
it('should substitute {{featureId}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Feature {{featureId}} completed' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature feat-123 completed');
});
it('should substitute {{featureName}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'The feature "{{featureName}}" is done!' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('The feature "Test Feature" is done!');
});
it('should substitute {{projectName}} in title', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: '[{{projectName}}] Event: {{eventType}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
});
it('should substitute {{timestamp}} in body', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ body: 'Completed at: {{timestamp}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
});
it('should substitute {{error}} in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Something went wrong',
};
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Error: Something went wrong');
});
it('should substitute multiple variables', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{
title: '[{{projectName}}] {{featureName}}',
body: 'Feature {{featureId}} completed at {{timestamp}}',
},
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('[test-project] Test Feature');
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
});
it('should replace unknown variables with empty string', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ title: 'Value: {{unknownVariable}}' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Value: ');
});
});
describe('default title generation', () => {
it('should generate title with feature name for feature_success', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
});
it('should generate title without feature name when missing', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Completed');
});
it('should generate correct title for feature_created', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_created' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
});
it('should generate correct title for feature_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'feature_error' };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
});
it('should generate correct title for auto_mode_complete', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'auto_mode_complete',
featureName: undefined,
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Complete');
});
it('should generate correct title for auto_mode_error', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Title']).toBe('Auto Mode Error');
});
});
describe('default body generation', () => {
it('should generate body with feature info', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, {}, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Feature: Test Feature');
expect(options.body).toContain('ID: feat-123');
expect(options.body).toContain('Project: test-project');
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
});
it('should include error in body for error events', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
const context = {
...createContext(),
eventType: 'feature_error',
error: 'Build failed',
};
await service.sendNotification(endpoint, {}, context);
const options = mockFetch.mock.calls[0][1];
expect(options.body).toContain('Error: Build failed');
});
});
describe('emoji and tags handling', () => {
it('should handle emoji shortcode with colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('tada');
});
it('should handle emoji without colons', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('warning');
});
it('should combine emoji and tags correctly', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'rotating_light', tags: 'urgent,alert' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
// Emoji comes first, then tags
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
});
it('should ignore emoji with spaces', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
const endpoint = createEndpoint();
await service.sendNotification(
endpoint,
{ emoji: 'multi word emoji', tags: 'test' },
createContext()
);
const options = mockFetch.mock.calls[0][1];
expect(options.headers['Tags']).toBe('test');
});
});
});

View File

@@ -14,12 +14,28 @@ import {
type Credentials,
type ProjectSettings,
} from '@/types/settings.js';
import type { NtfyEndpointConfig } from '@automaker/types';
describe('settings-service.ts', () => {
let testDataDir: string;
let testProjectDir: string;
let settingsService: SettingsService;
/**
* Helper to create a test ntfy endpoint with sensible defaults
*/
function createTestNtfyEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
return {
id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: 'Test Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
...overrides,
};
}
beforeEach(async () => {
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
@@ -171,6 +187,150 @@ describe('settings-service.ts', () => {
expect(updated.theme).toBe('solarized');
});
it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Ntfy',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// The empty array should be ignored - existing endpoints should be preserved
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow adding new ntfyEndpoints to existing list', async () => {
const endpoint1 = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'First Endpoint',
topic: 'first-topic',
});
const endpoint2 = createTestNtfyEndpoint({
id: 'endpoint-2',
name: 'Second Endpoint',
serverUrl: 'https://ntfy.example.com',
topic: 'second-topic',
authType: 'token',
token: 'test-token',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint1] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [endpoint1, endpoint2] as any,
});
// Both endpoints should be present
expect(updated.ntfyEndpoints?.length).toBe(2);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2');
});
it('should allow updating ntfyEndpoints with non-empty array', async () => {
const originalEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Original Name',
topic: 'original-topic',
});
const updatedEndpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'Updated Name',
topic: 'updated-topic',
enabled: false,
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [originalEndpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [updatedEndpoint] as any,
});
// The update should go through with the new values
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name');
expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic');
expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false);
});
it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => {
// Start with no endpoints (default state)
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2));
// Trying to set empty array should be fine when there are no existing endpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
} as any);
// Empty array should be set (no data loss because there was nothing to lose)
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should preserve ntfyEndpoints while updating other settings', async () => {
const endpoint = createTestNtfyEndpoint({
id: 'endpoint-1',
name: 'My Endpoint',
topic: 'my-topic',
});
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'dark',
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Update theme without sending ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
theme: 'light',
});
// Theme should be updated
expect(updated.theme).toBe('light');
// ntfyEndpoints should be preserved from existing settings
expect(updated.ntfyEndpoints?.length).toBe(1);
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should allow clearing ntfyEndpoints with escape hatch flag', async () => {
const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' });
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
ntfyEndpoints: [endpoint] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
// Use escape hatch to intentionally clear ntfyEndpoints
const updated = await settingsService.updateGlobalSettings({
ntfyEndpoints: [],
__allowEmptyNtfyEndpoints: true,
} as any);
// The empty array should be applied because escape hatch was used
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
});
it('should migrate ntfyEndpoints from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Ntfy Server',
serverUrl: 'https://ntfy.sh',
topic: 'my-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
expect(result.migratedGlobalSettings).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server');
expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic');
});
it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => {
const localStorageData = {
'automaker-storage': JSON.stringify({
state: {
eventHooks: [
{
id: 'hook-1',
name: 'Test Hook',
eventType: 'feature:started',
enabled: true,
actions: [],
},
],
ntfyEndpoints: [
{
id: 'endpoint-1',
name: 'My Endpoint',
serverUrl: 'https://ntfy.sh',
topic: 'test-topic',
authType: 'none',
enabled: true,
},
],
},
}),
};
const result = await settingsService.migrateFromLocalStorage(localStorageData);
expect(result.success).toBe(true);
const settings = await settingsService.getGlobalSettings();
expect(settings.eventHooks?.length).toBe(1);
expect(settings.ntfyEndpoints?.length).toBe(1);
expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1');
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
});
it('should handle direct localStorage values', async () => {
const localStorageData = {
'automaker:lastProjectDir': '/path/to/project',