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

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

2
.gitignore vendored
View File

@@ -71,7 +71,7 @@ test/agent-session-test-*/
test/feature-backlog-test-*/
test/running-task-display-test-*/
test/agent-output-modal-responsive-*/
test/fixtures/.worker-*/
test/fixtures/
test/board-bg-test-*/
test/edit-feature-test-*/
test/open-project-test-*/

View File

@@ -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",

View File

@@ -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;
}
}
}

View File

@@ -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
*/

View File

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

View 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();

View File

@@ -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[]) || [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.15.0",
"version": "1.0.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -9,6 +9,7 @@
},
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"desktopName": "automaker.desktop",
"private": true,
"engines": {
"node": ">=22.0.0 <23.0.0"

View File

@@ -3,7 +3,7 @@
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2 } from 'lucide-react';
import { Bell, Check, Trash2, AlertCircle } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
@@ -11,25 +11,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Notification } from '@automaker/types';
import { cn } from '@/lib/utils';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
import { cn, formatRelativeTime } from '@/lib/utils';
interface NotificationBellProps {
projectPath: string | null;
@@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({ to: '/board' });
navigate({ to: '/board', search: { featureId: notification.featureId } });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]
@@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
return <Check className="h-4 w-4 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-4 w-4 text-blue-500" />;
case 'feature_error':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'auto_mode_error':
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4" />;
}

View File

@@ -195,8 +195,10 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
await invalidateSessions();
// Select the new session immediately before invalidating the cache to avoid
// a race condition where the cache re-render resets the selected session.
onSelectSession(result.session.id);
await invalidateSessions();
}
};
@@ -210,8 +212,10 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
if (result.success && result.session?.id) {
await invalidateSessions();
// Select the new session immediately before invalidating the cache to avoid
// a race condition where the cache re-render resets the selected session.
onSelectSession(result.session.id);
await invalidateSessions();
}
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);

View File

