mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Improve auto-loop event emission and add ntfy notifications (#821)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
const FAILURE_WINDOW_MS = 60000;
|
||||
|
||||
// Sleep intervals for the auto-loop (in milliseconds)
|
||||
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
|
||||
const SLEEP_INTERVAL_IDLE_MS = 10000;
|
||||
const SLEEP_INTERVAL_NORMAL_MS = 2000;
|
||||
const SLEEP_INTERVAL_ERROR_MS = 5000;
|
||||
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
|
||||
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
if (pendingFeatures.length === 0) {
|
||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
// Double-check that we have no features in 'in_progress' state that might
|
||||
// have been released from the concurrency manager but not yet updated to
|
||||
// their final status. This prevents auto_mode_idle from firing prematurely
|
||||
// when features are transitioning states (e.g., during status update).
|
||||
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
branchName
|
||||
);
|
||||
|
||||
// Only emit auto_mode_idle if we're truly done with all features
|
||||
if (!hasInProgressFeatures) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
}
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
|
||||
} catch {
|
||||
if (projectState.abortController.signal.aborted) break;
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
projectState.isRunning = false;
|
||||
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature belongs to the current worktree based on branch name.
|
||||
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
|
||||
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
|
||||
*/
|
||||
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
|
||||
const isMainWorktree = branchName === null || branchName === 'main';
|
||||
if (isMainWorktree) {
|
||||
// Main worktree: include features with no branchName or branchName === 'main'
|
||||
return !feature.branchName || feature.branchName === 'main';
|
||||
} else {
|
||||
// Feature worktree: only include exact branch match
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are features in 'in_progress' status for the current worktree.
|
||||
* This prevents auto_mode_idle from firing prematurely when features are
|
||||
* transitioning states (e.g., during status update from in_progress to completed).
|
||||
*/
|
||||
private async hasInProgressFeaturesForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<boolean> {
|
||||
if (!this.loadAllFeaturesFn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFeatures = await this.loadAllFeaturesFn(projectPath);
|
||||
return allFeatures.some(
|
||||
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
|
||||
);
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
logger.warn(
|
||||
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
EventHookTrigger,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
NtfyEndpointConfig,
|
||||
EventHookContext,
|
||||
} from '@automaker/types';
|
||||
import { ntfyService, type NtfyContext } from './ntfy-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('EventHooks');
|
||||
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Context available for variable substitution in hooks
|
||||
*/
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: EventHookTrigger;
|
||||
}
|
||||
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
|
||||
type HookContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
@@ -451,6 +444,8 @@ export class EventHookService {
|
||||
await this.executeShellHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'http') {
|
||||
await this.executeHttpHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'ntfy') {
|
||||
await this.executeNtfyHook(hook.action, context, hookName);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Hook "${hookName}" failed:`, error);
|
||||
@@ -558,6 +553,89 @@ export class EventHookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ntfy.sh notification hook
|
||||
*/
|
||||
private async executeNtfyHook(
|
||||
action: EventHookNtfyAction,
|
||||
context: HookContext,
|
||||
hookName: string
|
||||
): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available for ntfy hook');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the endpoint configuration
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const endpoints = settings.ntfyEndpoints || [];
|
||||
const endpoint = endpoints.find((e) => e.id === action.endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert HookContext to NtfyContext
|
||||
const ntfyContext: NtfyContext = {
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
timestamp: context.timestamp,
|
||||
eventType: context.eventType,
|
||||
};
|
||||
|
||||
// Build click URL with deep-link if project context is available
|
||||
let clickUrl = action.clickUrl;
|
||||
if (!clickUrl && endpoint.defaultClickUrl) {
|
||||
clickUrl = endpoint.defaultClickUrl;
|
||||
// If we have a project path and the click URL looks like the server URL,
|
||||
// append deep-link path
|
||||
if (context.projectPath && clickUrl) {
|
||||
try {
|
||||
const url = new URL(clickUrl);
|
||||
// Add featureId as query param for deep linking to board with feature output modal
|
||||
if (context.featureId) {
|
||||
url.pathname = '/board';
|
||||
url.searchParams.set('featureId', context.featureId);
|
||||
} else if (context.projectPath) {
|
||||
url.pathname = '/board';
|
||||
}
|
||||
clickUrl = url.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, log warning and use as-is
|
||||
logger.warn(
|
||||
`Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
|
||||
|
||||
const result = await ntfyService.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: action.title,
|
||||
body: action.body,
|
||||
tags: action.tags,
|
||||
emoji: action.emoji,
|
||||
clickUrl,
|
||||
priority: action.priority,
|
||||
},
|
||||
ntfyContext
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
|
||||
} else {
|
||||
logger.info(`Ntfy hook "${hookName}" completed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
|
||||
@@ -33,6 +33,36 @@ import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
// Notification type constants
|
||||
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
|
||||
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
|
||||
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
|
||||
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
|
||||
|
||||
// Notification title constants
|
||||
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
|
||||
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
|
||||
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
|
||||
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
* This is the payload that comes with 'auto-mode:event' events
|
||||
*/
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
message?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
projectPath?: string;
|
||||
/** Status field present when type === 'feature_status_changed' */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||
*
|
||||
@@ -45,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
|
||||
export class FeatureStateManager {
|
||||
private events: EventEmitter;
|
||||
private featureLoader: FeatureLoader;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||
this.events = events;
|
||||
this.featureLoader = featureLoader;
|
||||
|
||||
// Subscribe to error events to create notifications
|
||||
this.unsubscribe = events.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEventError(payload as AutoModeEventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup subscriptions
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,77 +154,18 @@ export class FeatureStateManager {
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
// Handle justFinishedAt timestamp based on status
|
||||
const shouldSetJustFinishedAt = status === 'waiting_approval';
|
||||
const shouldClearJustFinishedAt = status !== 'waiting_approval';
|
||||
if (shouldSetJustFinishedAt) {
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
} else if (shouldClearJustFinishedAt) {
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
|
||||
// Finalize task statuses when feature is done:
|
||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||
// - Do NOT mark pending tasks as completed (they were never started)
|
||||
// - Clear currentTaskId since no task is actively running
|
||||
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
} else if (status === 'verified') {
|
||||
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||
// Do NOT mark pending tasks as completed - they were never started
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
} else {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
|
||||
if (status === 'waiting_approval' || status === 'verified') {
|
||||
this.finalizeInProgressTasks(feature, featureId, status);
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
@@ -193,19 +182,21 @@ export class FeatureStateManager {
|
||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
const displayName = this.getFeatureDisplayName(feature, featureId);
|
||||
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
type: NOTIFICATION_TYPE_VERIFIED,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_VERIFIED,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
@@ -736,6 +727,137 @@ export class FeatureStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a feature, preferring title over feature ID.
|
||||
* Empty string titles are treated as missing and fallback to featureId.
|
||||
*
|
||||
* @param feature - The feature to get the display name for
|
||||
* @param featureId - The feature ID to use as fallback
|
||||
* @returns The display name (title or feature ID)
|
||||
*/
|
||||
private getFeatureDisplayName(feature: Feature, featureId: string): string {
|
||||
// Use title if it's a non-empty string, otherwise fallback to featureId
|
||||
return feature.title && feature.title.trim() ? feature.title : featureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-mode events to create error notifications.
|
||||
* This listens for error events and creates notifications to alert users.
|
||||
*/
|
||||
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
|
||||
if (!payload.type) return;
|
||||
|
||||
// Only handle error events
|
||||
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// For auto_mode_feature_complete, only notify on failures (passes === false)
|
||||
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project path - handle different event formats
|
||||
const projectPath = payload.projectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Determine notification type and title based on event type
|
||||
// Only auto_mode_feature_complete events should create feature_error notifications
|
||||
const isFeatureError = payload.type === 'auto_mode_feature_complete';
|
||||
const notificationType = isFeatureError
|
||||
? NOTIFICATION_TYPE_FEATURE_ERROR
|
||||
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
|
||||
const notificationTitle = isFeatureError
|
||||
? NOTIFICATION_TITLE_FEATURE_ERROR
|
||||
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
|
||||
|
||||
// Build error message
|
||||
let errorMessage = payload.message || 'An error occurred';
|
||||
if (payload.error) {
|
||||
errorMessage = payload.error;
|
||||
}
|
||||
|
||||
// Use feature title as notification title when available, fall back to gesture name
|
||||
let title = notificationTitle;
|
||||
if (payload.featureId) {
|
||||
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
|
||||
if (displayName) {
|
||||
title = displayName;
|
||||
errorMessage = `${notificationTitle}: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
await notificationService.createNotification({
|
||||
type: notificationType,
|
||||
title,
|
||||
message: errorMessage,
|
||||
featureId: payload.featureId,
|
||||
projectPath,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.warn(`Failed to create error notification:`, notificationError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature display name by loading the feature directly.
|
||||
*/
|
||||
private async getFeatureDisplayNameById(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) return null;
|
||||
return this.getFeatureDisplayName(feature, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks when a feature reaches a terminal state.
|
||||
* Marks in_progress tasks as completed but leaves pending tasks untouched.
|
||||
*
|
||||
* @param feature - The feature whose tasks should be finalized
|
||||
* @param featureId - The feature ID for logging
|
||||
* @param targetStatus - The status the feature is transitioning to
|
||||
*/
|
||||
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
|
||||
if (!feature.planSpec?.tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event via the event emitter
|
||||
*
|
||||
|
||||
282
apps/server/src/services/ntfy-service.ts
Normal file
282
apps/server/src/services/ntfy-service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Ntfy Service - Sends push notifications via ntfy.sh
|
||||
*
|
||||
* Provides integration with ntfy.sh for push notifications.
|
||||
* Supports custom servers, authentication, tags, emojis, and click actions.
|
||||
*
|
||||
* @see https://docs.ntfy.sh/publish/
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('Ntfy');
|
||||
|
||||
/** Default timeout for ntfy HTTP requests (10 seconds) */
|
||||
const DEFAULT_NTFY_TIMEOUT = 10000;
|
||||
|
||||
// Re-export EventHookContext as NtfyContext for backward compatibility
|
||||
export type NtfyContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Ntfy Service
|
||||
*
|
||||
* Handles sending notifications to ntfy.sh endpoints.
|
||||
*/
|
||||
export class NtfyService {
|
||||
/**
|
||||
* Send a notification to a ntfy.sh endpoint
|
||||
*
|
||||
* @param endpoint The ntfy.sh endpoint configuration
|
||||
* @param options Notification options (title, body, tags, etc.)
|
||||
* @param context Context for variable substitution
|
||||
*/
|
||||
async sendNotification(
|
||||
endpoint: NtfyEndpointConfig,
|
||||
options: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: string;
|
||||
emoji?: string;
|
||||
clickUrl?: string;
|
||||
priority?: 1 | 2 | 3 | 4 | 5;
|
||||
},
|
||||
context: NtfyContext
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!endpoint.enabled) {
|
||||
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
|
||||
return { success: false, error: 'Endpoint is disabled' };
|
||||
}
|
||||
|
||||
// Validate endpoint configuration
|
||||
const validationError = this.validateEndpoint(endpoint);
|
||||
if (validationError) {
|
||||
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
// Build URL
|
||||
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
// Title (with variable substitution)
|
||||
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
|
||||
if (title) {
|
||||
headers['Title'] = title;
|
||||
}
|
||||
|
||||
// Priority
|
||||
const priority = options.priority || 3;
|
||||
headers['Priority'] = String(priority);
|
||||
|
||||
// Tags and emoji
|
||||
const tags = this.buildTags(
|
||||
options.tags || endpoint.defaultTags,
|
||||
options.emoji || endpoint.defaultEmoji
|
||||
);
|
||||
if (tags) {
|
||||
headers['Tags'] = tags;
|
||||
}
|
||||
|
||||
// Click action URL
|
||||
const clickUrl = this.substituteVariables(
|
||||
options.clickUrl || endpoint.defaultClickUrl || '',
|
||||
context
|
||||
);
|
||||
if (clickUrl) {
|
||||
headers['Click'] = clickUrl;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
this.addAuthHeaders(headers, endpoint);
|
||||
|
||||
// Message body (with variable substitution)
|
||||
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
|
||||
|
||||
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
|
||||
return { success: false, error: 'Request timed out' };
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ntfy notification failed: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ntfy endpoint configuration
|
||||
*/
|
||||
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
|
||||
// Validate server URL
|
||||
if (!endpoint.serverUrl) {
|
||||
return 'Server URL is required';
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(endpoint.serverUrl);
|
||||
} catch {
|
||||
return 'Invalid server URL format';
|
||||
}
|
||||
|
||||
// Validate topic
|
||||
if (!endpoint.topic) {
|
||||
return 'Topic is required';
|
||||
}
|
||||
|
||||
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
|
||||
return 'Topic cannot contain spaces';
|
||||
}
|
||||
|
||||
// Validate authentication
|
||||
if (endpoint.authType === 'basic') {
|
||||
if (!endpoint.username || !endpoint.password) {
|
||||
return 'Username and password are required for basic authentication';
|
||||
}
|
||||
} else if (endpoint.authType === 'token') {
|
||||
if (!endpoint.token) {
|
||||
return 'Access token is required for token authentication';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags string from tags and emoji
|
||||
*/
|
||||
private buildTags(tags?: string, emoji?: string): string {
|
||||
const tagList: string[] = [];
|
||||
|
||||
if (tags) {
|
||||
// Split by comma and trim whitespace
|
||||
const parsedTags = tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
tagList.push(...parsedTags);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
// Add emoji as first tag if it looks like a shortcode
|
||||
if (emoji.startsWith(':') && emoji.endsWith(':')) {
|
||||
tagList.unshift(emoji.slice(1, -1));
|
||||
} else if (!emoji.includes(' ')) {
|
||||
// If it's a single emoji or shortcode without colons, add as-is
|
||||
tagList.unshift(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return tagList.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication headers based on auth type
|
||||
*/
|
||||
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
|
||||
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
|
||||
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
|
||||
'base64'
|
||||
);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (endpoint.authType === 'token' && endpoint.token) {
|
||||
headers['Authorization'] = `Bearer ${endpoint.token}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default title based on event context
|
||||
*/
|
||||
private getDefaultTitle(context: NtfyContext): string {
|
||||
const eventName = this.formatEventName(context.eventType);
|
||||
if (context.featureName) {
|
||||
return `${eventName}: ${context.featureName}`;
|
||||
}
|
||||
return eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default body based on event context
|
||||
*/
|
||||
private getDefaultBody(context: NtfyContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.featureName) {
|
||||
lines.push(`Feature: ${context.featureName}`);
|
||||
}
|
||||
if (context.featureId) {
|
||||
lines.push(`ID: ${context.featureId}`);
|
||||
}
|
||||
if (context.projectName) {
|
||||
lines.push(`Project: ${context.projectName}`);
|
||||
}
|
||||
if (context.error) {
|
||||
lines.push(`Error: ${context.error}`);
|
||||
}
|
||||
lines.push(`Time: ${context.timestamp}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event type to human-readable name
|
||||
*/
|
||||
private formatEventName(eventType: string): string {
|
||||
const eventNames: Record<string, string> = {
|
||||
feature_created: 'Feature Created',
|
||||
feature_success: 'Feature Completed',
|
||||
feature_error: 'Feature Failed',
|
||||
auto_mode_complete: 'Auto Mode Complete',
|
||||
auto_mode_error: 'Auto Mode Error',
|
||||
};
|
||||
return eventNames[eventType] || eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
private substituteVariables(template: string, context: NtfyContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof NtfyContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const ntfyService = new NtfyService();
|
||||
@@ -618,6 +618,36 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('eventHooks');
|
||||
}
|
||||
|
||||
// Guard ntfyEndpoints against accidental wipe
|
||||
// (similar to eventHooks, these are user-configured and shouldn't be lost)
|
||||
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
|
||||
const allowEmptyNtfyEndpoints =
|
||||
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
|
||||
// Remove the flag so it doesn't get persisted
|
||||
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
|
||||
|
||||
if (!allowEmptyNtfyEndpoints) {
|
||||
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
|
||||
? current.ntfyEndpoints.length
|
||||
: 0;
|
||||
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
|
||||
? sanitizedUpdates.ntfyEndpoints.length
|
||||
: currentNtfyLen;
|
||||
|
||||
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
|
||||
logger.warn(
|
||||
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
|
||||
{
|
||||
currentNtfyLen,
|
||||
newNtfyLen,
|
||||
}
|
||||
);
|
||||
delete sanitizedUpdates.ntfyEndpoints;
|
||||
}
|
||||
} else {
|
||||
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
|
||||
}
|
||||
|
||||
// Empty object overwrite guard
|
||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
@@ -1023,6 +1053,8 @@ export class SettingsService {
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
|
||||
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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\?.+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user