@@ -114,7 +114,12 @@ const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWo
const logger = createLogger('Board');
export function BoardView() {
interface BoardViewProps {
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
initialFeatureId?: string;
}
export function BoardView({ initialFeatureId }: BoardViewProps) {
const {
currentProject,
defaultSkipTests,
@@ -300,6 +305,93 @@ export function BoardView() {
setFeaturesWithContext,
});
// Handle initial feature ID from URL - switch to the correct worktree and open output modal
// Uses a ref to track which featureId has been handled to prevent re-opening
// when the component re-renders but initialFeatureId hasn't changed.
// We read worktrees from the store reactively so this effect re-runs once worktrees load.
const handledFeatureIdRef = useRef<string | undefined>(undefined);
// Reset the handled ref whenever initialFeatureId changes (including to undefined),
// so navigating to the same featureId again after clearing works correctly.
useEffect(() => {
handledFeatureIdRef.current = undefined;
}, [initialFeatureId]);
const deepLinkWorktrees = useAppStore(
useCallback(
(s) =>
currentProject?.path
? (s.worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES,
[currentProject?.path]
)
);
useEffect(() => {
if (
!initialFeatureId ||
handledFeatureIdRef.current === initialFeatureId ||
isLoading ||
!hookFeatures.length ||
!currentProject?.path
) {
return;
}
const feature = hookFeatures.find((f) => f.id === initialFeatureId);
if (!feature) return;
// If the feature has a branch, wait for worktrees to load so we can switch
if (feature.branchName && deepLinkWorktrees.length === 0) {
return; // Worktrees not loaded yet - effect will re-run when they load
}
// Switch to the correct worktree based on the feature's branchName
if (feature.branchName && deepLinkWorktrees.length > 0) {
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName);
if (targetWorktree) {
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
const isAlreadySelected = targetWorktree.isMain
? currentWt?.path === null
: currentWt?.path === targetWorktree.path;
if (!isAlreadySelected) {
logger.info(
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
);
setCurrentWorktree(
currentProject.path,
targetWorktree.isMain ? null : targetWorktree.path,
targetWorktree.branch
);
}
}
} else if (!feature.branchName && deepLinkWorktrees.length > 0) {
// Feature has no branch - should be on the main worktree
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
if (currentWt?.path !== null && currentWt !== null) {
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain);
if (mainWorktree) {
logger.info(
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
);
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
}
}
}
logger.info(`Opening output modal for feature from URL: ${initialFeatureId}`);
setOutputFeature(feature);
setShowOutputModal(true);
handledFeatureIdRef.current = initialFeatureId;
}, [
initialFeatureId,
isLoading,
hookFeatures,
currentProject?.path,
deepLinkWorktrees,
setCurrentWorktree,
setOutputFeature,
setShowOutputModal,
]);
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
@@ -1988,7 +2080,10 @@ export function BoardView() {
{/* Agent Output Modal */}
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
onClose={() => {
setShowOutputModal(false);
handledFeatureIdRef.current = undefined;
}}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}

View File

@@ -85,7 +85,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Track real-time task summary updates from WebSocket events
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string>>(new Map());
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string | null>>(new Map());
// Track last WebSocket event timestamp to know if we're receiving real-time updates
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
@@ -200,7 +200,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
const effectiveTodos = useMemo(() => {
const effectiveTodos = useMemo((): {
content: string;
status: 'pending' | 'in_progress' | 'completed';
summary?: string | null;
}[] => {
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
@@ -250,7 +254,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
return {
content: task.description,
status: effectiveStatus,
summary: realtimeSummary ?? task.summary,
summary: taskSummaryMap.has(task.id) ? realtimeSummary : task.summary,
};
});
}

View File

@@ -240,6 +240,12 @@ export const ListView = memo(function ListView({
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
const effectiveSortConfig: SortConfig = useMemo(
() => (sortNewestCardOnTop ? { column: 'createdAt', direction: 'desc' } : sortConfig),
[sortNewestCardOnTop, sortConfig]
);
// Generate status groups from columnFeaturesMap
const statusGroups = useMemo<StatusGroup[]>(() => {
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
@@ -454,7 +460,7 @@ export const ListView = memo(function ListView({
>
{/* Table header */}
<ListHeader
sortConfig={sortConfig}
sortConfig={effectiveSortConfig}
onSortChange={onSortChange}
showCheckbox={isSelectionMode}
allSelected={selectionState.allSelected}

View File

@@ -20,7 +20,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
import {
GitPullRequest,
ExternalLink,
Sparkles,
RefreshCw,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -93,6 +100,7 @@ export function CreatePRDialog({
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// PR description model override
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
@@ -286,6 +294,7 @@ export function CreatePRDialog({
setSelectedRemote('');
setSelectedTargetRemote('');
setIsGeneratingDescription(false);
setIsDescriptionExpanded(false);
operationCompletedRef.current = false;
}, [defaultBaseBranch]);
@@ -642,13 +651,28 @@ export function CreatePRDialog({
</div>
<div className="grid gap-2">
<Label htmlFor="pr-body">Description</Label>
<div className="flex items-center justify-between">
<Label htmlFor="pr-body">Description</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
className="h-6 px-2 text-xs"
title={isDescriptionExpanded ? 'Collapse description' : 'Expand description'}
>
{isDescriptionExpanded ? (
<Minimize2 className="w-3 h-3" />
) : (
<Maximize2 className="w-3 h-3" />
)}
</Button>
</div>
<Textarea
id="pr-body"
placeholder="Describe the changes in this PR..."
value={body}
onChange={(e) => setBody(e.target.value)}
className="min-h-[80px]"
className={isDescriptionExpanded ? 'min-h-[300px]' : 'min-h-[80px]'}
/>
</div>

View File

@@ -32,6 +32,18 @@ import {
SelectValue,
} from '@/components/ui/select';
/**
* Qualify a branch name with a remote prefix when appropriate.
* Returns undefined when branch is empty, and avoids double-prefixing.
*/
function qualifyRemoteBranch(remote: string, branch?: string): string | undefined {
const trimmed = branch?.trim();
if (!trimmed) return undefined;
if (remote === 'local') return trimmed;
if (trimmed.startsWith(`${remote}/`)) return trimmed;
return `${remote}/${trimmed}`;
}
/**
* Parse git/worktree error messages and return user-friendly versions
*/
@@ -264,19 +276,21 @@ export function CreateWorktreeDialog({
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
}
// If a specific remote is selected, show only branches from that remote
// If a specific remote is selected, show only branches from that remote (without remote prefix)
const remoteBranchList = remoteBranches.get(selectedRemote);
if (remoteBranchList) {
return remoteBranchList.map((b) => b.fullRef);
return remoteBranchList.map((b) => b.name);
}
// Fallback: filter from available branches by remote prefix
// Fallback: filter from available branches by remote prefix, stripping the prefix for display
const prefix = `${selectedRemote}/`;
return availableBranches
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
.map((b) => b.name);
.filter((b) => b.isRemote && b.name.startsWith(prefix))
.map((b) => b.name.substring(prefix.length));
}, [availableBranches, selectedRemote, remoteBranches]);
// Determine if the selected base branch is a remote branch.
// When a remote is selected in the source dropdown, the branch is always remote.
// Also detect manually entered remote-style names (e.g. "origin/feature")
// so the UI shows the "Remote branch — will fetch latest" hint even when
// the branch isn't in the fetched availableBranches list.
@@ -285,6 +299,8 @@ export function CreateWorktreeDialog({
// If the branch list couldn't be fetched, availableBranches is a fallback
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
if (branchFetchError) return false;
// If a remote is explicitly selected, the branch is remote
if (selectedRemote !== 'local') return true;
// Check fetched branch list first
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
if (knownRemote) return true;
@@ -295,7 +311,7 @@ export function CreateWorktreeDialog({
return !isKnownLocal;
}
return false;
}, [baseBranch, availableBranches, branchFetchError]);
}, [baseBranch, availableBranches, branchFetchError, selectedRemote]);
const handleCreate = async () => {
if (!branchName.trim()) {
@@ -334,8 +350,10 @@ export function CreateWorktreeDialog({
return;
}
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
const effectiveBaseBranch = trimmedBaseBranch || undefined;
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD).
// When a remote is selected, prepend the remote name to form the full ref
// (e.g. "main" with remote "origin" becomes "origin/main").
const effectiveBaseBranch = qualifyRemoteBranch(selectedRemote, trimmedBaseBranch);
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
if (result.success && result.worktree) {
@@ -435,7 +453,7 @@ export function CreateWorktreeDialog({
<span>Base Branch</span>
{baseBranch && !showBaseBranch && (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
{baseBranch}
{qualifyRemoteBranch(selectedRemote, baseBranch) ?? baseBranch}
</code>
)}
</button>

View File

@@ -163,7 +163,7 @@ export function WorktreeDropdownItem({
className="inline-flex items-center justify-center h-4 w-4 text-amber-500"
title="Dev server starting..."
>
<Spinner size="xs" variant="current" />
<Spinner size="xs" variant="primary" />
</span>
)}

View File

@@ -372,7 +372,7 @@ export function WorktreeDropdown({
className="inline-flex items-center justify-center h-4 w-4 text-amber-500 shrink-0"
title="Dev server starting..."
>
<Spinner size="xs" variant="current" />
<Spinner size="xs" variant="primary" />
</span>
)}
@@ -561,7 +561,7 @@ export function WorktreeDropdown({
}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingAnyDevServer}
isStartingAnyDevServer={isStartingAnyDevServer}
isDevServerStarting={isDevServerStarting(selectedWorktree)}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}

View File

@@ -533,7 +533,7 @@ export function WorktreeTab({
trackingRemote={trackingRemote}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingAnyDevServer}
isStartingAnyDevServer={isStartingAnyDevServer}
isDevServerStarting={isDevServerStarting}
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}

View File

@@ -9,28 +9,11 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card';
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, ExternalLink, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useNavigate } from '@tanstack/react-router';
import type { Notification } from '@automaker/types';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
import { formatRelativeTime } from '@/lib/utils';
export function NotificationsView() {
const { currentProject } = useAppStore();
@@ -111,8 +94,8 @@ export function NotificationsView() {
// Navigate to the relevant view based on notification type
if (notification.featureId) {
// Navigate to board view - feature will be selected
navigate({ to: '/board' });
// Navigate to board view with feature ID to show output
navigate({ to: '/board', search: { featureId: notification.featureId } });
}
},
[handleMarkAsRead, navigate]
@@ -128,6 +111,10 @@ export function NotificationsView() {
return <Check className="h-5 w-5 text-blue-500" />;
case 'agent_complete':
return <Check className="h-5 w-5 text-purple-500" />;
case 'feature_error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'auto_mode_error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
default:
return <Bell className="h-5 w-5" />;
}

View File

@@ -46,6 +46,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
projectAnalysisModel: 'Project Analysis',
ideationModel: 'Ideation',
memoryExtractionModel: 'Memory Extraction',
prDescriptionModel: 'PR Description',
};
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];

View File

@@ -7,7 +7,7 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { Bot, Folder, RefreshCw, Square, Activity, FileText, Cpu } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -16,6 +16,16 @@ import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
import { useRunningAgents } from '@/hooks/queries';
import { useStopFeature } from '@/hooks/mutations';
import { getModelDisplayName } from '@/lib/utils';
function formatFeatureId(featureId: string): string {
// Strip 'feature-' prefix and timestamp for readability
// e.g. 'feature-1772305345138-epit9shpdxl' → 'epit9shpdxl'
const match = featureId.match(/^feature-\d+-(.+)$/);
if (match) return match[1];
// For other patterns like 'backlog-plan:...' or 'spec-generation:...', show as-is
return featureId;
}
export function RunningAgentsView() {
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
@@ -156,15 +166,21 @@ export function RunningAgentsView() {
{/* Agent info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate" title={agent.title || agent.featureId}>
{agent.title || agent.featureId}
{agent.title || formatFeatureId(agent.featureId)}
</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
{agent.model && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/30 flex items-center gap-1">
<Cpu className="h-3 w-3" />
{getModelDisplayName(agent.model)}
</span>
)}
</div>
{agent.description && (
<p

View File

@@ -19,16 +19,19 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Terminal, Globe } from 'lucide-react';
import { Terminal, Globe, Bell } from 'lucide-react';
import type {
EventHook,
EventHookTrigger,
EventHookHttpMethod,
EventHookShellAction,
EventHookHttpAction,
EventHookNtfyAction,
} from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { generateUUID } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
interface EventHookDialogProps {
open: boolean;
@@ -37,7 +40,7 @@ interface EventHookDialogProps {
onSave: (hook: EventHook) => void;
}
type ActionType = 'shell' | 'http';
type ActionType = 'shell' | 'http' | 'ntfy';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_created',
@@ -49,7 +52,17 @@ const TRIGGER_OPTIONS: EventHookTrigger[] = [
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
const PRIORITY_OPTIONS = [
{ value: 1, label: 'Min (no sound/vibration)' },
{ value: 2, label: 'Low' },
{ value: 3, label: 'Default' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Urgent (max)' },
];
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
const ntfyEndpoints = useAppStore((state) => state.ntfyEndpoints);
// Form state
const [name, setName] = useState('');
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
@@ -65,6 +78,15 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
const [headers, setHeaders] = useState('');
const [body, setBody] = useState('');
// Ntfy action state
const [ntfyEndpointId, setNtfyEndpointId] = useState('');
const [ntfyTitle, setNtfyTitle] = useState('');
const [ntfyBody, setNtfyBody] = useState('');
const [ntfyTags, setNtfyTags] = useState('');
const [ntfyEmoji, setNtfyEmoji] = useState('');
const [ntfyClickUrl, setNtfyClickUrl] = useState('');
const [ntfyPriority, setNtfyPriority] = useState<1 | 2 | 3 | 4 | 5>(3);
// Reset form when dialog opens/closes or editingHook changes
useEffect(() => {
if (open) {
@@ -72,68 +94,131 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
// Populate form with existing hook data
setName(editingHook.name || '');
setTrigger(editingHook.trigger);
setActionType(editingHook.action.type);
setActionType(editingHook.action.type as ActionType);
if (editingHook.action.type === 'shell') {
const shellAction = editingHook.action as EventHookShellAction;
setCommand(shellAction.command);
setTimeout(String(shellAction.timeout || 30000));
// Reset HTTP fields
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
} else {
// Reset other fields
resetHttpFields();
resetNtfyFields();
} else if (editingHook.action.type === 'http') {
const httpAction = editingHook.action as EventHookHttpAction;
setUrl(httpAction.url);
setMethod(httpAction.method);
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
setBody(httpAction.body || '');
// Reset shell fields
setCommand('');
setTimeout('30000');
// Reset other fields
resetShellFields();
resetNtfyFields();
} else if (editingHook.action.type === 'ntfy') {
const ntfyAction = editingHook.action as EventHookNtfyAction;
setNtfyEndpointId(ntfyAction.endpointId);
setNtfyTitle(ntfyAction.title || '');
setNtfyBody(ntfyAction.body || '');
setNtfyTags(ntfyAction.tags || '');
setNtfyEmoji(ntfyAction.emoji || '');
setNtfyClickUrl(ntfyAction.clickUrl || '');
setNtfyPriority(ntfyAction.priority || 3);
// Reset other fields
resetShellFields();
resetHttpFields();
}
} else {
// Reset to defaults for new hook
setName('');
setTrigger('feature_success');
setActionType('shell');
setCommand('');
setTimeout('30000');
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
resetShellFields();
resetHttpFields();
resetNtfyFields();
}
}
}, [open, editingHook]);
const resetShellFields = () => {
setCommand('');
setTimeout('30000');
};
const resetHttpFields = () => {
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
};
const resetNtfyFields = () => {
setNtfyEndpointId('');
setNtfyTitle('');
setNtfyBody('');
setNtfyTags('');
setNtfyEmoji('');
setNtfyClickUrl('');
setNtfyPriority(3);
};
const handleSave = () => {
let action: EventHook['action'];
if (actionType === 'shell') {
action = {
type: 'shell',
command,
timeout: parseInt(timeout, 10) || 30000,
};
} else if (actionType === 'http') {
// Parse headers JSON with error handling
let parsedHeaders: Record<string, string> | undefined;
if (headers.trim()) {
try {
parsedHeaders = JSON.parse(headers);
} catch {
// If JSON is invalid, show error and don't save
toast.error('Invalid JSON in Headers field');
return;
}
}
action = {
type: 'http',
url,
method,
headers: parsedHeaders,
body: body.trim() || undefined,
};
} else {
action = {
type: 'ntfy',
endpointId: ntfyEndpointId,
title: ntfyTitle.trim() || undefined,
body: ntfyBody.trim() || undefined,
tags: ntfyTags.trim() || undefined,
emoji: ntfyEmoji.trim() || undefined,
clickUrl: ntfyClickUrl.trim() || undefined,
priority: ntfyPriority,
};
}
const hook: EventHook = {
id: editingHook?.id || generateUUID(),
name: name.trim() || undefined,
trigger,
enabled: editingHook?.enabled ?? true,
action:
actionType === 'shell'
? {
type: 'shell',
command,
timeout: parseInt(timeout, 10) || 30000,
}
: {
type: 'http',
url,
method,
headers: headers.trim() ? JSON.parse(headers) : undefined,
body: body.trim() || undefined,
},
action,
};
onSave(hook);
};
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
const selectedEndpoint = ntfyEndpoints.find((e) => e.id === ntfyEndpointId);
const isValid = (() => {
if (actionType === 'shell') return command.trim().length > 0;
if (actionType === 'http') return url.trim().length > 0;
if (actionType === 'ntfy') return Boolean(selectedEndpoint);
return false;
})();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -179,13 +264,17 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
<Label>Action Type</Label>
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
<TabsList className="w-full">
<TabsTrigger value="shell" className="flex-1 gap-2">
<TabsTrigger value="shell" className="flex-1 gap-1">
<Terminal className="w-4 h-4" />
Shell Command
<span className="sr-only sm:inline">Shell</span>
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-2">
<TabsTrigger value="http" className="flex-1 gap-1">
<Globe className="w-4 h-4" />
HTTP Request
<span className="sr-only sm:inline">HTTP</span>
</TabsTrigger>
<TabsTrigger value="ntfy" className="flex-1 gap-1">
<Bell className="w-4 h-4" />
<span className="sr-only sm:inline">Ntfy</span>
</TabsTrigger>
</TabsList>
@@ -273,6 +362,139 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
</p>
</div>
</TabsContent>
{/* Ntfy notification form */}
<TabsContent value="ntfy" className="space-y-4 mt-4">
{ntfyEndpoints.length === 0 ? (
<div className="rounded-lg bg-muted/50 p-4 text-center">
<Bell className="w-8 h-8 mx-auto mb-2 text-muted-foreground opacity-50" />
<p className="text-sm text-muted-foreground">No ntfy endpoints configured.</p>
<p className="text-xs text-muted-foreground mt-1">
Add an endpoint in the "Endpoints" tab first.
</p>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="ntfy-endpoint">Endpoint *</Label>
<Select value={ntfyEndpointId} onValueChange={setNtfyEndpointId}>
<SelectTrigger id="ntfy-endpoint">
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
<SelectContent>
{ntfyEndpoints
.filter((e) => e.enabled)
.map((endpoint) => (
<SelectItem key={endpoint.id} value={endpoint.id}>
{endpoint.name} ({endpoint.topic})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedEndpoint && (
<div className="rounded-lg bg-muted/30 p-3 text-xs text-muted-foreground">
<p>
<strong>Server:</strong> {selectedEndpoint.serverUrl}
</p>
{selectedEndpoint.defaultTags && (
<p>
<strong>Default Tags:</strong> {selectedEndpoint.defaultTags}
</p>
)}
{selectedEndpoint.defaultEmoji && (
<p>
<strong>Default Emoji:</strong> {selectedEndpoint.defaultEmoji}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="ntfy-title">Title (optional)</Label>
<Input
id="ntfy-title"
value={ntfyTitle}
onChange={(e) => setNtfyTitle(e.target.value)}
placeholder="Feature {{featureName}} completed"
/>
<p className="text-xs text-muted-foreground">
Defaults to event name. Use {'{{variable}}'} for dynamic values.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-body">Message (optional)</Label>
<Textarea
id="ntfy-body"
value={ntfyBody}
onChange={(e) => setNtfyBody(e.target.value)}
placeholder="Feature {{featureId}} completed at {{timestamp}}"
className="font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Defaults to event details. Leave empty for auto-generated message.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ntfy-tags">Tags (optional)</Label>
<Input
id="ntfy-tags"
value={ntfyTags}
onChange={(e) => setNtfyTags(e.target.value)}
placeholder="warning,skull"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-emoji">Emoji</Label>
<Input
id="ntfy-emoji"
value={ntfyEmoji}
onChange={(e) => setNtfyEmoji(e.target.value)}
placeholder="tada"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-click">Click URL (optional)</Label>
<Input
id="ntfy-click"
value={ntfyClickUrl}
onChange={(e) => setNtfyClickUrl(e.target.value)}
placeholder="https://example.com"
/>
<p className="text-xs text-muted-foreground">
URL to open when notification is clicked. Defaults to endpoint setting.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-priority">Priority</Label>
<Select
value={String(ntfyPriority)}
onValueChange={(v) => setNtfyPriority(Number(v) as 1 | 2 | 3 | 4 | 5)}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -1,24 +1,63 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
import {
Webhook,
Plus,
Trash2,
Pencil,
Terminal,
Globe,
History,
Bell,
Server,
} from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import type {
EventHook,
EventHookTrigger,
NtfyEndpointConfig,
NtfyAuthenticationType,
} from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
import { EventHistoryView } from './event-history-view';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { generateUUID } from '@/lib/utils';
const logger = createLogger('EventHooks');
type TabType = 'hooks' | 'endpoints' | 'history';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const { eventHooks, setEventHooks, ntfyEndpoints, setNtfyEndpoints } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
const [activeTab, setActiveTab] = useState<TabType>('hooks');
// Ntfy endpoint dialog state
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
const [editingEndpoint, setEditingEndpoint] = useState<NtfyEndpointConfig | null>(null);
const handleAddHook = () => {
setEditingHook(null);
@@ -65,6 +104,57 @@ export function EventHooksSection() {
}
};
// Ntfy endpoint handlers
const handleAddEndpoint = () => {
setEditingEndpoint(null);
setEndpointDialogOpen(true);
};
const handleEditEndpoint = (endpoint: NtfyEndpointConfig) => {
setEditingEndpoint(endpoint);
setEndpointDialogOpen(true);
};
const handleDeleteEndpoint = async (endpointId: string) => {
try {
await setNtfyEndpoints(ntfyEndpoints.filter((e) => e.id !== endpointId));
toast.success('Endpoint deleted');
} catch (error) {
logger.error('Failed to delete ntfy endpoint:', error);
toast.error('Failed to delete endpoint');
}
};
const handleToggleEndpoint = async (endpointId: string, enabled: boolean) => {
try {
await setNtfyEndpoints(
ntfyEndpoints.map((e) => (e.id === endpointId ? { ...e, enabled } : e))
);
} catch (error) {
logger.error('Failed to toggle ntfy endpoint:', error);
toast.error('Failed to update endpoint');
}
};
const handleSaveEndpoint = async (endpoint: NtfyEndpointConfig) => {
try {
if (editingEndpoint) {
// Update existing
await setNtfyEndpoints(ntfyEndpoints.map((e) => (e.id === endpoint.id ? endpoint : e)));
toast.success('Endpoint updated');
} else {
// Add new
await setNtfyEndpoints([...ntfyEndpoints, endpoint]);
toast.success('Endpoint added');
}
setEndpointDialogOpen(false);
setEditingEndpoint(null);
} catch (error) {
logger.error('Failed to save ntfy endpoint:', error);
toast.error('Failed to save endpoint');
}
};
// Group hooks by trigger type for better organization
const hooksByTrigger = eventHooks.reduce(
(acc, hook) => {
@@ -96,7 +186,7 @@ export function EventHooksSection() {
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
<p className="text-sm text-muted-foreground/80">
Run custom commands or webhooks when events occur
Run custom commands or send notifications when events occur
</p>
</div>
</div>
@@ -106,17 +196,27 @@ export function EventHooksSection() {
Add Hook
</Button>
)}
{activeTab === 'endpoints' && (
<Button onClick={handleAddEndpoint} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Endpoint
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)}>
<div className="px-6 pt-4">
<TabsList className="grid w-full max-w-xs grid-cols-2">
<TabsList className="grid w-full max-w-sm grid-cols-3">
<TabsTrigger value="hooks" className="gap-2">
<Webhook className="w-4 h-4" />
Hooks
</TabsTrigger>
<TabsTrigger value="endpoints" className="gap-2">
<Bell className="w-4 h-4" />
Endpoints
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="w-4 h-4" />
History
@@ -148,6 +248,7 @@ export function EventHooksSection() {
<HookCard
key={hook.id}
hook={hook}
ntfyEndpoints={ntfyEndpoints}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
@@ -166,12 +267,56 @@ export function EventHooksSection() {
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
{'{{error}}'} {'{{errorType}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
</TabsContent>
{/* Endpoints Tab */}
<TabsContent value="endpoints" className="m-0">
<div className="p-6 pt-4">
{ntfyEndpoints.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No ntfy endpoints configured</p>
<p className="text-xs mt-1">Add endpoints to send push notifications via ntfy.sh</p>
</div>
) : (
<div className="space-y-2">
{ntfyEndpoints.map((endpoint) => (
<EndpointCard
key={endpoint.id}
endpoint={endpoint}
onEdit={() => handleEditEndpoint(endpoint)}
onDelete={() => handleDeleteEndpoint(endpoint.id)}
onToggle={(enabled) => handleToggleEndpoint(endpoint.id, enabled)}
/>
))}
</div>
)}
</div>
{/* Help text */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">About ntfy.sh:</p>
<p className="mb-2">
ntfy.sh is a simple pub-sub notification service. Create a topic and subscribe via
web, mobile app, or API.
</p>
<a
href="https://ntfy.sh"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
https://ntfy.sh
</a>
</div>
</div>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="m-0">
<div className="p-6 pt-4">
@@ -180,26 +325,51 @@ export function EventHooksSection() {
</TabsContent>
</Tabs>
{/* Dialog */}
{/* Hook Dialog */}
<EventHookDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingHook={editingHook}
onSave={handleSaveHook}
/>
{/* Endpoint Dialog */}
<NtfyEndpointDialog
open={endpointDialogOpen}
onOpenChange={setEndpointDialogOpen}
editingEndpoint={editingEndpoint}
onSave={handleSaveEndpoint}
/>
</div>
);
}
interface HookCardProps {
hook: EventHook;
ntfyEndpoints: NtfyEndpointConfig[];
onEdit: () => void;
onDelete: () => void;
onToggle: (enabled: boolean) => void;
}
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
function HookCard({ hook, ntfyEndpoints, onEdit, onDelete, onToggle }: HookCardProps) {
const isShell = hook.action.type === 'shell';
const isHttp = hook.action.type === 'http';
const isNtfy = hook.action.type === 'ntfy';
// Get ntfy endpoint name if this is an ntfy hook
const ntfyEndpointName = isNtfy
? ntfyEndpoints.find(
(e) => e.id === (hook.action as { type: 'ntfy'; endpointId: string }).endpointId
)?.name || 'Unknown endpoint'
: null;
// Get icon background and color
const iconStyle = isShell
? 'bg-amber-500/10 text-amber-500'
: isHttp
? 'bg-blue-500/10 text-blue-500'
: 'bg-purple-500/10 text-purple-500';
return (
<div
@@ -210,24 +380,27 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
)}
>
{/* Type icon */}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', iconStyle)}>
{isShell ? (
<Terminal className="w-4 h-4" />
) : isHttp ? (
<Globe className="w-4 h-4" />
) : (
<Bell className="w-4 h-4" />
)}
>
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
{hook.name || (isShell ? 'Shell Command' : isHttp ? 'HTTP Webhook' : 'Ntfy Notification')}
</p>
<p className="text-xs text-muted-foreground truncate">
{isShell
? (hook.action as { type: 'shell'; command: string }).command
: (hook.action as { type: 'http'; url: string }).url}
: isHttp
? (hook.action as { type: 'http'; url: string }).url
: ntfyEndpointName}
</p>
</div>
@@ -249,3 +422,341 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
</div>
);
}
interface EndpointCardProps {
endpoint: NtfyEndpointConfig;
onEdit: () => void;
onDelete: () => void;
onToggle: (enabled: boolean) => void;
}
function EndpointCard({ endpoint, onEdit, onDelete, onToggle }: EndpointCardProps) {
return (
<div
data-testid="endpoint-card"
className={cn(
'flex items-center gap-3 p-3 rounded-lg border',
'bg-background/50 hover:bg-background/80 transition-colors',
!endpoint.enabled && 'opacity-60'
)}
>
{/* Icon */}
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-purple-500/10 text-purple-500">
<Server className="w-4 h-4" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{endpoint.name}</p>
<p className="text-xs text-muted-foreground truncate">
{endpoint.topic} {endpoint.serverUrl.replace(/^https?:\/\//, '')}
</p>
</div>
{/* Auth badge */}
{endpoint.authType !== 'none' && (
<div className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
{endpoint.authType === 'basic' ? 'Basic Auth' : 'Token'}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<Switch
checked={endpoint.enabled}
onCheckedChange={onToggle}
aria-label={`${endpoint.enabled ? 'Disable' : 'Enable'} endpoint ${endpoint.name}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onEdit}
aria-label={`Edit endpoint ${endpoint.name}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onDelete}
aria-label={`Delete endpoint ${endpoint.name}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
);
}
// Ntfy Endpoint Dialog Component
interface NtfyEndpointDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingEndpoint: NtfyEndpointConfig | null;
onSave: (endpoint: NtfyEndpointConfig) => void;
}
function NtfyEndpointDialog({
open,
onOpenChange,
editingEndpoint,
onSave,
}: NtfyEndpointDialogProps) {
const [name, setName] = useState('');
const [serverUrl, setServerUrl] = useState('https://ntfy.sh');
const [topic, setTopic] = useState('');
const [authType, setAuthType] = useState<NtfyAuthenticationType>('none');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [token, setToken] = useState('');
const [defaultTags, setDefaultTags] = useState('');
const [defaultEmoji, setDefaultEmoji] = useState('');
const [defaultClickUrl, setDefaultClickUrl] = useState('');
const [enabled, setEnabled] = useState(true);
// Reset form when dialog opens/closes
useEffect(() => {
if (open) {
if (editingEndpoint) {
setName(editingEndpoint.name);
setServerUrl(editingEndpoint.serverUrl);
setTopic(editingEndpoint.topic);
setAuthType(editingEndpoint.authType);
setUsername(editingEndpoint.username || '');
setPassword(''); // Don't populate password for security
setToken(''); // Don't populate token for security
setDefaultTags(editingEndpoint.defaultTags || '');
setDefaultEmoji(editingEndpoint.defaultEmoji || '');
setDefaultClickUrl(editingEndpoint.defaultClickUrl || '');
setEnabled(editingEndpoint.enabled);
} else {
setName('');
setServerUrl('https://ntfy.sh');
setTopic('');
setAuthType('none');
setUsername('');
setPassword('');
setToken('');
setDefaultTags('');
setDefaultEmoji('');
setDefaultClickUrl('');
setEnabled(true);
}
}
}, [open, editingEndpoint]);
const handleSave = () => {
const trimmedPassword = password.trim();
const trimmedToken = token.trim();
const endpoint: NtfyEndpointConfig = {
id: editingEndpoint?.id || generateUUID(),
name: name.trim(),
serverUrl: serverUrl.trim(),
topic: topic.trim(),
authType,
username: authType === 'basic' ? username.trim() : undefined,
// Preserve existing secret if input was left blank when editing
password:
authType === 'basic'
? trimmedPassword || (editingEndpoint ? editingEndpoint.password : undefined)
: undefined,
token:
authType === 'token'
? trimmedToken || (editingEndpoint ? editingEndpoint.token : undefined)
: undefined,
defaultTags: defaultTags.trim() || undefined,
defaultEmoji: defaultEmoji.trim() || undefined,
defaultClickUrl: defaultClickUrl.trim() || undefined,
enabled,
};
onSave(endpoint);
};
// Validate form
const isServerUrlValid = (() => {
const trimmed = serverUrl.trim();
if (!trimmed) return false;
try {
const parsed = new URL(trimmed);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
})();
const isValid =
name.trim().length > 0 &&
isServerUrlValid &&
topic.trim().length > 0 &&
!topic.includes(' ') &&
(authType !== 'basic' ||
(username.trim().length > 0 &&
(password.trim().length > 0 || Boolean(editingEndpoint?.password)))) &&
(authType !== 'token' || token.trim().length > 0 || Boolean(editingEndpoint?.token));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingEndpoint ? 'Edit Ntfy Endpoint' : 'Add Ntfy Endpoint'}</DialogTitle>
<DialogDescription>
Configure an ntfy.sh server to receive push notifications.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="endpoint-name">Name *</Label>
<Input
id="endpoint-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Personal Phone"
/>
</div>
{/* Server URL */}
<div className="space-y-2">
<Label htmlFor="endpoint-server">Server URL</Label>
<Input
id="endpoint-server"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://ntfy.sh"
/>
<p className="text-xs text-muted-foreground">
Default is ntfy.sh. Use custom URL for self-hosted servers.
</p>
</div>
{/* Topic */}
<div className="space-y-2">
<Label htmlFor="endpoint-topic">Topic *</Label>
<Input
id="endpoint-topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="my-automaker-notifications"
/>
<p className="text-xs text-muted-foreground">
Topic name (no spaces). This acts like a channel for your notifications.
</p>
</div>
{/* Authentication */}
<div className="space-y-2">
<Label htmlFor="endpoint-auth">Authentication</Label>
<Select
value={authType}
onValueChange={(v) => setAuthType(v as NtfyAuthenticationType)}
>
<SelectTrigger id="endpoint-auth">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (public topic)</SelectItem>
<SelectItem value="basic">Username & Password</SelectItem>
<SelectItem value="token">Access Token</SelectItem>
</SelectContent>
</Select>
</div>
{/* Conditional auth fields */}
{authType === 'basic' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint-username">Username</Label>
<Input
id="endpoint-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint-password">Password</Label>
<Input
id="endpoint-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
/>
</div>
</div>
)}
{authType === 'token' && (
<div className="space-y-2">
<Label htmlFor="endpoint-token">Access Token</Label>
<Input
id="endpoint-token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="tk_xxxxxxxxxxxxx"
/>
</div>
)}
{/* Default Tags */}
<div className="space-y-2">
<Label htmlFor="endpoint-tags">Default Tags (optional)</Label>
<Input
id="endpoint-tags"
value={defaultTags}
onChange={(e) => setDefaultTags(e.target.value)}
placeholder="warning,skull"
/>
<p className="text-xs text-muted-foreground">
Comma-separated tags or emoji shortcodes (e.g., warning, partypopper)
</p>
</div>
{/* Default Emoji */}
<div className="space-y-2">
<Label htmlFor="endpoint-emoji">Default Emoji (optional)</Label>
<Input
id="endpoint-emoji"
value={defaultEmoji}
onChange={(e) => setDefaultEmoji(e.target.value)}
placeholder="tada"
/>
</div>
{/* Default Click URL */}
<div className="space-y-2">
<Label htmlFor="endpoint-click">Default Click URL (optional)</Label>
<Input
id="endpoint-click"
value={defaultClickUrl}
onChange={(e) => setDefaultClickUrl(e.target.value)}
placeholder="http://localhost:3007"
/>
<p className="text-xs text-muted-foreground">
URL to open when notification is clicked. Auto-linked to project/feature if available.
</p>
</div>
{/* Enabled toggle */}
<div className="flex items-center justify-between">
<Label htmlFor="endpoint-enabled">Enabled</Label>
<Switch id="endpoint-enabled" checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid}>
{editingEndpoint ? 'Save Changes' : 'Add Endpoint'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -44,6 +44,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
projectAnalysisModel: 'Project Analysis',
ideationModel: 'Ideation',
memoryExtractionModel: 'Memory Extraction',
prDescriptionModel: 'PR Description',
};
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];

View File

@@ -580,7 +580,7 @@ export function PhaseModelSelector({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
badge: model.tier === 'premium' ? 'Premium' : undefined,
provider: 'opencode' as const,
}));

View File

@@ -248,7 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
isGitRepo: true,
hasCommits: true,
trackingRemote: result.result?.trackingRemote,
remotesWithBranch: result.result?.remotesWithBranch,
remotesWithBranch: (result.result as { remotesWithBranch?: string[] })?.remotesWithBranch,
};
},
enabled: !!worktreePath,

View File

@@ -30,7 +30,7 @@ export function useAgentOutputWebSocket({
onFeatureComplete,
}: UseAgentOutputWebSocketProps) {
const [streamedContent, setStreamedContent] = useState('');
const closeTimeoutRef = useRef<NodeJS.Timeout>();
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Use React Query for initial output loading
const { data: initialOutput = '', isLoading } = useAgentOutput(projectPath, featureId, {
@@ -98,7 +98,16 @@ export function useAgentOutputWebSocket({
if (isBacklogPlan) {
// Handle backlog plan events
if (api.backlogPlan) {
unsubscribe = api.backlogPlan.onEvent(handleBacklogPlanEvent);
unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
if (
data !== null &&
typeof data === 'object' &&
'type' in data &&
typeof (data as { type: unknown }).type === 'string'
) {
handleBacklogPlanEvent(data as BacklogPlanEvent);
}
});
}
} else {
// Handle auto mode events

View File

@@ -39,6 +39,7 @@ import {
migratePhaseModelEntry,
type GlobalSettings,
type CursorModelId,
type PhaseModelEntry,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -198,6 +199,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
ntfyEndpoints: state.ntfyEndpoints as GlobalSettings['ntfyEndpoints'],
featureTemplates: state.featureTemplates as GlobalSettings['featureTemplates'],
projects: state.projects as GlobalSettings['projects'],
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
@@ -809,6 +812,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {},
eventHooks: settings.eventHooks ?? [],
ntfyEndpoints: settings.ntfyEndpoints ?? [],
featureTemplates: settings.featureTemplates ?? [],
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
claudeApiProfiles: settings.claudeApiProfiles ?? [],
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
@@ -821,7 +826,10 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
agentModelBySession: settings.agentModelBySession
? Object.fromEntries(
Object.entries(settings.agentModelBySession as Record<string, unknown>).map(
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
([sessionId, entry]) => [
sessionId,
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
]
)
)
: current.agentModelBySession,
@@ -945,6 +953,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization,
eventHooks: state.eventHooks,
ntfyEndpoints: state.ntfyEndpoints,
featureTemplates: state.featureTemplates,
claudeCompatibleProviders: state.claudeCompatibleProviders,
claudeApiProfiles: state.claudeApiProfiles,
activeClaudeApiProfileId: state.activeClaudeApiProfileId,

View File

@@ -37,6 +37,7 @@ import {
type CursorModelId,
type GeminiModelId,
type CopilotModelId,
type PhaseModelEntry,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -106,6 +107,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'subagentsSources',
'promptCustomization',
'eventHooks',
'ntfyEndpoints',
'featureTemplates',
'claudeCompatibleProviders', // Claude-compatible provider configs - must persist to server
'claudeApiProfiles',
@@ -855,7 +857,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
agentModelBySession: serverSettings.agentModelBySession
? Object.fromEntries(
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
([sessionId, entry]) => [
sessionId,
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
]
)
)
: currentAppState.agentModelBySession,
@@ -870,6 +875,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
recentFolders: serverSettings.recentFolders ?? [],
// Event hooks
eventHooks: serverSettings.eventHooks ?? [],
// Ntfy endpoints
ntfyEndpoints: serverSettings.ntfyEndpoints ?? [],
// Feature templates
featureTemplates: serverSettings.featureTemplates ?? [],
// Codex CLI Settings

View File

@@ -239,6 +239,8 @@ export interface RunningAgent {
projectPath: string;
projectName: string;
isAutoMode: boolean;
model?: string;
provider?: string;
title?: string;
description?: string;
branchName?: string;

View File

@@ -1384,7 +1384,7 @@ export function isAccumulatedSummary(summary: string | undefined): boolean {
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes(PHASE_SEPARATOR) && summary.match(/###\s+.+/g)?.length > 0;
summary.includes(PHASE_SEPARATOR) && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
return hasMultiplePhases;
}

View File

@@ -101,12 +101,22 @@ export function getProviderFromModel(model?: string): ModelProvider {
/**
* Get display name for a model
* Handles both aliases (e.g., "sonnet") and full model IDs (e.g., "claude-sonnet-4-20250514")
*/
export function getModelDisplayName(model: ModelAlias | string): string {
const displayNames: Record<string, string> = {
// Claude aliases
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
// Claude canonical IDs (without version suffix)
'claude-haiku': 'Claude Haiku',
'claude-sonnet': 'Claude Sonnet',
'claude-opus': 'Claude Opus',
// Claude full model IDs (returned by server)
'claude-haiku-4-5': 'Claude Haiku',
'claude-sonnet-4-20250514': 'Claude Sonnet',
'claude-opus-4-6': 'Claude Opus',
// Codex models
'codex-gpt-5.2': 'GPT-5.2',
'codex-gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
@@ -211,3 +221,24 @@ export function generateUUID(): string {
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 0) return date.toLocaleDateString();
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}

View File

@@ -53,10 +53,6 @@ if (isDev) {
// Must be set before app.whenReady() — has no effect on macOS/Windows.
if (process.platform === 'linux') {
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
// Link the running process to its .desktop file so GNOME/KDE uses the
// desktop entry's Icon for the taskbar instead of Electron's default.
// Must be called before any window is created.
app.setDesktopName('automaker.desktop');
}
// Register IPC handlers

View File

@@ -1,6 +1,11 @@
import { createLazyFileRoute } from '@tanstack/react-router';
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
import { BoardView } from '@/components/views/board-view';
export const Route = createLazyFileRoute('/board')({
component: BoardView,
component: BoardRouteComponent,
});
function BoardRouteComponent() {
const { featureId } = useSearch({ from: '/board' });
return <BoardView initialFeatureId={featureId} />;
}

View File

@@ -1,4 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
// Search params schema for board route
const boardSearchSchema = z.object({
featureId: z.string().optional(),
});
// Component is lazy-loaded via board.lazy.tsx for code splitting.
// Board is the most-visited landing route, but lazy loading still benefits
@@ -6,4 +12,6 @@ import { createFileRoute } from '@tanstack/react-router';
// downloaded when the user actually navigates to /board (vs being bundled
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
// dynamic import automatically when a .lazy.tsx file exists.
export const Route = createFileRoute('/board')({});
export const Route = createFileRoute('/board')({
validateSearch: boardSearchSchema,
});

View File

@@ -361,6 +361,7 @@ const initialState: AppState = {
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
promptCustomization: {},
eventHooks: [],
ntfyEndpoints: [],
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
claudeCompatibleProviders: [],
claudeApiProfiles: [],
@@ -1501,6 +1502,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}
},
// Ntfy Endpoint actions
setNtfyEndpoints: async (endpoints) => {
set({ ntfyEndpoints: endpoints });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints });
} catch (error) {
logger.error('Failed to sync ntfy endpoints:', error);
}
},
// Feature Template actions
setFeatureTemplates: async (templates) => {
set({ featureTemplates: templates });

View File

@@ -18,6 +18,7 @@ import type {
ModelDefinition,
ServerLogLevel,
EventHook,
NtfyEndpointConfig,
ClaudeApiProfile,
ClaudeCompatibleProvider,
SidebarStyle,
@@ -275,6 +276,9 @@ export interface AppState {
// Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
// Ntfy.sh Notification Endpoints
ntfyEndpoints: NtfyEndpointConfig[]; // Configured ntfy.sh endpoints for push notifications
// Feature Templates
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
@@ -675,6 +679,9 @@ export interface AppActions {
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => Promise<void>;
// Ntfy Endpoint actions
setNtfyEndpoints: (endpoints: NtfyEndpointConfig[]) => Promise<void>;
// Feature Template actions
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
addFeatureTemplate: (template: FeatureTemplate) => Promise<void>;

View File

@@ -1433,10 +1433,14 @@ export interface WorktreeAPI {
error?: string;
}>;
// Subscribe to dev server log events (started, output, stopped, url-detected)
// Subscribe to dev server log events (starting, started, output, stopped, url-detected)
onDevServerLogEvent: (
callback: (
event:
| {
type: 'dev-server:starting';
payload: { worktreePath: string; timestamp: string };
}
| {
type: 'dev-server:started';
payload: { worktreePath: string; port: number; url: string; timestamp: string };

View File

@@ -85,15 +85,8 @@ test.describe('Agent Chat Session', () => {
const sessionCount = await countSessionItems(page);
expect(sessionCount).toBeGreaterThanOrEqual(1);
// Ensure the new session is selected (click first session item if message list not yet visible)
// Handles race where list updates before selection is applied in CI
// Verify the message list is visible (indicates the newly created session was selected)
const messageList = page.locator('[data-testid="message-list"]');
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
if (!(await messageList.isVisible())) {
await sessionItem.click();
}
// Verify the message list is visible (indicates a session is selected)
await expect(messageList).toBeVisible({ timeout: 10000 });
// Verify the agent input is visible

View File

@@ -0,0 +1,176 @@
/**
* Feature Deep Link E2E Test
*
* Tests that navigating to /board?featureId=xxx opens the board and shows
* the output modal for the specified feature.
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
setupRealProject,
waitForNetworkIdle,
clickAddFeature,
fillAddFeatureDialog,
confirmAddFeature,
authenticateForTests,
handleLoginScreenIfPresent,
waitForAgentOutputModal,
getOutputModalDescription,
} from '../utils';
const TEST_TEMP_DIR = createTempDirPath('feature-deep-link-test');
test.describe('Feature Deep Link', () => {
let projectPath: string;
let projectName: string;
test.beforeEach(async ({}, testInfo) => {
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
const automakerDir = path.join(projectPath, '.automaker');
fs.mkdirSync(automakerDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDir, 'categories.json'),
JSON.stringify({ categories: [] }, null, 2)
);
fs.writeFileSync(
path.join(automakerDir, 'app_spec.txt'),
`# ${projectName}\n\nA test project for e2e testing.`
);
});
test.afterEach(async () => {
if (projectPath && fs.existsSync(projectPath)) {
fs.rmSync(projectPath, { recursive: true, force: true });
}
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
test('should open output modal when navigating to /board?featureId=xxx', async ({ page }) => {
const featureDescription = `Deep link test feature ${Date.now()}`;
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk = await authenticateForTests(page);
expect(authOk).toBe(true);
// Create a feature first
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
timeout: 5000,
});
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, featureDescription);
await confirmAddFeature(page);
// Wait for the feature to appear in the backlog
await expect(async () => {
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({
hasText: featureDescription,
});
expect(await featureCard.count()).toBeGreaterThan(0);
}).toPass({ timeout: 20000 });
// Get the feature ID from the card
const featureCard = page
.locator('[data-testid="kanban-column-backlog"]')
.locator('[data-testid^="kanban-card-"]')
.filter({ hasText: featureDescription })
.first();
const cardTestId = await featureCard.getAttribute('data-testid');
const featureId = cardTestId?.replace('kanban-card-', '') || null;
expect(featureId).toBeTruthy();
// Close any open modals first
const modal = page.locator('[data-testid="agent-output-modal"]');
if (await modal.isVisible()) {
await page.keyboard.press('Escape');
await expect(modal).toBeHidden({ timeout: 3000 });
}
// Now navigate to the board with the featureId query parameter
await page.goto(`/board?featureId=${encodeURIComponent(featureId ?? '')}`);
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// The output modal should automatically open
await waitForAgentOutputModal(page, { timeout: 10000 });
const modalVisible = await page.locator('[data-testid="agent-output-modal"]').isVisible();
expect(modalVisible).toBe(true);
// Verify the modal shows the correct feature
const modalDescription = await getOutputModalDescription(page);
expect(modalDescription).toContain(featureDescription);
});
test('should handle invalid featureId gracefully', async ({ page }) => {
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk2 = await authenticateForTests(page);
expect(authOk2).toBe(true);
// Navigate with a non-existent feature ID
const nonExistentId = 'non-existent-feature-id-12345';
await page.goto(`/board?featureId=${nonExistentId}`);
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// Board should still load
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Output modal should NOT appear (feature doesn't exist)
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeHidden({ timeout: 3000 });
});
test('should handle navigation without featureId', async ({ page }) => {
// Setup project
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
const authOk3 = await authenticateForTests(page);
expect(authOk3).toBe(true);
// Navigate without featureId
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
// Board should load normally
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
timeout: 5000,
});
// Output modal should NOT appear
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeHidden({ timeout: 2000 });
});
});

View File

@@ -0,0 +1,271 @@
/**
* Event Hooks Settings Page Tests
*
* Tests for the event hooks settings section, including:
* - Event hooks management
* - Ntfy endpoint configuration
* - Dialog state management (useEffect hook validation)
*
* This test also serves as a regression test for the bug where
* useEffect was not imported in the event-hooks-section.tsx file,
* causing a runtime error when opening the Ntfy endpoint dialog.
*/
import { test, expect, type Page } from '@playwright/test';
import { authenticateForTests, navigateToSettings } from '../utils';
// Timeout constants for maintainability
const TIMEOUTS = {
sectionVisible: 10000,
dialogVisible: 5000,
dialogHidden: 5000,
endpointVisible: 5000,
} as const;
// Selectors for reuse
const SELECTORS = {
eventHooksButton: 'button:has-text("Event Hooks")',
endpointsTab: 'button[role="tab"]:has-text("Endpoints")',
sectionText: 'text=Run custom commands or send notifications',
addEndpointButton: 'button:has-text("Add Endpoint")',
dialog: '[role="dialog"]',
dialogTitle: 'text=Add Ntfy Endpoint',
} as const;
/**
* Navigate to the Event Hooks Endpoints tab
* This helper reduces code duplication across tests
*/
async function navigateToEndpointsTab(page: Page): Promise<void> {
await navigateToSettings(page);
// Click on the Event Hooks section in the navigation
await page.locator(SELECTORS.eventHooksButton).first().click();
// Wait for the event hooks section to be visible
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
// Switch to Endpoints tab (ntfy endpoints)
await page.locator(SELECTORS.endpointsTab).click();
}
test.describe('Event Hooks Settings', () => {
test.beforeEach(async ({ page }) => {
await authenticateForTests(page);
});
test('should load event hooks settings section without errors', async ({ page }) => {
await navigateToSettings(page);
// Click on the Event Hooks section in the navigation
await page.locator(SELECTORS.eventHooksButton).first().click();
// Wait for the event hooks section to be visible
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
// Verify the tabs are present
await expect(page.locator('button[role="tab"]:has-text("Hooks")')).toBeVisible();
await expect(page.locator(SELECTORS.endpointsTab)).toBeVisible();
await expect(page.locator('button[role="tab"]:has-text("History")')).toBeVisible();
});
test('should open add ntfy endpoint dialog and verify useEffect resets form', async ({
page,
}) => {
// This test specifically validates that the useEffect hook in NtfyEndpointDialog
// works correctly - if useEffect was not imported, the form would not reset
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
// Dialog should be visible
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Dialog title should indicate adding new endpoint
await expect(dialog.locator(SELECTORS.dialogTitle)).toBeVisible();
// Form should have default values (useEffect reset)
// This is the critical test - if useEffect was not imported or not working,
// these assertions would fail because the form state would not be reset
const nameInput = dialog.locator('input#endpoint-name');
const serverUrlInput = dialog.locator('input#endpoint-server');
const topicInput = dialog.locator('input#endpoint-topic');
// Name should be empty (reset by useEffect)
await expect(nameInput).toHaveValue('');
// Server URL should have default value (reset by useEffect)
await expect(serverUrlInput).toHaveValue('https://ntfy.sh');
// Topic should be empty (reset by useEffect)
await expect(topicInput).toHaveValue('');
// Close the dialog
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
});
test('should open and close endpoint dialog without JavaScript errors', async ({ page }) => {
// This test verifies the dialog opens without throwing a "useEffect is not defined" error
// Listen for console errors
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await navigateToEndpointsTab(page);
// Open and close the dialog multiple times to stress test the useEffect
for (let i = 0; i < 3; i++) {
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
}
// Verify no React hook related errors occurred
// This catches "useEffect is not defined", "useState is not defined", etc.
const reactHookError = consoleErrors.find(
(error) =>
(error.includes('useEffect') ||
error.includes('useState') ||
error.includes('useCallback')) &&
error.includes('is not defined')
);
expect(reactHookError).toBeUndefined();
});
test('should have enabled toggle working in endpoint dialog', async ({ page }) => {
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Verify the enabled switch exists and is checked by default (useEffect sets enabled=true)
const enabledSwitch = dialog.locator('#endpoint-enabled');
await expect(enabledSwitch).toBeChecked();
// Click the switch to toggle it off
await enabledSwitch.click();
await expect(enabledSwitch).not.toBeChecked();
// Click it again to toggle it back on
await enabledSwitch.click();
await expect(enabledSwitch).toBeChecked();
// Close the dialog
await page.keyboard.press('Escape');
});
test('should have Add Endpoint button disabled when form is invalid', async ({ page }) => {
await navigateToEndpointsTab(page);
// Click Add Endpoint button
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// The Add Endpoint button should be disabled because form is empty (name and topic required)
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
await expect(addButton).toBeDisabled();
// Fill in name but not topic
await dialog.locator('input#endpoint-name').fill('Test Name');
// Button should still be disabled (topic is required)
await expect(addButton).toBeDisabled();
// Fill in topic with invalid value (contains space)
await dialog.locator('input#endpoint-topic').fill('invalid topic');
// Button should still be disabled (topic has space which is invalid)
await expect(addButton).toBeDisabled();
// Fix the topic
await dialog.locator('input#endpoint-topic').fill('valid-topic');
// Now button should be enabled
await expect(addButton).toBeEnabled();
// Close the dialog
await page.keyboard.press('Escape');
});
test('should persist ntfy endpoint after adding and page reload', async ({ page }) => {
// This test verifies that ntfy endpoints are correctly saved to the server
// and restored when the page is reloaded - the core bug fix being tested
await navigateToEndpointsTab(page);
// Add a new endpoint
await page.locator(SELECTORS.addEndpointButton).click();
const dialog = page.locator(SELECTORS.dialog);
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
// Fill in the endpoint form
const uniqueSuffix = Date.now();
await dialog.locator('input#endpoint-name').fill(`Test Endpoint ${uniqueSuffix}`);
await dialog.locator('input#endpoint-server').fill('https://ntfy.sh');
await dialog.locator('input#endpoint-topic').fill(`test-topic-${uniqueSuffix}`);
// Save the endpoint
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
await addButton.click();
// Dialog should close
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
// Wait for the endpoint to appear in the list
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
timeout: TIMEOUTS.endpointVisible,
});
// Reload the page
await page.reload();
// Re-authenticate after reload
await authenticateForTests(page);
// Navigate back to the endpoints tab
await navigateToEndpointsTab(page);
// Verify the endpoint persisted after reload
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
timeout: TIMEOUTS.sectionVisible,
});
});
test('should display existing endpoints on initial load', async ({ page }) => {
// This test verifies that any existing endpoints are displayed when the page first loads
// Navigate to the page and check if we can see the endpoints section
await navigateToEndpointsTab(page);
// The endpoints tab should show either existing endpoints or the empty state
// The key is that it should NOT show "empty" if there are endpoints on the server
// Either we see "No endpoints configured" OR we see endpoint cards
const emptyState = page.locator('text=No endpoints configured');
const endpointCard = page.locator('[data-testid="endpoint-card"]').first();
// One of these should be visible
await expect(
Promise.race([
emptyState.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'empty'),
endpointCard.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'card'),
])
).resolves.toBeDefined();
});
});

View File

@@ -172,8 +172,13 @@ export type {
EventHookHttpMethod,
EventHookShellAction,
EventHookHttpAction,
EventHookNtfyAction,
EventHookAction,
EventHook,
EventHookContext,
// Ntfy notification types
NtfyAuthenticationType,
NtfyEndpointConfig,
// Feature template types
FeatureTemplate,
// Claude-compatible provider types (new)

View File

@@ -12,7 +12,9 @@ export type NotificationType =
| 'feature_waiting_approval'
| 'feature_verified'
| 'spec_regeneration_complete'
| 'agent_complete';
| 'agent_complete'
| 'feature_error'
| 'auto_mode_error';
/**
* Notification - A single notification entry

View File

@@ -747,6 +747,49 @@ export type EventHookTrigger =
/** HTTP methods supported for webhook requests */
export type EventHookHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
/**
* NtfyAuthenticationType - Authentication methods for ntfy.sh
*
* - 'none': No authentication (default for public topics)
* - 'basic': Username and password authentication
* - 'token': Access token authentication
*/
export type NtfyAuthenticationType = 'none' | 'basic' | 'token';
/**
* NtfyEndpointConfig - Configuration for a ntfy.sh notification endpoint
*
* Stores reusable ntfy.sh server configuration that can be referenced
* by multiple event hooks. Supports custom servers (self-hosted),
* authentication, and notification customization.
*/
export interface NtfyEndpointConfig {
/** Unique identifier for this endpoint configuration */
id: string;
/** Display name (e.g., "Personal Phone", "Team Channel") */
name: string;
/** Server URL (default: https://ntfy.sh) */
serverUrl: string;
/** Topic name (required, no spaces) */
topic: string;
/** Authentication type */
authType: NtfyAuthenticationType;
/** Username for basic auth (required if authType === 'basic') */
username?: string;
/** Password for basic auth (required if authType === 'basic') */
password?: string;
/** Access token (required if authType === 'token') */
token?: string;
/** Default tags for notifications (comma-separated emoji codes) */
defaultTags?: string;
/** Default emoji for notifications (emoji or shortcode) */
defaultEmoji?: string;
/** Default click action URL (auto-populated with server URL) */
defaultClickUrl?: string;
/** Whether this endpoint is enabled */
enabled: boolean;
}
/**
* EventHookShellAction - Configuration for executing a shell command
*
@@ -778,8 +821,32 @@ export interface EventHookHttpAction {
body?: string;
}
/**
* EventHookNtfyAction - Configuration for sending ntfy.sh push notifications
*
* Uses a pre-configured ntfy.sh endpoint from the global settings.
* Supports variable substitution in title and body.
*/
export interface EventHookNtfyAction {
type: 'ntfy';
/** ID of the NtfyEndpointConfig to use */
endpointId: string;
/** Notification title (supports {{variable}} substitution, defaults to event name) */
title?: string;
/** Notification body/message (supports {{variable}} substitution) */
body?: string;
/** Tags for this specific notification (comma-separated, overrides endpoint default) */
tags?: string;
/** Emoji for this specific notification (overrides endpoint default) */
emoji?: string;
/** Click action URL (overrides endpoint default, supports {{variable}} substitution) */
clickUrl?: string;
/** Priority level (1=min, 3=default, 5=max/urgent) */
priority?: 1 | 2 | 3 | 4 | 5;
}
/** Union type for all hook action configurations */
export type EventHookAction = EventHookShellAction | EventHookHttpAction;
export type EventHookAction = EventHookShellAction | EventHookHttpAction | EventHookNtfyAction;
/**
* EventHook - Configuration for a single event hook
@@ -818,6 +885,31 @@ export const EVENT_HOOK_TRIGGER_LABELS: Record<EventHookTrigger, string> = {
auto_mode_error: 'Auto mode paused due to error',
};
/**
* EventHookContext - Context variables available for substitution in event hooks
*
* These variables can be used in shell commands, HTTP bodies, and ntfy notifications
* using the {{variableName}} syntax.
*/
export interface EventHookContext {
/** ID of the feature (if applicable) */
featureId?: string;
/** Title/name of the feature (if applicable) */
featureName?: string;
/** Absolute path to the project */
projectPath?: string;
/** Name of the project (derived from path) */
projectName?: string;
/** Error message (only for error events) */
error?: string;
/** Error type/classification (only for error events) */
errorType?: string;
/** ISO timestamp when the event occurred */
timestamp: string;
/** The event type that triggered the hook */
eventType: EventHookTrigger;
}
const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
@@ -1398,6 +1490,14 @@ export interface GlobalSettings {
*/
eventHooks?: EventHook[];
// Ntfy.sh Notification Endpoints
/**
* Configured ntfy.sh notification endpoints for push notifications.
* These endpoints can be referenced by event hooks to send notifications.
* @see NtfyEndpointConfig for configuration details
*/
ntfyEndpoints?: NtfyEndpointConfig[];
// Feature Templates Configuration
/**
* Feature templates for quick task creation from the Add Feature dropdown
@@ -1823,6 +1923,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
subagentsSources: ['user', 'project'],
// Event hooks
eventHooks: [],
// Ntfy.sh notification endpoints
ntfyEndpoints: [],
// Feature templates
featureTemplates: DEFAULT_FEATURE_TEMPLATES,
// New provider system

View File

@@ -1,6 +1,6 @@
{
"name": "automaker",
"version": "0.15.0",
"version": "1.0.0",
"license": "MIT",
"private": true,
"engines": {

Submodule test/fixtures/projectA deleted from e2bcc1c966