diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51a6df9bf..80de52767 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -693,7 +693,7 @@ jobs: files: artifacts/release/* fail_on_unmatched_files: false draft: false - prerelease: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-RC') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 8c136325b..24eca450f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ Work\ Trees/ community-data/ .mcp.json specs/ +.maestro/ +maestro-cue.yaml # Tests coverage/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7650f41dd..29110bc8b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,6 +18,7 @@ Deep technical documentation for Maestro's architecture and design patterns. For - [Achievement System](#achievement-system) - [AI Tab System](#ai-tab-system) - [File Preview Tab System](#file-preview-tab-system) +- [Terminal Tab System](#terminal-tab-system) - [Execution Queue](#execution-queue) - [Navigation History](#navigation-history) - [Group Chat System](#group-chat-system) @@ -1116,6 +1117,54 @@ File tabs display a colored badge based on file extension. Colors are theme-awar --- +## Terminal Tab System + +Persistent PTY-backed terminal tabs that integrate into the unified tab bar alongside AI and file tabs. Built on xterm.js for full terminal emulation with ANSI support. + +### Features + +- **Persistent PTY**: Each tab spawns a dedicated PTY via `process:spawnTerminalTab` IPC — the shell stays alive between tab switches +- **xterm.js rendering**: Full terminal emulation via `XTerminal.tsx` (wraps `@xterm/xterm`); raw PTY data passes through unchanged +- **Multi-tab**: Multiple independent shells per agent; tabs are closable and renameable +- **State persistence**: `terminalTabs` array saved with the session; PTYs are re-spawned on restore +- **Spawn failure UX**: `state === 'exited' && pid === 0` shows an error overlay with a Retry button +- **Exit message**: PTY exit writes a yellow ANSI banner and new-terminal hint to the xterm buffer + +### Terminal Tab Interface + +```typescript +interface TerminalTab { + id: string; // Unique tab ID (UUID) + name: string; // Display name (custom or auto "Terminal N") + shellType: string; // Shell binary (e.g., "zsh", "bash") + cwd: string; // Working directory + pid: number; // PTY process ID (0 = not yet spawned) + state: 'idle' | 'running' | 'exited'; + exitCode: number | null; + createdAt: number; +} +``` + +### Session Fields + +```typescript +// In Session interface +terminalTabs: TerminalTab[]; // Array of terminal tabs +activeTerminalTabId: string | null; // Active terminal tab (null if not in terminal mode) +``` + +### Key Files + +| File | Purpose | +| --------------------------- | -------------------------------------------------------------------- | +| `XTerminal.tsx` | xterm.js wrapper; handles PTY data I/O and terminal lifecycle | +| `TerminalView.tsx` | Layout container; manages tab selection and spawn/exit state | +| `terminalTabHelpers.ts` | CRUD helpers (`createTerminalTab`, `addTerminalTab`, `closeTerminalTab`, etc.) | +| `tabStore.ts` | Zustand selectors for terminal tab state | +| `src/main/ipc/handlers/process.ts` | `process:spawnTerminalTab` IPC handler with SSH support | + +--- + ## Execution Queue Sequential message processing system that prevents race conditions when multiple operations target the same agent. diff --git a/CLAUDE-IPC.md b/CLAUDE-IPC.md index 1e9c9334b..aa12a746e 100644 --- a/CLAUDE-IPC.md +++ b/CLAUDE-IPC.md @@ -43,6 +43,7 @@ The `window.maestro` API exposes the following namespaces: - `history` - Per-agent execution history (see History API below) - `cli` - CLI activity detection for playbook runs - `tempfile` - Temporary file management for batch processing +- `cue` - Maestro Cue event-driven automation (see Cue API below) ## Analytics & Visualization @@ -74,6 +75,40 @@ window.maestro.history = { **AI Context Integration**: Use `getFilePath(sessionId)` to get the path to an agent's history file. This file can be passed directly to AI agents as context, giving them visibility into past completed tasks, decisions, and work patterns. +## Cue API + +Maestro Cue event-driven automation engine. Gated behind the `maestroCue` Encore Feature flag. + +```typescript +window.maestro.cue = { + // Query engine state + getStatus: () => Promise, + getActiveRuns: () => Promise, + getActivityLog: (limit?) => Promise, + + // Engine controls + enable: () => Promise, + disable: () => Promise, + + // Run management + stopRun: (runId) => Promise, + stopAll: () => Promise, + + // Session config management + refreshSession: (sessionId, projectRoot) => Promise, + + // YAML config file operations + readYaml: (projectRoot) => Promise, + writeYaml: (projectRoot, content) => Promise, + validateYaml: (content) => Promise<{ valid: boolean; errors: string[] }>, + + // Real-time updates + onActivityUpdate: (callback) => () => void, // Returns unsubscribe function +}; +``` + +**Events:** `cue:activityUpdate` is pushed from main process on subscription triggers, run completions, config reloads, and config removals. + ## Power Management - `power` - Sleep prevention: setEnabled, isEnabled, getStatus, addReason, removeReason diff --git a/CLAUDE-PATTERNS.md b/CLAUDE-PATTERNS.md index 2b4d39977..ec3e2494c 100644 --- a/CLAUDE-PATTERNS.md +++ b/CLAUDE-PATTERNS.md @@ -348,9 +348,9 @@ When adding a new Encore Feature, gate **all** access points: 6. **Hamburger menu** — Make the setter optional, conditionally render the menu item in `SessionList.tsx` 7. **Command palette** — Pass `undefined` for the handler in `QuickActionsModal.tsx` (already conditionally renders based on handler existence) -### Reference Implementation: Director's Notes +### Reference Implementations -Director's Notes is the first Encore Feature and serves as the canonical example: +**Director's Notes** — First Encore Feature, canonical example: - **Flag:** `encoreFeatures.directorNotes` in `EncoreFeatureFlags` - **App.tsx gating:** Modal render wrapped in `{encoreFeatures.directorNotes && directorNotesOpen && (…)}`, callback passed as `encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined` @@ -358,6 +358,15 @@ Director's Notes is the first Encore Feature and serves as the canonical example - **Hamburger menu:** `setDirectorNotesOpen` made optional in `SessionList.tsx`, button conditionally rendered with `{setDirectorNotesOpen && (…)}` - **Command palette:** `onOpenDirectorNotes` already conditionally renders in `QuickActionsModal.tsx` — passing `undefined` from App.tsx is sufficient +**Maestro Cue** — Event-driven automation, second Encore Feature: + +- **Flag:** `encoreFeatures.maestroCue` in `EncoreFeatureFlags` +- **App.tsx gating:** Cue modal, hooks (`useCue`, `useCueAutoDiscovery`), and engine lifecycle gated on `encoreFeatures.maestroCue` +- **Keyboard shortcut:** `ctx.encoreFeatures?.maestroCue` guard in `useMainKeyboardHandler.ts` +- **Hamburger menu:** `setMaestroCueOpen` made optional in `SessionList.tsx` +- **Command palette:** `onOpenMaestroCue` conditionally renders in `QuickActionsModal.tsx` +- **Session list:** Cue status indicator (Zap icon) gated on `maestroCueEnabled` + When adding a new Encore Feature, mirror this pattern across all access points. See [CONTRIBUTING.md → Encore Features](CONTRIBUTING.md#encore-features-feature-gating) for the full contributor guide. diff --git a/CLAUDE-WIZARD.md b/CLAUDE-WIZARD.md index ec1b1c9c2..aedb5b44b 100644 --- a/CLAUDE-WIZARD.md +++ b/CLAUDE-WIZARD.md @@ -38,7 +38,7 @@ src/renderer/components/Wizard/ 3. **Conversation** → AI asks clarifying questions, builds confidence score (0-100) 4. **Phase Review** → View/edit generated Phase 1 document, choose to start tour -When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `Auto Run Docs/Initiation/`. The `Initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. +When confidence reaches 80+ and agent signals "ready", user proceeds to Phase Review where Auto Run documents are generated and saved to `.maestro/playbooks/initiation/`. The `initiation/` subfolder keeps wizard-generated documents separate from user-created playbooks. ### Triggering the Wizard @@ -179,7 +179,7 @@ The Inline Wizard creates Auto Run Playbook documents from within an existing ag - Multiple wizards can run in different tabs simultaneously - Wizard state is **per-tab** (`AITab.wizardState`), not per-agent -- Documents written to unique subfolder under Auto Run folder (e.g., `Auto Run Docs/Project-Name/`) +- Documents written to unique subfolder under playbooks folder (e.g., `.maestro/playbooks/project-name/`) - On completion, tab renamed to "Project: {SubfolderName}" - Final AI message summarizes generated docs and next steps - Same `agentSessionId` preserved for context continuity diff --git a/CLAUDE.md b/CLAUDE.md index f5be8ee90..f5290a028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,11 @@ Use "agent" in user-facing language. Reserve "session" for provider-level conver - **Command Terminal** - Main window in terminal/shell mode - **System Log Viewer** - Special view for system logs (`LogViewer.tsx`) +### Automation + +- **Cue** — Event-driven automation system (Maestro Cue), gated as an Encore Feature. Watches for file changes, time intervals, agent completions, GitHub PRs/issues, and pending markdown tasks to trigger automated prompts. Configured via `.maestro/cue.yaml` per project. +- **Cue Modal** — Dashboard for managing Cue subscriptions and viewing activity (`CueModal.tsx`) + ### Agent States (color-coded) - **Green** - Ready/idle @@ -131,9 +136,10 @@ src/ │ ├── preload.ts # Secure IPC bridge │ ├── process-manager.ts # Process spawning (PTY + child_process) │ ├── agent-*.ts # Agent detection, capabilities, session storage +│ ├── cue/ # Maestro Cue event-driven automation engine │ ├── parsers/ # Per-agent output parsers + error patterns │ ├── storage/ # Per-agent session storage implementations -│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, etc.) +│ ├── ipc/handlers/ # IPC handler modules (stats, git, playbooks, cue, etc.) │ └── utils/ # Utilities (execFile, ssh-spawn-wrapper, etc.) │ ├── renderer/ # React frontend (desktop) @@ -203,6 +209,12 @@ src/ | Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | | Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | | Modify history components | `src/renderer/components/History/` | +| Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` | +| Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` | +| Modify Cue modal | `src/renderer/components/CueModal.tsx` | +| Configure Cue engine | `src/main/cue/cue-engine.ts`, `src/main/ipc/handlers/cue.ts` | +| Add terminal feature | `src/renderer/components/XTerminal.tsx`, `src/renderer/components/TerminalView.tsx` | +| Modify terminal tabs | `src/renderer/utils/terminalTabHelpers.ts`, `src/renderer/stores/tabStore.ts` | --- diff --git a/docs/assets/theme-hint.js b/docs/assets/theme-hint.js new file mode 100644 index 000000000..c2bbff3c4 --- /dev/null +++ b/docs/assets/theme-hint.js @@ -0,0 +1,31 @@ +/* global window, document, localStorage, URLSearchParams */ +/** + * Theme Hint Script for Maestro Docs + * + * When the Maestro app opens a docs URL with a ?theme= query parameter, + * this script sets the Mintlify theme to match. + * + * Supported values: ?theme=dark | ?theme=light + * + * Mintlify stores the user's theme preference in localStorage under the + * key "mintlify-color-scheme". Setting this key and dispatching a storage + * event causes Mintlify to switch themes without a page reload. + */ +(function () { + var params = new URLSearchParams(window.location.search); + var theme = params.get('theme'); + + if (theme === 'dark' || theme === 'light') { + // Mintlify reads this localStorage key for theme preference + try { + localStorage.setItem('mintlify-color-scheme', theme); + } catch { + // localStorage unavailable — ignore + } + + // Apply the class immediately to prevent flash of wrong theme + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(theme); + document.documentElement.style.colorScheme = theme; + } +})(); diff --git a/docs/autorun-playbooks.md b/docs/autorun-playbooks.md index 287b5939f..623fe7a6d 100644 --- a/docs/autorun-playbooks.md +++ b/docs/autorun-playbooks.md @@ -42,7 +42,7 @@ Auto Run supports running multiple documents in sequence: 2. Click **+ Add Docs** to add more documents to the queue 3. Drag to reorder documents as needed 4. Configure options per document: - - **Reset on Completion** - Creates a working copy in `Runs/` subfolder instead of modifying the original. The original document is never touched, and working copies (e.g., `TASK-1735192800000-loop-1.md`) serve as audit logs. + - **Reset on Completion** - Creates a working copy in `runs/` subfolder instead of modifying the original. The original document is never touched, and working copies (e.g., `TASK-1735192800000-loop-1.md`) serve as audit logs. - **Duplicate** - Add the same document multiple times 5. Enable **Loop Mode** to cycle back to the first document after completing the last 6. Click **Go** to start running documents diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 000000000..a0e618dd7 --- /dev/null +++ b/docs/deep-links.md @@ -0,0 +1,96 @@ +--- +title: Deep Links +description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications. +icon: link +--- + +# Deep Links + +Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks. + +## URL Format + +``` +maestro://[action]/[parameters] +``` + +### Available Actions + +| URL | Action | +| ------------------------------------------- | ------------------------------------------ | +| `maestro://focus` | Bring Maestro window to foreground | +| `maestro://session/{sessionId}` | Navigate to an agent | +| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent | +| `maestro://group/{groupId}` | Expand a group and focus its first agent | + +IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded. + +## Usage + +### From Terminal + +```bash +# macOS +open "maestro://session/abc123" +open "maestro://session/abc123/tab/def456" +open "maestro://group/my-group-id" +open "maestro://focus" + +# Linux +xdg-open "maestro://session/abc123" + +# Windows +start maestro://session/abc123 +``` + +### OS Notification Clicks + +When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab. + +This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings. + +### Template Variables + +Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents: + +| Variable | Description | Example Value | +| --------------------- | ---------------------------------------------- | ------------------------------------- | +| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` | +| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` | +| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` | + +These variables can be used in: + +- **System prompts** — give AI agents awareness of their own deep link for cross-referencing +- **Custom AI commands** — include deep links in generated output +- **Auto Run documents** — reference agents in batch automation workflows +- **Custom notification commands** — include deep links in TTS or logging scripts + +### From Scripts and External Tools + +Any application can launch Maestro deep links by opening the URL. This enables integrations like: + +- CI/CD pipelines that open a specific agent after deployment +- Shell scripts that navigate to a group after batch operations +- Alfred/Raycast workflows for quick agent access +- Bookmarks for frequently-used agents + +## Platform Behavior + +| Platform | Mechanism | +| ----------------- | ----------------------------------------------------------------------------- | +| **macOS** | `app.on('open-url')` delivers the URL to the running instance | +| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance | +| **Cold start** | URL is buffered and processed after the window is ready | + +Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one. + + +In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development. + + +## Related + +- [Configuration](./configuration) — OS notification settings +- [General Usage](./general-usage) — Core UI and workflow patterns +- [MCP Server](./mcp-server) — Connect AI applications to Maestro diff --git a/docs/docs.json b/docs/docs.json index e786b6ea5..4222d881a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -9,6 +9,7 @@ "href": "https://runmaestro.ai" }, "favicon": "/assets/icon.ico", + "js": "/assets/theme-hint.js", "colors": { "primary": "#BD93F9", "light": "#F8F8F2", @@ -52,8 +53,6 @@ "history", "context-management", "document-graph", - "usage-dashboard", - "symphony", "git-worktrees", "group-chat", "remote-control", @@ -74,7 +73,17 @@ { "group": "Encore Features", "icon": "flask", - "pages": ["encore-features", "director-notes"] + "pages": [ + "encore-features", + "director-notes", + "usage-dashboard", + "symphony", + "maestro-cue", + "maestro-cue-configuration", + "maestro-cue-events", + "maestro-cue-advanced", + "maestro-cue-examples" + ] }, { "group": "Providers & CLI", @@ -82,7 +91,7 @@ }, { "group": "Integrations", - "pages": ["mcp-server"], + "pages": ["mcp-server", "deep-links"], "icon": "plug" }, { diff --git a/docs/encore-features.md b/docs/encore-features.md index 9b4928de7..9775ebeb5 100644 --- a/docs/encore-features.md +++ b/docs/encore-features.md @@ -16,11 +16,12 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t ## Available Features -| Feature | Shortcut | Description | -| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | -| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | - -More features will be added here as they ship. +| Feature | Shortcut | Description | +| ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------ | +| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | +| [Usage Dashboard](./usage-dashboard) | `Opt+Cmd+U` / `Alt+Ctrl+U` | Comprehensive analytics for tracking AI usage patterns | +| [Maestro Symphony](./symphony) | `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Contribute to open source by donating AI tokens | +| [Maestro Cue](./maestro-cue) | `Cmd+Shift+E` / `Ctrl+Shift+E` | Event-driven automation: file changes, timers, agent chaining, GitHub polling, and task tracking | ## For Developers diff --git a/docs/features.md b/docs/features.md index 13dc13e80..c668b598c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -9,7 +9,7 @@ icon: sparkles - 🌳 **[Git Worktrees](./git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently — then create PRs with one click. True parallel development without conflicts. - 🤖 **[Auto Run & Playbooks](./autorun-playbooks)** - File-system-based task runner that processes markdown checklists through AI agents. Create Playbooks (collections of Auto Run documents) for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context. - 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more. -- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. +- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. _(Encore Feature — enable in Settings > Encore Features)_ - 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions. - 🌐 **[Remote Control](./remote-control)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. - 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance. @@ -34,7 +34,7 @@ icon: sparkles - 🎨 **[Beautiful Themes](https://github.com/RunMaestro/Maestro/blob/main/THEMES.md)** - 17 built-in themes across dark (Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark), light (GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light), and vibe (Pedurple, Maestro's Choice, Dre Synth, InQuest) categories, plus a fully customizable theme builder. - ⏱️ **[WakaTime Integration](./configuration#wakatime-integration)** - Automatic time tracking via WakaTime with optional per-file write activity tracking across all supported agents. - 💰 **Cost Tracking** - Real-time token usage and cost tracking per session and globally. -- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. +- 📊 **[Usage Dashboard](./usage-dashboard)** - Comprehensive analytics for tracking AI usage patterns. View aggregated statistics, compare agent performance, analyze activity heatmaps, and export data to CSV. Access via `Opt+Cmd+U` / `Alt+Ctrl+U`. _(Encore Feature — enable in Settings > Encore Features)_ - 🎬 **[Director's Notes](./director-notes)** - Bird's-eye view of all agent activity in a unified timeline. Aggregate history from every agent, search and filter entries, and generate AI-powered synopses of recent work. Access via `Cmd+Shift+O` / `Ctrl+Shift+O`. _(Encore Feature — enable in Settings > Encore Features)_ - 🏆 **[Achievements](./achievements)** - Level up from Apprentice to Titan of the Baton based on cumulative Auto Run time. 11 conductor-themed ranks to unlock. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e4ac14a0..cb3f4f5c0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,7 +31,7 @@ Press `Cmd+Shift+N` / `Ctrl+Shift+N` to launch the **Onboarding Wizard**, which ![Wizard Document Generation](./screenshots/wizard-doc-generation.png) -The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `Auto Run Docs/` to keep them organized separately from documents you create later. +The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `.maestro/playbooks/` to keep them organized separately from documents you create later. ### Introductory Tour diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index 7dc5e6918..960fe8cb4 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -121,6 +121,14 @@ The bulk close operations (Close All, Close Others, Close Left, Close Right) are | Page Up/Down | `Alt+Up/Down Arrow` while in output | | Jump to Top/Bottom | `Cmd+Up/Down Arrow` while in output | +## Font Size + +| Action | macOS | Windows/Linux | +| ------------------ | ------------- | -------------- | +| Increase Font Size | `Cmd+=` | `Ctrl+=` | +| Decrease Font Size | `Cmd+-` | `Ctrl+-` | +| Reset Font Size | `Cmd+Shift+0` | `Ctrl+Shift+0` | + ## Tab Completion (Command Terminal) The Command Terminal provides intelligent tab completion for faster command entry: diff --git a/docs/maestro-cue-advanced.md b/docs/maestro-cue-advanced.md new file mode 100644 index 000000000..05cd6945d --- /dev/null +++ b/docs/maestro-cue-advanced.md @@ -0,0 +1,372 @@ +--- +title: Cue Advanced Patterns +description: Fan-in/fan-out, payload filtering, agent chaining, template variables, and concurrency control. +icon: diagram-project +--- + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings. This guide covers the advanced features that enable complex multi-agent workflows. + +## Fan-Out + +Fan-out sends a single trigger's prompt to multiple target agents simultaneously. Use this when one event should kick off parallel work across several agents. + +**How it works:** Add a `fan_out` field with a list of agent names. When the trigger fires, Cue spawns a run against each target agent. + +```yaml +subscriptions: + - name: parallel-deploy + event: agent.completed + source_session: 'build-agent' + fan_out: + - 'deploy-staging' + - 'deploy-production' + - 'deploy-docs' + prompt: | + Build completed. Deploy the latest artifacts. + Source output: {{CUE_SOURCE_OUTPUT}} +``` + +In this example, when `build-agent` finishes, Cue sends the same prompt to three different agents in parallel. + +**Notes:** + +- Each fan-out target runs independently — failures in one don't affect others +- All targets receive the same prompt with the same template variable values +- Fan-out targets must be agent names visible in the Left Bar +- Fan-out respects `max_concurrent` — if slots are full, excess runs are queued + +## Fan-In + +Fan-in waits for **multiple** source agents to complete before firing a single trigger. Use this to coordinate work that depends on several agents finishing first. + +**How it works:** Set `source_session` to a list of agent names. Cue waits for all of them to complete before firing the subscription. + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + - 'api-tests' + prompt: | + All prerequisite agents have completed. + Run the full integration test suite with `npm run test:integration`. + +settings: + timeout_minutes: 60 # Wait up to 60 minutes for all sources + timeout_on_fail: continue # Fire anyway if timeout is reached +``` + +**Behavior:** + +- Cue tracks completions from each source agent independently +- The subscription fires only when **all** listed sources have completed +- If `timeout_on_fail` is `'continue'`, the subscription fires with partial data after the timeout +- If `timeout_on_fail` is `'break'` (default), the subscription is marked as timed out and does not fire +- Completion tracking resets after the subscription fires + +## Filtering + +Filters let you conditionally trigger subscriptions based on event payload data. All filter conditions are AND'd — every condition must pass for the subscription to fire. + +### Filter Syntax + +Filters are key-value pairs where the key is a payload field name and the value is an expression: + +```yaml +filter: + field_name: expression +``` + +### Expression Types + +| Expression | Meaning | Example | +| -------------- | --------------------- | ---------------------- | +| `"value"` | Exact string match | `extension: ".ts"` | +| `123` | Exact numeric match | `exitCode: 0` | +| `true`/`false` | Exact boolean match | `draft: false` | +| `"!value"` | Negation (not equal) | `status: "!failed"` | +| `">=N"` | Greater than or equal | `taskCount: ">=3"` | +| `">N"` | Greater than | `durationMs: ">60000"` | +| `"<=N"` | Less than or equal | `exitCode: "<=1"` | +| `"=3' + prompt: | + {{CUE_TASK_COUNT}} tasks are pending. Work through them in priority order. +``` + +**Skip files in test directories:** + +```yaml +- name: lint-src-only + event: file.changed + watch: '**/*.ts' + filter: + path: '!**/test/**' + prompt: Lint {{CUE_FILE_PATH}}. +``` + +## Agent Chaining + +Agent chaining connects multiple agents in a pipeline where each agent's completion triggers the next. This is built on `agent.completed` events with optional filtering. + +### Simple Chain + +```yaml +subscriptions: + # Step 1: Lint + - name: lint + event: file.changed + watch: 'src/**/*.ts' + prompt: Run the linter on {{CUE_FILE_PATH}}. + + # Step 2: Test (after lint passes) + - name: test-after-lint + event: agent.completed + source_session: 'lint-agent' + filter: + exitCode: 0 + prompt: Lint passed. Run the related test suite. + + # Step 3: Build (after tests pass) + - name: build-after-test + event: agent.completed + source_session: 'test-agent' + filter: + exitCode: 0 + prompt: Tests passed. Build the project with `npm run build`. +``` + +### Diamond Pattern + +Combine fan-out and fan-in for complex workflows: + +``` + ┌─── backend-build ───┐ +trigger ──┤ ├── integration-tests + └─── frontend-build ──┘ +``` + +```yaml +subscriptions: + # Fan-out: trigger both builds + - name: parallel-builds + event: file.changed + watch: 'src/**/*' + fan_out: + - 'backend-agent' + - 'frontend-agent' + prompt: Source changed. Rebuild your component. + + # Fan-in: wait for both, then test + - name: integration-tests + event: agent.completed + source_session: + - 'backend-agent' + - 'frontend-agent' + prompt: Both builds complete. Run integration tests. +``` + +## Template Variables + +All prompts support `{{VARIABLE}}` syntax. Variables are replaced with event payload data before the prompt is sent to the agent. + +### Common Variables (All Events) + +| Variable | Description | +| ------------------------- | ------------------------------ | +| `{{CUE_EVENT_TYPE}}` | Event type that triggered this | +| `{{CUE_EVENT_TIMESTAMP}}` | ISO 8601 timestamp | +| `{{CUE_TRIGGER_NAME}}` | Subscription name | +| `{{CUE_RUN_ID}}` | Unique run UUID | + +### File Variables (`file.changed`, `task.pending`) + +| Variable | Description | +| -------------------------- | -------------------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute file path | +| `{{CUE_FILE_NAME}}` | Filename only | +| `{{CUE_FILE_DIR}}` | Directory path | +| `{{CUE_FILE_EXT}}` | Extension (with dot) | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type: `add`, `change`, `unlink` | + +### Task Variables (`task.pending`) + +| Variable | Description | +| ------------------------ | --------------------------------------- | +| `{{CUE_TASK_FILE}}` | File path with pending tasks | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | +| `{{CUE_TASK_FILE_DIR}}` | Directory path | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks | +| `{{CUE_TASK_LIST}}` | Formatted list (line number: task text) | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K) | + +### Agent Variables (`agent.completed`) + +| Variable | Description | +| ----------------------------- | --------------------------------------------- | +| `{{CUE_SOURCE_SESSION}}` | Source agent name(s) | +| `{{CUE_SOURCE_OUTPUT}}` | Source agent output (truncated to 5K) | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Subscription that triggered the source run | + +### GitHub Variables (`github.pull_request`, `github.issue`) + +| Variable | Description | PR | Issue | +| ------------------------ | --------------------------- | --- | ----- | +| `{{CUE_GH_TYPE}}` | `pull_request` or `issue` | Y | Y | +| `{{CUE_GH_NUMBER}}` | PR/issue number | Y | Y | +| `{{CUE_GH_TITLE}}` | Title | Y | Y | +| `{{CUE_GH_AUTHOR}}` | Author login | Y | Y | +| `{{CUE_GH_URL}}` | HTML URL | Y | Y | +| `{{CUE_GH_BODY}}` | Body text (truncated) | Y | Y | +| `{{CUE_GH_LABELS}}` | Labels (comma-separated) | Y | Y | +| `{{CUE_GH_STATE}}` | State (`open` / `closed`) | Y | Y | +| `{{CUE_GH_REPO}}` | Repository (`owner/repo`) | Y | Y | +| `{{CUE_GH_BRANCH}}` | Head branch | Y | | +| `{{CUE_GH_BASE_BRANCH}}` | Base branch | Y | | +| `{{CUE_GH_ASSIGNEES}}` | Assignees (comma-separated) | | Y | + +### Standard Variables + +Cue prompts also have access to all standard Maestro template variables (like `{{PROJECT_ROOT}}`, `{{TIMESTAMP}}`, etc.) — the same variables available in Auto Run playbooks and system prompts. + +## Concurrency Control + +Control how many Cue-triggered runs can execute simultaneously and how overflow events are handled. + +### max_concurrent + +Limits parallel runs per agent. When all slots are occupied, new events are queued. + +```yaml +settings: + max_concurrent: 3 # Up to 3 runs at once +``` + +**Range:** 1–10. **Default:** 1 (serial execution). + +With `max_concurrent: 1` (default), events are processed one at a time in order. This is the safest setting — it prevents agents from receiving overlapping prompts. + +Increase `max_concurrent` when your subscriptions are independent and don't conflict with each other (e.g., reviewing different PRs, scanning different files). + +### queue_size + +Controls how many events can wait when all concurrent slots are full. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +**Range:** 0–50. **Default:** 10. + +- Events beyond the queue limit are **dropped** (silently discarded) +- Set to `0` to disable queuing — events that can't run immediately are discarded +- The current queue depth is visible in the Cue Modal's sessions table + +### Timeout + +Prevents runaway agents from blocking the pipeline. + +```yaml +settings: + timeout_minutes: 45 # Kill runs after 45 minutes + timeout_on_fail: continue # Let downstream subscriptions proceed anyway +``` + +**`timeout_on_fail` options:** + +- `break` (default) — Timed-out runs are marked as failed. Downstream `agent.completed` subscriptions see the failure. +- `continue` — Timed-out runs are stopped, but downstream subscriptions still fire with whatever data is available. Useful for fan-in patterns where you'd rather proceed with partial results than block the entire pipeline. + +## Sleep/Wake Reconciliation + +Cue handles system sleep gracefully: + +- **`time.heartbeat`** subscriptions reconcile missed intervals on wake. If your machine sleeps through three intervals, Cue fires one catch-up event (not three). +- **File watchers** (`file.changed`, `task.pending`) resume monitoring on wake. Changes that occurred during sleep may trigger events depending on the OS file system notification behavior. +- **GitHub pollers** resume polling on wake. Any PRs/issues created during sleep are detected on the next poll. + +The engine uses a heartbeat mechanism to detect sleep periods. This is transparent — no configuration needed. + +## Persistence + +Cue persists its state in a local SQLite database: + +- **Event journal** — Records all events (completed, failed, timed out) for the Activity Log +- **GitHub seen tracking** — Remembers which PRs/issues have already triggered events (30-day retention) +- **Heartbeat** — Tracks engine uptime for sleep/wake detection + +Events older than 7 days are automatically pruned to keep the database lean. diff --git a/docs/maestro-cue-configuration.md b/docs/maestro-cue-configuration.md new file mode 100644 index 000000000..68825674f --- /dev/null +++ b/docs/maestro-cue-configuration.md @@ -0,0 +1,263 @@ +--- +title: Cue Configuration Reference +description: Complete YAML schema reference for .maestro/cue.yaml configuration files. +icon: file-code +--- + +Cue is configured via a `.maestro/cue.yaml` file placed inside the `.maestro/` directory at your project root. The engine watches this file for changes and hot-reloads automatically. + +## File Location + +``` +your-project/ +├── .maestro/ +│ └── cue.yaml # Cue configuration +├── src/ +├── package.json +└── ... +``` + +Maestro discovers this file automatically when the Cue Encore Feature is enabled. Each agent that has a `.maestro/cue.yaml` in its project root gets its own independent Cue engine instance. + +## Full Schema + +```yaml +# Subscriptions define trigger-prompt pairings +subscriptions: + - name: string # Required. Unique identifier for this subscription + event: string # Required. Event type (see Event Types) + enabled: boolean # Optional. Default: true + prompt: string # Required. Prompt text or path to a .md file + + # Event-specific fields + interval_minutes: number # Required for time.heartbeat + schedule_times: list # Required for time.scheduled (HH:MM strings) + schedule_days: list # Optional for time.scheduled (mon, tue, wed, thu, fri, sat, sun) + watch: string # Required for file.changed, task.pending (glob pattern) + source_session: string | list # Required for agent.completed + fan_out: list # Optional. Target session names for fan-out + filter: object # Optional. Payload field conditions + repo: string # Optional for github.* (auto-detected if omitted) + poll_minutes: number # Optional for github.*, task.pending + +# Global settings (all optional — sensible defaults applied) +settings: + timeout_minutes: number # Default: 30. Max run duration before timeout + timeout_on_fail: string # Default: 'break'. What to do on timeout: 'break' or 'continue' + max_concurrent: number # Default: 1. Simultaneous runs (1-10) + queue_size: number # Default: 10. Max queued events (0-50) +``` + +## Subscriptions + +Each subscription is a trigger-prompt pairing. When the trigger fires, Cue sends the prompt to the agent. + +### Required Fields + +| Field | Type | Description | +| -------- | ------ | ---------------------------------------------------------------------- | +| `name` | string | Unique identifier. Used in logs, history, and as a reference in chains | +| `event` | string | One of the seven [event types](./maestro-cue-events) | +| `prompt` | string | The prompt to send, either inline text or a path to a `.md` file | + +### Optional Fields + +| Field | Type | Default | Description | +| ------------------ | --------------- | ------- | ----------------------------------------------------------------------- | +| `enabled` | boolean | `true` | Set to `false` to pause a subscription without removing it | +| `interval_minutes` | number | — | Timer interval. Required for `time.heartbeat` | +| `schedule_times` | list of strings | — | Times in `HH:MM` format. Required for `time.scheduled` | +| `schedule_days` | list of strings | — | Days of week (`mon`–`sun`). Optional for `time.scheduled` | +| `watch` | string (glob) | — | File glob pattern. Required for `file.changed`, `task.pending` | +| `source_session` | string or list | — | Source agent name(s). Required for `agent.completed` | +| `fan_out` | list of strings | — | Target agent names to fan out to | +| `filter` | object | — | Payload conditions (see [Filtering](./maestro-cue-advanced#filtering)) | +| `repo` | string | — | GitHub repo (`owner/repo`). Auto-detected from git remote | +| `poll_minutes` | number | varies | Poll interval for `github.*` (default 5) and `task.pending` (default 1) | + +### Prompt Field + +The `prompt` field accepts either inline text or a file path: + +**Inline prompt:** + +```yaml +prompt: | + Please lint the file {{CUE_FILE_PATH}} and fix any errors. +``` + +**File reference:** + +```yaml +prompt: prompts/lint-check.md +``` + +File paths are resolved relative to the project root. Prompt files support the same `{{VARIABLE}}` template syntax as inline prompts. + +### Disabling Subscriptions + +Set `enabled: false` to pause a subscription without deleting it: + +```yaml +subscriptions: + - name: nightly-report + event: time.heartbeat + interval_minutes: 1440 + enabled: false # Paused — won't fire until re-enabled + prompt: Generate a daily summary report. +``` + +## Settings + +The optional `settings` block configures global engine behavior. All fields have sensible defaults — you only need to include settings you want to override. + +### timeout_minutes + +**Default:** `30` | **Type:** positive number + +Maximum duration (in minutes) for a single Cue-triggered run. If an agent takes longer than this, the run is terminated. + +```yaml +settings: + timeout_minutes: 60 # Allow up to 1 hour per run +``` + +### timeout_on_fail + +**Default:** `'break'` | **Type:** `'break'` or `'continue'` + +What happens when a run times out: + +- **`break`** — Stop the run and mark it as failed. No further processing for this event. +- **`continue`** — Stop the run but allow downstream subscriptions (in fan-in chains) to proceed with partial data. + +```yaml +settings: + timeout_on_fail: continue # Don't block the pipeline on slow agents +``` + +### max_concurrent + +**Default:** `1` | **Type:** integer, 1–10 + +Maximum number of Cue-triggered runs that can execute simultaneously for this agent. Additional events are queued. + +```yaml +settings: + max_concurrent: 3 # Allow up to 3 parallel runs +``` + +### queue_size + +**Default:** `10` | **Type:** integer, 0–50 + +Maximum number of events that can be queued when all concurrent slots are occupied. Events beyond this limit are dropped. + +Set to `0` to disable queueing — events that can't run immediately are discarded. + +```yaml +settings: + queue_size: 20 # Buffer up to 20 events +``` + +## Validation + +The engine validates your YAML on every load. Common validation errors: + +| Error | Fix | +| --------------------------------------- | ------------------------------------------------------------ | +| `"name" is required` | Every subscription needs a unique `name` field | +| `"event" is required` | Specify one of the seven event types | +| `"prompt" is required` | Provide inline text or a file path | +| `"interval_minutes" is required` | `time.heartbeat` events must specify a positive interval | +| `"schedule_times" is required` | `time.scheduled` events must have at least one `HH:MM` time | +| `"watch" is required` | `file.changed` and `task.pending` events need a glob pattern | +| `"source_session" is required` | `agent.completed` events need the name of the source agent | +| `"max_concurrent" must be between 1-10` | Keep concurrent runs within the allowed range | +| `"queue_size" must be between 0-50` | Keep queue size within the allowed range | +| `filter key must be string/number/bool` | Filter values only accept primitive types | + +The inline YAML editor in the Cue Modal shows validation errors in real-time as you type. + +## Complete Example + +A realistic configuration demonstrating multiple event types working together: + +```yaml +subscriptions: + # Lint TypeScript files on save + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + filter: + extension: '.ts' + prompt: | + The file {{CUE_FILE_PATH}} was modified. + Run `npx eslint {{CUE_FILE_PATH}} --fix` and report any remaining issues. + + # Run tests every 30 minutes + - name: periodic-tests + event: time.heartbeat + interval_minutes: 30 + prompt: | + Run the test suite with `npm test`. + If any tests fail, investigate and fix them. + + # Morning standup on weekdays + - name: morning-standup + event: time.scheduled + schedule_times: + - '09:00' + schedule_days: + - mon + - tue + - wed + - thu + - fri + prompt: | + Generate a standup report from recent git activity. + + # Review new PRs automatically + - name: pr-review + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + prompt: | + A new PR needs review: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Please review this PR for code quality, potential bugs, and style issues. + + # Work on pending tasks from TODO.md + - name: task-worker + event: task.pending + watch: 'TODO.md' + poll_minutes: 5 + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the highest priority task and complete it. + When done, check off the task in the file. + + # Chain: deploy after tests pass + - name: deploy-after-tests + event: agent.completed + source_session: 'test-runner' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed successfully. Deploy to staging with `npm run deploy:staging`. + +settings: + timeout_minutes: 45 + max_concurrent: 2 + queue_size: 15 +``` diff --git a/docs/maestro-cue-events.md b/docs/maestro-cue-events.md new file mode 100644 index 000000000..fc0288dcd --- /dev/null +++ b/docs/maestro-cue-events.md @@ -0,0 +1,378 @@ +--- +title: Cue Event Types +description: Detailed reference for all seven Maestro Cue event types with configuration, payloads, and examples. +icon: calendar-check +--- + +Cue supports seven event types. Each type watches for a different kind of activity and produces a payload that can be injected into prompts via [template variables](./maestro-cue-advanced#template-variables). + +## time.heartbeat + +Fires on a periodic timer. The subscription triggers immediately when the engine starts, then repeats at the configured interval. + +**Required fields:** + +| Field | Type | Description | +| ------------------ | ------ | -------------------------------------- | +| `interval_minutes` | number | Minutes between triggers (must be > 0) | + +**Behavior:** + +- Fires immediately on engine start (or when the subscription is first loaded) +- Reconciles missed intervals after system sleep — if your machine sleeps through one or more intervals, Cue fires a catch-up event on wake +- The interval resets after each trigger, not after each run completes + +**Example:** + +```yaml +subscriptions: + - name: hourly-summary + event: time.heartbeat + interval_minutes: 60 + prompt: | + Generate a summary of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"` and organize by author. +``` + +**Payload fields:** None specific to this event type. Use common variables like `{{CUE_TRIGGER_NAME}}` and `{{CUE_EVENT_TIMESTAMP}}`. + +--- + +## time.scheduled + +Fires at specific times and days of the week — a cron-like trigger for precise scheduling. + +**Required fields:** + +| Field | Type | Description | +| ---------------- | -------- | ------------------------------------------------ | +| `schedule_times` | string[] | Array of times in `HH:MM` format (24-hour clock) | + +**Optional fields:** + +| Field | Type | Default | Description | +| --------------- | -------- | --------- | ------------------------------------------------------------------------ | +| `schedule_days` | string[] | every day | Days of the week to run: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun` | + +**Behavior:** + +- Checks every 60 seconds if the current time matches any `schedule_times` entry +- If `schedule_days` is set, the current day must also match +- Does **not** fire immediately on engine start (unlike `time.heartbeat`) +- Multiple times per day are supported — add multiple entries to `schedule_times` + +**Example — weekday standup:** + +```yaml +subscriptions: + - name: morning-standup + event: time.scheduled + schedule_times: + - '09:00' + schedule_days: + - mon + - tue + - wed + - thu + - fri + prompt: | + Generate a standup report: + 1. Run `git log --oneline --since="yesterday"` to find recent changes + 2. Check for any failing tests + 3. Summarize what was accomplished and what's next +``` + +**Example — multiple times daily:** + +```yaml +subscriptions: + - name: status-check + event: time.scheduled + schedule_times: + - '09:00' + - '13:00' + - '17:00' + prompt: | + Run a quick health check on all services. +``` + +**Payload fields:** + +| Field | Description | Example | +| -------------- | ------------------------------------- | ------- | +| `matched_time` | The scheduled time that matched | `09:00` | +| `matched_day` | The day of the week when it triggered | `mon` | + +--- + +## file.changed + +Fires when files matching a glob pattern are created, modified, or deleted. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------- | +| `watch` | string (glob) | Glob pattern for files to monitor | + +**Behavior:** + +- Monitors for `add`, `change`, and `unlink` (delete) events +- Debounces by 5 seconds per file — rapid saves to the same file produce a single event +- The glob is evaluated relative to the project root +- Standard glob syntax: `*` matches within a directory, `**` matches across directories + +**Example:** + +```yaml +subscriptions: + - name: test-on-change + event: file.changed + watch: 'src/**/*.{ts,tsx}' + filter: + changeType: '!unlink' # Don't trigger on file deletions + prompt: | + The file {{CUE_FILE_PATH}} was {{CUE_EVENT_TYPE}}. + Run the tests related to this file and report results. +``` + +**Payload fields:** + +| Variable | Description | Example | +| -------------------------- | --------------------------------- | ------------------------- | +| `{{CUE_FILE_PATH}}` | Absolute path to the changed file | `/project/src/app.ts` | +| `{{CUE_FILE_NAME}}` | Filename only | `app.ts` | +| `{{CUE_FILE_DIR}}` | Directory containing the file | `/project/src` | +| `{{CUE_FILE_EXT}}` | File extension (with dot) | `.ts` | +| `{{CUE_FILE_CHANGE_TYPE}}` | Change type | `add`, `change`, `unlink` | + +The `changeType` field is also available in [filters](./maestro-cue-advanced#filtering). + +--- + +## agent.completed + +Fires when another Maestro agent finishes a task. This is the foundation for agent chaining — building multi-step pipelines where one agent's completion triggers the next. + +**Required fields:** + +| Field | Type | Description | +| ---------------- | -------------- | ----------------------------------------------- | +| `source_session` | string or list | Name(s) of the agent(s) to watch for completion | + +**Behavior:** + +- **Single source** (string): Fires immediately when the named agent completes +- **Multiple sources** (list): Waits for **all** named agents to complete before firing (fan-in). See [Fan-In](./maestro-cue-advanced#fan-in) +- The source agent's output is captured and available via `{{CUE_SOURCE_OUTPUT}}` (truncated to 5,000 characters) +- Matches agent names as shown in the Left Bar + +**Example — single source:** + +```yaml +subscriptions: + - name: deploy-after-build + event: agent.completed + source_session: 'builder' + filter: + exitCode: 0 # Only deploy if build succeeded + prompt: | + The build agent completed successfully. + Output: {{CUE_SOURCE_OUTPUT}} + + Deploy to staging with `npm run deploy:staging`. +``` + +**Example — fan-in (multiple sources):** + +```yaml +subscriptions: + - name: integration-tests + event: agent.completed + source_session: + - 'backend-build' + - 'frontend-build' + prompt: | + Both builds completed. Run the full integration test suite. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ----------------------------- | ------------------------------------------------------ | ----------------- | +| `{{CUE_SOURCE_SESSION}}` | Name of the completing agent(s) | `builder` | +| `{{CUE_SOURCE_OUTPUT}}` | Truncated stdout from the source (max 5K chars) | `Build succeeded` | +| `{{CUE_SOURCE_STATUS}}` | Run status (`completed`, `failed`, `timeout`) | `completed` | +| `{{CUE_SOURCE_EXIT_CODE}}` | Process exit code | `0` | +| `{{CUE_SOURCE_DURATION}}` | Run duration in milliseconds | `15000` | +| `{{CUE_SOURCE_TRIGGERED_BY}}` | Name of the subscription that triggered the source run | `lint-on-save` | + +These fields are also available in [filters](./maestro-cue-advanced#filtering). + +The `triggeredBy` field is particularly useful when a source agent has multiple Cue subscriptions but you only want to chain from a specific one. See [Selective Chaining](./maestro-cue-examples#selective-chaining-with-triggeredby) for a complete example. + +--- + +## task.pending + +Watches markdown files for unchecked task items (`- [ ]`) and fires when pending tasks are found. + +**Required fields:** + +| Field | Type | Description | +| ------- | ------------- | --------------------------------------- | +| `watch` | string (glob) | Glob pattern for markdown files to scan | + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | --------------------------------- | +| `poll_minutes` | number | 1 | Minutes between scans (minimum 1) | + +**Behavior:** + +- Scans files matching the glob pattern at the configured interval +- Fires when unchecked tasks (`- [ ]`) are found +- Only fires when the task list changes (new tasks appear or existing ones are modified) +- The full task list is formatted and available via `{{CUE_TASK_LIST}}` +- File content (truncated to 10K characters) is available via `{{CUE_TASK_CONTENT}}` + +**Example:** + +```yaml +subscriptions: + - name: todo-worker + event: task.pending + watch: '**/*.md' + poll_minutes: 5 + prompt: | + Found {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the most important task and complete it. + When finished, mark it as done by changing `- [ ]` to `- [x]`. +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | ------------------------------------------ | ---------------------- | +| `{{CUE_TASK_FILE}}` | Path to the file containing tasks | `/project/TODO.md` | +| `{{CUE_TASK_FILE_NAME}}` | Filename only | `TODO.md` | +| `{{CUE_TASK_FILE_DIR}}` | Directory containing the file | `/project` | +| `{{CUE_TASK_COUNT}}` | Number of pending tasks found | `3` | +| `{{CUE_TASK_LIST}}` | Formatted list with line numbers | `L5: Write unit tests` | +| `{{CUE_TASK_CONTENT}}` | Full file content (truncated to 10K chars) | _(file contents)_ | + +--- + +## github.pull_request + +Polls GitHub for new pull requests using the GitHub CLI (`gh`). + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------------------------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format. Auto-detected from git remote if omitted | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +- Requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated +- On first run, seeds the "seen" list with existing PRs — only **new** PRs trigger events +- Tracks seen PRs in a local database with 30-day retention +- Auto-detects the repository from the git remote if `repo` is not specified + +**Example:** + +```yaml +subscriptions: + - name: pr-reviewer + event: github.pull_request + poll_minutes: 3 + filter: + draft: false # Skip draft PRs + base_branch: main # Only PRs targeting main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + Labels: {{CUE_GH_LABELS}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review this PR for: + 1. Code quality and style consistency + 2. Potential bugs or edge cases + 3. Test coverage +``` + +**Payload fields:** + +| Variable | Description | Example | +| ------------------------ | --------------------------------- | ------------------------------------- | +| `{{CUE_GH_TYPE}}` | Always `pull_request` | `pull_request` | +| `{{CUE_GH_NUMBER}}` | PR number | `42` | +| `{{CUE_GH_TITLE}}` | PR title | `Add user authentication` | +| `{{CUE_GH_AUTHOR}}` | Author's GitHub login | `octocat` | +| `{{CUE_GH_URL}}` | HTML URL to the PR | `https://github.com/org/repo/pull/42` | +| `{{CUE_GH_BODY}}` | PR description (truncated) | _(PR body text)_ | +| `{{CUE_GH_LABELS}}` | Comma-separated label names | `bug, priority-high` | +| `{{CUE_GH_STATE}}` | PR state | `open` | +| `{{CUE_GH_BRANCH}}` | Head (source) branch | `feature/auth` | +| `{{CUE_GH_BASE_BRANCH}}` | Base (target) branch | `main` | +| `{{CUE_GH_REPO}}` | Repository in `owner/repo` format | `RunMaestro/Maestro` | + +--- + +## github.issue + +Polls GitHub for new issues using the GitHub CLI (`gh`). Behaves identically to `github.pull_request` but for issues. + +**Optional fields:** + +| Field | Type | Default | Description | +| -------------- | ------ | ------- | ---------------------------------- | +| `repo` | string | auto | GitHub repo in `owner/repo` format | +| `poll_minutes` | number | 5 | Minutes between polls (minimum 1) | + +**Behavior:** + +Same as `github.pull_request` — requires GitHub CLI, seeds on first run, tracks seen issues. + +**Example:** + +```yaml +subscriptions: + - name: issue-triage + event: github.issue + poll_minutes: 5 + filter: + labels: '!wontfix' # Skip issues labeled wontfix + prompt: | + New issue: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Assignees: {{CUE_GH_ASSIGNEES}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the area of the codebase affected + 2. Estimate complexity (small/medium/large) + 3. Suggest which team member should handle it +``` + +**Payload fields:** + +Same as `github.pull_request`, except: + +| Variable | Description | Example | +| ---------------------- | ------------------------------- | ------------ | +| `{{CUE_GH_TYPE}}` | Always `issue` | `issue` | +| `{{CUE_GH_ASSIGNEES}}` | Comma-separated assignee logins | `alice, bob` | + +The branch-specific variables (`{{CUE_GH_BRANCH}}`, `{{CUE_GH_BASE_BRANCH}}`) are not available for issues. diff --git a/docs/maestro-cue-examples.md b/docs/maestro-cue-examples.md new file mode 100644 index 000000000..ba29e58fe --- /dev/null +++ b/docs/maestro-cue-examples.md @@ -0,0 +1,462 @@ +--- +title: Cue Examples +description: Real-world Maestro Cue configurations for common automation workflows. +icon: lightbulb +--- + +Complete, copy-paste-ready `.maestro/cue.yaml` configurations for common workflows. Each example is self-contained — drop it into your project's `.maestro/` directory and adjust agent names to match your Left Bar. + +## CI-Style Pipeline + +Lint, test, and deploy in sequence. Each step only runs if the previous one succeeded. + +**Agents needed:** `linter`, `tester`, `deployer` + +The `linter` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.{ts,tsx}' + prompt: | + Run `npx eslint {{CUE_FILE_PATH}} --fix`. + Report any errors that couldn't be auto-fixed. +``` + +The `tester` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: test-after-lint + event: agent.completed + source_session: 'linter' + filter: + status: completed + exitCode: 0 + prompt: | + Lint passed. Run `npm test` and report results. +``` + +The `deployer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: deploy-after-tests + event: agent.completed + source_session: 'tester' + filter: + status: completed + exitCode: 0 + prompt: | + Tests passed. Deploy to staging with `npm run deploy:staging`. +``` + +--- + +## Scheduled Automation + +Run prompts at specific times and days using `time.scheduled`. Unlike `time.heartbeat` (which fires every N minutes), scheduled triggers fire at exact clock times. + +**Agent needed:** `ops` + +```yaml +subscriptions: + # Morning standup report on weekdays + - name: morning-standup + event: time.scheduled + schedule_times: + - '09:00' + schedule_days: + - mon + - tue + - wed + - thu + - fri + prompt: | + Generate a standup report: + 1. Run `git log --oneline --since="yesterday"` for recent changes + 2. Check for any open PRs needing review + 3. Summarize what was done and what's next + + # End-of-day summary at 5 PM on weekdays + - name: eod-summary + event: time.scheduled + schedule_times: + - '17:00' + schedule_days: + - mon + - tue + - wed + - thu + - fri + prompt: | + Generate an end-of-day summary with today's commits and open items. + + # Weekend maintenance at midnight Saturday + - name: weekend-maintenance + event: time.scheduled + schedule_times: + - '00:00' + schedule_days: + - sat + prompt: | + Run maintenance tasks: + 1. Clean up old build artifacts + 2. Update dependencies with `npm outdated` + 3. Generate a dependency report +``` + +--- + +## Selective Chaining with triggeredBy + +When an agent has multiple subscriptions but only one should chain to another agent, use the `triggeredBy` filter. This field contains the subscription name that triggered the completing run. + +**Agents needed:** `worker` (has multiple cue subscriptions), `reviewer` + +The `worker` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + # This one should NOT trigger the reviewer + - name: routine-cleanup + event: time.heartbeat + interval_minutes: 60 + prompt: Run `npm run clean` and remove stale build artifacts. + + # This one should NOT trigger the reviewer either + - name: lint-check + event: file.changed + watch: 'src/**/*.ts' + prompt: Lint {{CUE_FILE_PATH}}. + + # Only THIS one should trigger the reviewer + - name: implement-feature + event: github.issue + filter: + labels: 'enhancement' + prompt: | + New feature request: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + {{CUE_GH_BODY}} + + Implement this feature following existing patterns. +``` + +The `reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: review-new-feature + event: agent.completed + source_session: 'worker' + filter: + triggeredBy: 'implement-feature' # Only chains from this specific subscription + status: completed + prompt: | + The worker just implemented a feature. Review the changes: + + {{CUE_SOURCE_OUTPUT}} + + Check for: + 1. Code quality and consistency + 2. Missing test coverage + 3. Documentation gaps +``` + +The `triggeredBy` filter also supports glob patterns: `triggeredBy: "implement-*"` matches any subscription name starting with `implement-`. + +--- + +## Research Swarm + +Fan out a question to multiple agents, then fan in to synthesize results. + +**Agents needed:** `coordinator`, `researcher-a`, `researcher-b`, `researcher-c` + +The `coordinator` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + # Fan-out: send the research question to all researchers + - name: dispatch-research + event: file.changed + watch: 'research-question.md' + fan_out: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + Research the following question from different angles. + File: {{CUE_FILE_PATH}} + + Read the file and provide a thorough analysis. + + # Fan-in: synthesize when all researchers finish + - name: synthesize-results + event: agent.completed + source_session: + - 'researcher-a' + - 'researcher-b' + - 'researcher-c' + prompt: | + All researchers have completed their analysis. + + Combined outputs: + {{CUE_SOURCE_OUTPUT}} + + Synthesize these perspectives into a single coherent report. + Highlight agreements, contradictions, and key insights. + +settings: + timeout_minutes: 60 + timeout_on_fail: continue # Synthesize with partial results if someone times out +``` + +--- + +## PR Review with Targeted Follow-Up + +Auto-review new PRs, then selectively notify a security reviewer only for PRs that touch auth code. + +**Agents needed:** `pr-reviewer`, `security-reviewer` + +The `pr-reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: review-all-prs + event: github.pull_request + poll_minutes: 3 + filter: + draft: false + base_branch: main + prompt: | + New PR: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + {{CUE_GH_BODY}} + + Review for code quality, bugs, and style. + In your output, list all files changed. +``` + +The `security-reviewer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: security-review + event: agent.completed + source_session: 'pr-reviewer' + filter: + triggeredBy: 'review-all-prs' + status: completed + prompt: | + A PR was just reviewed. Check if any auth/security-sensitive files were changed: + + {{CUE_SOURCE_OUTPUT}} + + If auth, session, or permission-related code was modified: + 1. Audit the changes for security vulnerabilities + 2. Check for injection, XSS, or auth bypass risks + 3. Verify proper input validation + + If no security-sensitive files were changed, respond with "No security review needed." +``` + +--- + +## TODO Task Queue + +Watch a markdown file for unchecked tasks and work through them sequentially. + +**Agents needed:** `task-worker` + +```yaml +subscriptions: + - name: work-todos + event: task.pending + watch: 'TODO.md' + poll_minutes: 2 + filter: + taskCount: '>=1' + prompt: | + There are {{CUE_TASK_COUNT}} pending tasks in {{CUE_TASK_FILE}}: + + {{CUE_TASK_LIST}} + + Pick the FIRST unchecked task and complete it. + When done, change `- [ ]` to `- [x]` in the file. + Do NOT work on more than one task at a time. + +settings: + max_concurrent: 1 # Serial execution — one task at a time +``` + +--- + +## Multi-Environment Deploy + +Fan out deployments to staging, production, and docs after a build passes. + +**Agents needed:** `builder`, `deploy-staging`, `deploy-prod`, `deploy-docs` + +The `builder` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: build-on-push + event: file.changed + watch: 'src/**/*' + prompt: | + Source files changed. Run a full build with `npm run build`. + Report success or failure. +``` + +Any agent with visibility to `builder` (e.g., `deploy-staging`): + +```yaml +subscriptions: + - name: fan-out-deploy + event: agent.completed + source_session: 'builder' + filter: + triggeredBy: 'build-on-push' + exitCode: 0 + fan_out: + - 'deploy-staging' + - 'deploy-prod' + - 'deploy-docs' + prompt: | + Build succeeded. Deploy your target environment. + Build output: {{CUE_SOURCE_OUTPUT}} +``` + +--- + +## Issue Triage Bot + +Auto-triage new GitHub issues by labeling and assigning them. + +**Agents needed:** `triage-bot` + +```yaml +subscriptions: + - name: triage-issues + event: github.issue + poll_minutes: 5 + filter: + state: open + labels: '!triaged' # Skip already-triaged issues + prompt: | + New issue needs triage: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Labels: {{CUE_GH_LABELS}} + + {{CUE_GH_BODY}} + + Triage this issue: + 1. Identify the component/area affected + 2. Estimate complexity (small / medium / large) + 3. Suggest priority (P0-P3) + 4. Recommend an assignee based on the area + 5. Run `gh issue edit {{CUE_GH_NUMBER}} --add-label "triaged"` to mark as triaged +``` + +--- + +## Debate Pattern + +Two agents analyze a problem independently, then a third synthesizes their perspectives. + +**Agents needed:** `advocate`, `critic`, `judge` + +The config that triggers the debate (on any agent with visibility): + +```yaml +subscriptions: + - name: start-debate + event: file.changed + watch: 'debate-topic.md' + fan_out: + - 'advocate' + - 'critic' + prompt: | + Read {{CUE_FILE_PATH}} and analyze the proposal. + + You are assigned a role — argue from that perspective: + - advocate: argue IN FAVOR, highlight benefits and opportunities + - critic: argue AGAINST, highlight risks and weaknesses + + Be thorough and specific. +``` + +The `judge` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: synthesize-debate + event: agent.completed + source_session: + - 'advocate' + - 'critic' + prompt: | + Both sides of the debate have been presented. + + Arguments: + {{CUE_SOURCE_OUTPUT}} + + As the judge: + 1. Summarize each side's strongest points + 2. Identify where they agree and disagree + 3. Render a verdict with your reasoning + 4. Propose a path forward that addresses both perspectives + +settings: + timeout_minutes: 45 + timeout_on_fail: continue +``` + +--- + +## Scheduled Report with Conditional Chain + +Generate an hourly report, but only notify a summary agent when there's meaningful activity. + +**Agents needed:** `reporter`, `summarizer` + +The `reporter` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: hourly-git-report + event: time.heartbeat + interval_minutes: 60 + prompt: | + Generate a report of git activity in the last hour. + Run `git log --oneline --since="1 hour ago"`. + + If there are commits, format them as a structured report. + If there are no commits, respond with exactly: "NO_ACTIVITY" +``` + +The `summarizer` agent's `.maestro/cue.yaml`: + +```yaml +subscriptions: + - name: summarize-activity + event: agent.completed + source_session: 'reporter' + filter: + triggeredBy: 'hourly-git-report' + status: completed + prompt: | + The hourly reporter just finished. Here's its output: + + {{CUE_SOURCE_OUTPUT}} + + If the output says "NO_ACTIVITY", respond with "Nothing to summarize." + Otherwise, create a concise executive summary of the development activity. +``` diff --git a/docs/maestro-cue.md b/docs/maestro-cue.md new file mode 100644 index 000000000..9e5347c4a --- /dev/null +++ b/docs/maestro-cue.md @@ -0,0 +1,175 @@ +--- +title: Maestro Cue +description: Event-driven automation that triggers agent prompts in response to file changes, timers, agent completions, GitHub activity, and pending tasks. +icon: bolt +--- + +Maestro Cue is an event-driven automation engine that watches for things happening in your projects and automatically sends prompts to your agents in response. Instead of manually kicking off tasks, you define **subscriptions** — trigger-prompt pairings — in a YAML file, and Cue handles the rest. + + +Maestro Cue is an **Encore Feature** — it's disabled by default. Enable it in **Settings > Encore Features** to access the shortcut, modal, and automation engine. + + +## What Can Cue Do? + +A few examples of what you can automate with Cue: + +- **Run linting whenever TypeScript files change** — watch `src/**/*.ts` and prompt an agent to lint on every save +- **Generate a morning standup** — schedule at 9:00 AM on weekdays to scan recent git activity and draft a report +- **Chain agents together** — when your build agent finishes, automatically trigger a test agent, then a deploy agent +- **Triage new GitHub PRs** — poll for new pull requests and prompt an agent to review the diff +- **Track TODO progress** — scan markdown files for unchecked tasks and prompt an agent to work on the next one +- **Fan out deployments** — when a build completes, trigger multiple deploy agents simultaneously + +## Enabling Cue + +1. Open **Settings** (`Cmd+,` / `Ctrl+,`) +2. Navigate to the **Encore Features** tab +3. Toggle **Maestro Cue** on + +Once enabled, Maestro automatically scans all your active agents for `.maestro/cue.yaml` files in their project roots. The Cue engine starts immediately — no restart required. + +## Quick Start + +Create a file called `.maestro/cue.yaml` in your project (inside the `.maestro/` directory at the project root): + +```yaml +subscriptions: + - name: lint-on-save + event: file.changed + watch: 'src/**/*.ts' + prompt: | + The file {{CUE_FILE_PATH}} was just modified. + Please run the linter and fix any issues. +``` + +That's it. Whenever a `.ts` file in `src/` changes, Cue sends that prompt to the agent with the file path filled in automatically. + +## The Cue Modal + +Open the Cue dashboard to monitor and manage all automation activity. + +**Keyboard shortcut:** + +- macOS: `Option+Q` +- Windows/Linux: `Alt+Q` + +**From Quick Actions:** + +- Press `Cmd+K` / `Ctrl+K` and search for "Maestro Cue" + +### Sessions Table + +The primary view shows all agents that have a `.maestro/cue.yaml` file: + + + +| Column | Description | +| ------------------ | ------------------------------------------------ | +| **Session** | Agent name | +| **Agent** | Provider type (Claude Code, Codex, etc.) | +| **Status** | Green dot = active, yellow = paused, gray = none | +| **Last Triggered** | How long ago the most recent event fired | +| **Subs** | Number of subscriptions in the YAML | +| **Queue** | Events waiting to be processed | +| **Edit** | Opens the inline YAML editor for that agent | + +### Active Runs + +Shows currently executing Cue-triggered prompts with elapsed time and which subscription triggered them. + +### Activity Log + +A chronological record of completed and failed runs. Each entry shows: + +- Subscription name and event type +- Status (completed, failed, timeout, stopped) +- Duration +- Timestamp + +### YAML Editor + +Click the edit button on any session row to open the inline YAML editor. Changes are validated in real-time — errors appear immediately so you can fix them before saving. The engine hot-reloads your config automatically when the file changes. + +### Help + +Built-in reference guide accessible from the modal header. Covers configuration syntax, event types, and template variables. + +## Configuration File + +Cue is configured via a `.maestro/cue.yaml` file placed inside the `.maestro/` directory at your project root. See the [Configuration Reference](./maestro-cue-configuration) for the complete YAML schema. + +## Event Types + +Cue supports seven event types that trigger subscriptions: + +| Event Type | Trigger | Key Fields | +| --------------------- | ----------------------------------- | --------------------------------- | +| `time.heartbeat` | Periodic timer ("every N minutes") | `interval_minutes` | +| `time.scheduled` | Specific times and days of the week | `schedule_times`, `schedule_days` | +| `file.changed` | File created, modified, or deleted | `watch` (glob pattern) | +| `agent.completed` | Another agent finishes a task | `source_session` | +| `task.pending` | Unchecked markdown tasks found | `watch` (glob pattern) | +| `github.pull_request` | New PR opened on GitHub | `repo` (optional) | +| `github.issue` | New issue opened on GitHub | `repo` (optional) | + +See [Event Types](./maestro-cue-events) for detailed documentation and examples for each type. + +## Template Variables + +Prompts support `{{VARIABLE}}` syntax for injecting event data. When Cue fires a subscription, it replaces template variables with the actual event payload before sending the prompt to the agent. + +```yaml +prompt: | + A new PR was opened: {{CUE_GH_TITLE}} (#{{CUE_GH_NUMBER}}) + Author: {{CUE_GH_AUTHOR}} + Branch: {{CUE_GH_BRANCH}} -> {{CUE_GH_BASE_BRANCH}} + URL: {{CUE_GH_URL}} + + Please review this PR and provide feedback. +``` + +See [Advanced Patterns](./maestro-cue-advanced) for the complete template variable reference. + +## Advanced Features + +Cue supports sophisticated automation patterns beyond simple trigger-prompt pairings: + +- **[Fan-out](./maestro-cue-advanced#fan-out)** — One trigger fires against multiple target agents simultaneously +- **[Fan-in](./maestro-cue-advanced#fan-in)** — Wait for multiple agents to complete before triggering +- **[Payload filtering](./maestro-cue-advanced#filtering)** — Conditionally trigger based on event data (glob matching, comparisons, negation) +- **[Agent chaining](./maestro-cue-advanced#agent-chaining)** — Build multi-step pipelines where each agent's output feeds the next +- **[Concurrency control](./maestro-cue-advanced#concurrency-control)** — Limit simultaneous runs and queue overflow events + +See [Advanced Patterns](./maestro-cue-advanced) for full documentation. + +## Keyboard Shortcuts + +| Shortcut | Action | +| -------------------- | -------------- | +| `Option+Q` / `Alt+Q` | Open Cue Modal | +| `Esc` | Close modal | + +## History Integration + +Cue-triggered runs appear in the History panel with a teal **CUE** badge. Each entry records: + +- The subscription name that triggered it +- The event type +- The source session (for agent completion chains) + +Filter by CUE entries in the History panel or in Director's Notes (when both Encore Features are enabled) to isolate automated activity from manual work. + +## Requirements + +- **GitHub CLI (`gh`)** — Required only for `github.pull_request` and `github.issue` events. Must be installed and authenticated (`gh auth login`). +- **File watching** — `file.changed` and `task.pending` events use filesystem watchers. No additional dependencies required. + +## Tips + +- **Start simple** — Begin with a single `file.changed` or `time.heartbeat` subscription before building complex chains +- **Use the YAML editor** — The inline editor validates your config in real-time, catching errors before they reach the engine +- **Check the Activity Log** — If a subscription isn't firing, the activity log shows failures with error details +- **Prompt files vs inline** — For complex prompts, point the `prompt` field at a `.md` file instead of inlining YAML +- **Hot reload** — The engine watches `.maestro/cue.yaml` for changes and reloads automatically — no need to restart Maestro +- **Template variables** — Use `{{CUE_TRIGGER_NAME}}` in prompts so the agent knows which automation triggered it diff --git a/docs/openspec-commands.md b/docs/openspec-commands.md index c9d0ab57b..34b1a217b 100644 --- a/docs/openspec-commands.md +++ b/docs/openspec-commands.md @@ -83,7 +83,7 @@ Bridges OpenSpec with Maestro's Auto Run: 1. Reads the proposal and tasks from a change 2. Converts tasks into Auto Run document format with phases -3. Saves to `Auto Run Docs/` with task checkboxes (filename: `OpenSpec--Phase-XX-[Description].md`) +3. Saves to `.maestro/playbooks/` with task checkboxes (filename: `OpenSpec--Phase-XX-[Description].md`) 4. Preserves task IDs (T001, T002, etc.) for traceability 5. Groups related tasks into logical phases (5–15 tasks each) diff --git a/docs/releases.md b/docs/releases.md index 98003e301..faebfb92d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -289,7 +289,6 @@ The big changes in the v0.12.x line are the following three: Minor bugfixes on top of v0.7.3: # Onboarding, Wizard, and Tours - - Implemented comprehensive onboarding wizard with integrated tour system 🚀 - Added project-understanding confidence display to wizard UI 🎨 - Enhanced keyboard navigation across all wizard screens ⌨️ @@ -297,7 +296,6 @@ Minor bugfixes on top of v0.7.3: - Added First Run Celebration modal with confetti animation 🎉 # UI / UX Enhancements - - Added expand-to-fullscreen button for Auto Run interface 🖥️ - Created dedicated modal component and improved modal priority constants for expanded Auto Run view 📐 - Enhanced user experience with fullscreen editing capabilities ✨ @@ -307,18 +305,15 @@ Minor bugfixes on top of v0.7.3: - Enhanced toast context with agent name for OS notifications 📢 # Auto Run Workflow Improvements - - Created phase document generation for Auto Run workflow 📄 - Added real-time log streaming to the LogViewer component 📊 # Application Behavior / Core Fixes - - Added validation to prevent nested worktrees inside the main repository 🚫 - Fixed process manager to properly emit exit events on errors 🔧 - Fixed process exit handling to ensure proper cleanup 🧹 # Update System - - Implemented automatic update checking on application startup 🚀 - Added settings toggle for enabling/disabling startup update checks ⚙️ @@ -509,7 +504,6 @@ Plus the prerelease ALPHA... All releases are available on the [GitHub Releases page](https://github.com/RunMaestro/Maestro/releases). Maestro is available for: - - **macOS** - Apple Silicon (arm64) and Intel (x64) - **Windows** - x64 - **Linux** - x64 and arm64, AppImage, deb, and rpm packages diff --git a/docs/speckit-commands.md b/docs/speckit-commands.md index 707bbd9f3..7ecc9917b 100644 --- a/docs/speckit-commands.md +++ b/docs/speckit-commands.md @@ -12,14 +12,14 @@ Spec-Kit is a structured specification workflow from [GitHub's spec-kit project] Maestro offers two paths to structured development: -| Feature | Spec-Kit | Onboarding Wizard | -| -------------------- | ------------------------------------------ | --------------------------- | -| **Approach** | Manual, command-driven workflow | Guided, conversational flow | -| **Best For** | Experienced users, complex projects | New users, quick setup | -| **Output** | Constitution, specs, tasks → Auto Run docs | Phase 1 Auto Run document | -| **Control** | Full control at each step | Streamlined, opinionated | -| **Learning Curve** | Moderate | Low | -| **Storage Location** | `.specify/` directory in project root | `Auto Run Docs/Initiation/` | +| Feature | Spec-Kit | Onboarding Wizard | +| -------------------- | ------------------------------------------ | -------------------------------- | +| **Approach** | Manual, command-driven workflow | Guided, conversational flow | +| **Best For** | Experienced users, complex projects | New users, quick setup | +| **Output** | Constitution, specs, tasks → Auto Run docs | Phase 1 Auto Run document | +| **Control** | Full control at each step | Streamlined, opinionated | +| **Learning Curve** | Moderate | Low | +| **Storage Location** | `.specify/` directory in project root | `.maestro/playbooks/Initiation/` | **Use Spec-Kit when:** @@ -98,11 +98,11 @@ Each task has an ID (T001, T002...), optional `[P]` marker for parallelizable ta **Maestro-specific command.** Converts your tasks into Auto Run documents that Maestro can execute autonomously. This bridges spec-kit's structured approach with Maestro's multi-agent capabilities. -**Creates:** Markdown documents in `Auto Run Docs/` with naming pattern: +**Creates:** Markdown documents in `.maestro/playbooks/` with naming pattern: ``` -Auto Run Docs/SpecKit--Phase-01-[Description].md -Auto Run Docs/SpecKit--Phase-02-[Description].md +.maestro/playbooks/SpecKit--Phase-01-[Description].md +.maestro/playbooks/SpecKit--Phase-02-[Description].md ``` Each phase document is self-contained, includes Spec Kit context references, preserves task IDs (T001, T002...) and user story markers ([US1], [US2]) for traceability. diff --git a/e2e/autorun-batch.spec.ts b/e2e/autorun-batch.spec.ts index 334f38efd..0c32cd1c8 100644 --- a/e2e/autorun-batch.spec.ts +++ b/e2e/autorun-batch.spec.ts @@ -33,7 +33,7 @@ test.describe('Auto Run Batch Processing', () => { test.beforeEach(async () => { // Create a temporary project directory testProjectDir = path.join(os.tmpdir(), `maestro-batch-test-${Date.now()}`); - testAutoRunFolder = path.join(testProjectDir, 'Auto Run Docs'); + testAutoRunFolder = path.join(testProjectDir, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder, { recursive: true }); // Create test markdown files with tasks diff --git a/e2e/autorun-editing.spec.ts b/e2e/autorun-editing.spec.ts index 92d73149d..ba9ba908a 100644 --- a/e2e/autorun-editing.spec.ts +++ b/e2e/autorun-editing.spec.ts @@ -33,7 +33,7 @@ test.describe('Auto Run Editing', () => { test.beforeEach(async () => { // Create a temporary project directory testProjectDir = path.join(os.tmpdir(), `maestro-test-project-${Date.now()}`); - testAutoRunFolder = path.join(testProjectDir, 'Auto Run Docs'); + testAutoRunFolder = path.join(testProjectDir, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder, { recursive: true }); // Create test markdown files diff --git a/e2e/autorun-sessions.spec.ts b/e2e/autorun-sessions.spec.ts index 7183842bc..15feb6fe6 100644 --- a/e2e/autorun-sessions.spec.ts +++ b/e2e/autorun-sessions.spec.ts @@ -37,8 +37,8 @@ test.describe('Auto Run Session Switching', () => { const timestamp = Date.now(); testProjectDir1 = path.join(os.tmpdir(), `maestro-session-test-1-${timestamp}`); testProjectDir2 = path.join(os.tmpdir(), `maestro-session-test-2-${timestamp}`); - testAutoRunFolder1 = path.join(testProjectDir1, 'Auto Run Docs'); - testAutoRunFolder2 = path.join(testProjectDir2, 'Auto Run Docs'); + testAutoRunFolder1 = path.join(testProjectDir1, '.maestro/playbooks'); + testAutoRunFolder2 = path.join(testProjectDir2, '.maestro/playbooks'); fs.mkdirSync(testAutoRunFolder1, { recursive: true }); fs.mkdirSync(testAutoRunFolder2, { recursive: true }); diff --git a/e2e/autorun-setup.spec.ts b/e2e/autorun-setup.spec.ts index 92c219f51..233abd8a0 100644 --- a/e2e/autorun-setup.spec.ts +++ b/e2e/autorun-setup.spec.ts @@ -190,11 +190,11 @@ test.describe('Auto Run Setup Wizard', () => { }); test.describe('Document Creation Flow', () => { - test.skip('should create Auto Run Docs folder in project', async ({ window }) => { + test.skip('should create .maestro/playbooks folder in project', async ({ window }) => { // This test requires completing the wizard flow // Would verify: // 1. Complete all wizard steps - // 2. 'Auto Run Docs' folder is created in project + // 2. '.maestro/playbooks' folder is created in project // 3. Initial documents are created }); diff --git a/e2e/fixtures/electron-app.ts b/e2e/fixtures/electron-app.ts index 3aa153ebe..feb07f77b 100644 --- a/e2e/fixtures/electron-app.ts +++ b/e2e/fixtures/electron-app.ts @@ -360,7 +360,7 @@ export const helpers = { * Create an Auto Run test folder with sample documents */ createAutoRunTestFolder(basePath: string): string { - const autoRunFolder = path.join(basePath, 'Auto Run Docs'); + const autoRunFolder = path.join(basePath, '.maestro/playbooks'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create sample documents @@ -496,7 +496,7 @@ More content for the second phase. * Create an Auto Run test folder with batch processing test documents */ createBatchTestFolder(basePath: string): string { - const autoRunFolder = path.join(basePath, 'Auto Run Docs'); + const autoRunFolder = path.join(basePath, '.maestro/playbooks'); fs.mkdirSync(autoRunFolder, { recursive: true }); // Create documents with varying task counts @@ -647,8 +647,8 @@ All tasks complete in this document. * Create test folders for multiple sessions with unique content */ createMultiSessionTestFolders(basePath: string): { session1: string; session2: string } { - const session1Path = path.join(basePath, 'session1', 'Auto Run Docs'); - const session2Path = path.join(basePath, 'session2', 'Auto Run Docs'); + const session1Path = path.join(basePath, 'session1', '.maestro/playbooks'); + const session2Path = path.join(basePath, 'session2', '.maestro/playbooks'); fs.mkdirSync(session1Path, { recursive: true }); fs.mkdirSync(session2Path, { recursive: true }); diff --git a/package-lock.json b/package-lock.json index 7482623e1..f7084d559 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.16.0", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -21,6 +21,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", @@ -264,6 +270,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -667,6 +674,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -710,6 +718,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2283,6 +2292,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2304,6 +2314,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2316,6 +2327,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2331,6 +2343,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -2718,6 +2731,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2734,6 +2748,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -2751,6 +2766,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3809,8 +3825,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4348,6 +4363,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4359,6 +4375,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4484,6 +4501,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -4877,6 +4895,45 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-unicode11": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -4914,6 +4971,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4995,6 +5053,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5998,6 +6057,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6480,6 +6540,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7205,6 +7266,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7614,6 +7676,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8111,6 +8174,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -8206,8 +8270,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.0", @@ -8351,7 +8414,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -8365,7 +8427,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -8385,7 +8446,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -8408,7 +8468,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8425,7 +8484,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -8442,7 +8500,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -8457,7 +8514,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8473,7 +8529,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -8486,8 +8541,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": { "version": "1.1.1", @@ -8495,7 +8549,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8506,7 +8559,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -8517,7 +8569,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -8533,7 +8584,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -9215,6 +9265,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11134,6 +11185,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11954,6 +12006,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12423,16 +12476,14 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -12445,8 +12496,7 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -12460,8 +12510,7 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -12475,8 +12524,7 @@ "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -12567,7 +12615,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15065,6 +15112,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15305,7 +15353,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -15321,7 +15368,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -15666,6 +15712,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15695,6 +15742,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -15742,6 +15790,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15928,7 +15977,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17685,6 +17735,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17995,6 +18046,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18368,6 +18420,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -18873,6 +18926,7 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -19463,6 +19517,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19476,6 +19531,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -20073,6 +20129,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f12544e11..b7b07b66c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro", - "version": "0.15.2", + "version": "0.16.0", "description": "Maestro hones fractured attention into focused intent.", "main": "dist/main/index.js", "author": { @@ -61,6 +61,14 @@ "npmRebuild": false, "appId": "com.maestro.app", "productName": "Maestro", + "protocols": [ + { + "name": "Maestro", + "schemes": [ + "maestro" + ] + } + ], "publish": { "provider": "github", "owner": "RunMaestro", @@ -225,6 +233,12 @@ "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.0.5", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "adm-zip": "^0.5.16", "ansi-to-html": "^0.7.2", "archiver": "^7.0.1", @@ -240,9 +254,11 @@ "electron-updater": "^6.6.2", "fastify": "^4.25.2", "js-tiktoken": "^1.0.21", + "js-yaml": "^4.1.1", "marked": "^17.0.1", "mermaid": "^11.12.1", "node-pty": "^1.1.0", + "picomatch": "^4.0.3", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", @@ -268,7 +284,9 @@ "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", "@types/electron-devtools-installer": "^2.2.5", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.10.6", + "@types/picomatch": "^4.0.2", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 3bd3a852a..55f655e2e 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -93,7 +93,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Hello world', - undefined + undefined, + { readOnlyMode: undefined } ); const output = JSON.parse(consoleSpy.mock.calls[0][0]); @@ -141,7 +142,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Continue from before', - 'session-xyz-789' + 'session-xyz-789', + { readOnlyMode: undefined } ); const output = JSON.parse(consoleSpy.mock.calls[0][0]); @@ -166,7 +168,8 @@ describe('send command', () => { 'claude-code', '/custom/project/path', 'Do something', - undefined + undefined, + { readOnlyMode: undefined } ); }); @@ -185,7 +188,30 @@ describe('send command', () => { await send('agent-codex', 'Use codex', {}); expect(detectAgent).toHaveBeenCalledWith('codex'); - expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined); + expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined, { + readOnlyMode: undefined, + }); + }); + + it('should pass readOnlyMode when --read-only flag is set', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); + vi.mocked(getSessionById).mockReturnValue(mockAgent()); + vi.mocked(detectAgent).mockResolvedValue({ available: true, path: '/usr/bin/claude' }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'Read-only response', + agentSessionId: 'session-ro', + }); + + await send('agent-abc', 'Analyze this code', { readOnly: true }); + + expect(spawnAgent).toHaveBeenCalledWith( + 'claude-code', + '/path/to/project', + 'Analyze this code', + undefined, + { readOnlyMode: true } + ); }); it('should exit with error when agent ID is not found', async () => { diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index d888907dc..548d90500 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -1163,6 +1163,42 @@ Some text with [x] in it that's not a checkbox } }); + it('should include read-only args for Claude when readOnlyMode is true', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + // Should include Claude's read-only args from centralized definitions + expect(args).toContain('--permission-mode'); + expect(args).toContain('plan'); + // Should still have base args + expect(args).toContain('--print'); + expect(args).toContain('--dangerously-skip-permissions'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should not include read-only args when readOnlyMode is false', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + expect(args).not.toContain('--permission-mode'); + expect(args).not.toContain('plan'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + it('should generate unique session-id for each spawn', async () => { // First spawn const promise1 = spawnAgent('claude-code', '/project', 'prompt1'); diff --git a/src/__tests__/integration/symphony.integration.test.ts b/src/__tests__/integration/symphony.integration.test.ts index 0963fd47b..1ba156454 100644 --- a/src/__tests__/integration/symphony.integration.test.ts +++ b/src/__tests__/integration/symphony.integration.test.ts @@ -1986,7 +1986,7 @@ error: failed to push some refs to 'https://github.com/owner/protected-repo.git' // Test paths with spaces - common in user-created directories const pathsWithSpaces = [ 'docs/my document.md', - 'Auto Run Docs/task 1.md', + '.maestro/playbooks/task 1.md', 'path with spaces/sub folder/file.md', ' leading-spaces.md', // Leading spaces 'trailing-spaces.md ', // Trailing spaces (may be trimmed) diff --git a/src/__tests__/main/autorun-folder-validation.test.ts b/src/__tests__/main/autorun-folder-validation.test.ts index aefad5018..ed7b19835 100644 --- a/src/__tests__/main/autorun-folder-validation.test.ts +++ b/src/__tests__/main/autorun-folder-validation.test.ts @@ -256,8 +256,8 @@ describe('Auto Run Folder Validation', () => { }); it('should handle paths with spaces', () => { - const folderPath = '/test/Auto Run Docs'; - const filePath = '/test/Auto Run Docs/My Document.md'; + const folderPath = '/test/.maestro/playbooks'; + const filePath = '/test/.maestro/playbooks/My Document.md'; expect(validatePathWithinFolder(filePath, folderPath)).toBe(true); }); diff --git a/src/__tests__/main/autorun-ipc.test.ts b/src/__tests__/main/autorun-ipc.test.ts index 5a4f0d206..213da88d1 100644 --- a/src/__tests__/main/autorun-ipc.test.ts +++ b/src/__tests__/main/autorun-ipc.test.ts @@ -8,7 +8,7 @@ * - autorun:listImages - list images for a document * - autorun:saveImage - save image with timestamp naming * - autorun:deleteImage - delete image file - * - autorun:deleteFolder - delete Auto Run Docs folder + * - autorun:deleteFolder - delete .maestro/playbooks folder * - autorun:createBackup - create backup copy of document for reset-on-completion * - autorun:restoreBackup - restore document from backup and delete backup file * - autorun:deleteBackups - delete all backup files in folder recursively @@ -961,12 +961,12 @@ describe('Auto Run IPC Handlers', () => { describe('autorun:deleteFolder', () => { describe('successful operations', () => { - it('should delete Auto Run Docs folder recursively', async () => { + it('should delete .maestro/playbooks folder recursively', async () => { mockStat.mockResolvedValue({ isDirectory: () => true }); mockRm.mockResolvedValue(undefined); const projectPath = '/test/project'; - const autoRunFolder = path.join(projectPath, 'Auto Run Docs'); + const autoRunFolder = path.join(projectPath, '.maestro/playbooks'); await mockStat(autoRunFolder); await mockRm(autoRunFolder, { recursive: true, force: true }); @@ -984,12 +984,13 @@ describe('Auto Run IPC Handlers', () => { }); describe('path validation', () => { - it('should only delete Auto Run Docs folder', () => { + it('should only delete playbooks folder', () => { + const ALLOWED_FOLDER_NAMES = new Set(['playbooks', 'Auto Run Docs']); const validateFolderName = (folderPath: string): boolean => { - return path.basename(folderPath) === 'Auto Run Docs'; + return ALLOWED_FOLDER_NAMES.has(path.basename(folderPath)); }; - expect(validateFolderName('/project/Auto Run Docs')).toBe(true); + expect(validateFolderName('/project/.maestro/playbooks')).toBe(true); expect(validateFolderName('/project/Documents')).toBe(false); expect(validateFolderName('/project/node_modules')).toBe(false); }); @@ -1011,8 +1012,8 @@ describe('Auto Run IPC Handlers', () => { it('should return error for non-directory path', async () => { mockStat.mockResolvedValue({ isDirectory: () => false }); - const result = { success: false, error: 'Auto Run Docs path is not a directory' }; - expect(result.error).toBe('Auto Run Docs path is not a directory'); + const result = { success: false, error: '.maestro/playbooks path is not a directory' }; + expect(result.error).toBe('.maestro/playbooks path is not a directory'); }); it('should return error for rm failure', async () => { @@ -1020,19 +1021,20 @@ describe('Auto Run IPC Handlers', () => { mockRm.mockRejectedValue(new Error('EACCES: permission denied')); await expect( - mockRm('/protected/Auto Run Docs', { recursive: true, force: true }) + mockRm('/protected/.maestro/playbooks', { recursive: true, force: true }) ).rejects.toThrow('EACCES'); }); it('should fail safety check for wrong folder name', () => { + const ALLOWED_FOLDER_NAMES = new Set(['playbooks', 'Auto Run Docs']); const folderName = path.basename('/project/WrongFolder'); - if (folderName !== 'Auto Run Docs') { + if (!ALLOWED_FOLDER_NAMES.has(folderName)) { const result = { success: false, - error: 'Safety check failed: not an Auto Run Docs folder', + error: 'Safety check failed: not a playbooks folder', }; - expect(result.error).toBe('Safety check failed: not an Auto Run Docs folder'); + expect(result.error).toBe('Safety check failed: not a playbooks folder'); } }); }); diff --git a/src/__tests__/main/cue/cue-completion-chains.test.ts b/src/__tests__/main/cue/cue-completion-chains.test.ts new file mode 100644 index 000000000..e28049f3a --- /dev/null +++ b/src/__tests__/main/cue/cue-completion-chains.test.ts @@ -0,0 +1,592 @@ +/** + * Tests for Cue Engine completion chains (Phase 09). + * + * Tests cover: + * - Completion event emission after Cue runs + * - Completion data in event payloads + * - Session name matching (matching by name, not just ID) + * - Fan-out dispatch to multiple target sessions + * - Fan-in data tracking (output concatenation, session names) + * - Fan-in timeout handling (break and continue modes) + * - hasCompletionSubscribers check + * - clearFanInState cleanup + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine completion chains', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('completion data in event payload', () => { + it('includes completion data when provided', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { + sessionName: 'Agent A', + status: 'completed', + exitCode: 0, + durationMs: 5000, + stdout: 'test output', + triggeredBy: 'some-sub', + }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + payload: expect.objectContaining({ + sourceSession: 'Agent A', + sourceSessionId: 'agent-a', + status: 'completed', + exitCode: 0, + durationMs: 5000, + sourceOutput: 'test output', + triggeredBy: 'some-sub', + }), + }) + ); + + engine.stop(); + }); + + it('truncates sourceOutput to 5000 chars', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + const longOutput = 'x'.repeat(10000); + engine.notifyAgentCompleted('agent-a', { stdout: longOutput }); + + const call = (deps.onCueRun as ReturnType).mock.calls[0]; + const event = call[2] as CueEvent; + expect((event.payload.sourceOutput as string).length).toBe(5000); + + engine.stop(); + }); + }); + + describe('session name matching', () => { + it('matches by session name when source_session uses name', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Test Session' }), + createMockSession({ id: 'session-2', name: 'Agent Alpha' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-alpha-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Agent Alpha', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('session-2'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-alpha-done', + }) + ); + + engine.stop(); + }); + }); + + describe('completion event emission (chaining)', () => { + it('emits completion event after Cue run finishes', async () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source', projectRoot: '/proj1' }), + createMockSession({ id: 'session-2', name: 'Downstream', projectRoot: '/proj2' }), + ]; + + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'chain', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'Source', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj1') return config1; + if (projectRoot === '/proj2') return config2; + return null; + }); + + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'do work', + expect.objectContaining({ type: 'time.heartbeat' }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'follow up', + expect.objectContaining({ type: 'agent.completed', triggerName: 'chain' }) + ); + + engine.stop(); + }); + }); + + describe('fan-out', () => { + it('dispatches to each fan_out target session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + // Only the orchestrator session owns the subscription + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-2', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 0 }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-3', + 'deploy', + expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 1 }), + }) + ); + + engine.stop(); + }); + + it('logs fan-out dispatch', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + createMockSession({ id: 'session-3', name: 'Backend', projectRoot: '/projects/be' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'Backend'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out: "deploy-all" → Frontend, Backend') + ); + + engine.stop(); + }); + + it('skips missing fan-out targets with log', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Orchestrator', projectRoot: '/projects/orch' }), + createMockSession({ id: 'session-2', name: 'Frontend', projectRoot: '/projects/fe' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'deploy-all', + event: 'agent.completed', + enabled: true, + prompt: 'deploy', + source_session: 'trigger-session', + fan_out: ['Frontend', 'NonExistent'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root: string) => + root === '/projects/orch' ? config : null + ); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('trigger-session'); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Fan-out target not found: "NonExistent"') + ); + + engine.stop(); + }); + }); + + describe('fan-in data tracking', () => { + it('concatenates fan-in source outputs in event payload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: 'output-a' }); + engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'output-b' }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + sourceOutput: 'output-a\n---\noutput-b', + sourceSession: 'Agent A, Agent B', + }), + }) + ); + + engine.stop(); + }); + + it('logs waiting message during fan-in', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b', 'agent-c'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('waiting for 2 more session(s)') + ); + + engine.stop(); + }); + }); + + describe('fan-in timeout', () => { + it('clears tracker on timeout in break mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'break' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('timed out (break mode)') + ); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires with partial data on timeout in continue mode', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + settings: { timeout_minutes: 1, timeout_on_fail: 'continue' }, + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a', { stdout: 'partial-output' }); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + payload: expect.objectContaining({ + partial: true, + timedOutSessions: expect.arrayContaining(['agent-b']), + }), + }) + ); + + engine.stop(); + }); + }); + + describe('hasCompletionSubscribers', () => { + it('returns true when subscribers exist for a session', () => { + const sessions = [ + createMockSession({ id: 'session-1', name: 'Source' }), + createMockSession({ id: 'session-2', name: 'Listener' }), + ]; + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'react', + source_session: 'Source', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.hasCompletionSubscribers('session-1')).toBe(true); + expect(engine.hasCompletionSubscribers('session-2')).toBe(false); + expect(engine.hasCompletionSubscribers('unknown')).toBe(false); + + engine.stop(); + }); + + it('returns false when engine is disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.hasCompletionSubscribers('any')).toBe(false); + }); + }); + + describe('clearFanInState', () => { + it('clears fan-in trackers for a specific session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.notifyAgentCompleted('agent-a'); + vi.clearAllMocks(); + + engine.clearFanInState('session-1'); + + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-concurrency.test.ts b/src/__tests__/main/cue/cue-concurrency.test.ts new file mode 100644 index 000000000..5a1b92d5b --- /dev/null +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -0,0 +1,636 @@ +/** + * Tests for per-session concurrency control and event queuing. + * + * Tests cover: + * - Concurrency limits (max_concurrent) gate event dispatch + * - Event queuing when at concurrency limit + * - Queue draining when slots free + * - Queue overflow (oldest entry dropped) + * - Stale event eviction during drain + * - Queue cleanup on stopAll, removeSession, and stop + * - getQueueStatus() and clearQueue() public API + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine Concurrency Control', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('max_concurrent enforcement', () => { + it('allows dispatching when below max_concurrent', async () => { + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial fire should dispatch (1/3 concurrent) + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + engine.stop(); + }); + + it('queues events when at max_concurrent limit', async () => { + // Create a never-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow the initial fire to start (never completes) + await vi.advanceTimersByTimeAsync(10); + + // First call dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + // Still only 1 call — the second was queued + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Verify queue has an entry + const queueStatus = engine.getQueueStatus(); + expect(queueStatus.get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + + it('logs queue activity with correct format', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 5, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Trigger another interval — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Event queued for "Test Session"') + ); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('1/5 in queue')); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue draining', () => { + it('dequeues and dispatches when a slot frees up', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Trigger another — should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Complete the first run — should drain the queue + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + // The queued event should now be dispatched + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + // Queue should be empty + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue overflow', () => { + it('drops oldest entry when queue is full', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 2, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + + // Fill the queue (size 2) + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 1 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 2 + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Overflow — should drop oldest + vi.advanceTimersByTime(1 * 60 * 1000); // queued: still 2, but oldest dropped + + expect(engine.getQueueStatus().get('session-1')).toBe(2); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Queue full for "Test Session", dropping oldest event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('stale event eviction', () => { + it('drops stale events during drain', async () => { + let resolveRun: ((val: CueRunResult) => void) | undefined; + const deps = createMockDeps({ + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 1, // 1 minute timeout + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Queue an event + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Wait long enough for the queued event to become stale (> 1 minute) + vi.advanceTimersByTime(2 * 60 * 1000); + + // Complete the first run — drain should evict the stale event + resolveRun!({ + runId: 'r1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(10); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Dropping stale queued event') + ); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('queue cleanup', () => { + it('stopAll clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('removeSession clears queue for that session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.removeSession('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('engine stop clears all queues', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stop(); + expect(engine.getQueueStatus().size).toBe(0); + }); + }); + + describe('clearQueue', () => { + it('clears queued events for a specific session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + engine.clearQueue('session-1'); + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('getQueueStatus', () => { + it('returns empty map when no events are queued', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(engine.getQueueStatus().size).toBe(0); + engine.stop(); + }); + + it('returns correct count per session', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + + expect(engine.getQueueStatus().get('session-1')).toBe(3); + + engine.stopAll(); + engine.stop(); + }); + }); + + describe('multi-concurrent slots', () => { + it('allows multiple concurrent runs up to max_concurrent', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + const config = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); // Initial fire + + // Trigger 2 more intervals — all should dispatch (3 slots) + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().size).toBe(0); // Nothing queued + + // 4th trigger should be queued + vi.advanceTimersByTime(1 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + engine.stopAll(); + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-db.test.ts b/src/__tests__/main/cue/cue-db.test.ts new file mode 100644 index 000000000..798e66dce --- /dev/null +++ b/src/__tests__/main/cue/cue-db.test.ts @@ -0,0 +1,420 @@ +/** + * Tests for the Cue Database module (cue-db.ts). + * + * Note: better-sqlite3 is a native module compiled for Electron's Node version. + * These tests use a mocked database to verify the logic without requiring the + * native module. The mock validates that the correct SQL statements and parameters + * are passed to better-sqlite3. + * + * Tests cover: + * - Database initialization and lifecycle + * - Event recording, status updates, and retrieval + * - Heartbeat write and read + * - Event pruning (housekeeping) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as path from 'path'; +import * as os from 'os'; + +// Store parameters passed to mock statement methods +const runCalls: unknown[][] = []; +const getCalls: unknown[][] = []; +const allCalls: unknown[][] = []; +let mockGetReturn: unknown = undefined; +let mockAllReturn: unknown[] = []; + +const mockStatement = { + run: vi.fn((...args: unknown[]) => { + runCalls.push(args); + return { changes: 1 }; + }), + get: vi.fn((...args: unknown[]) => { + getCalls.push(args); + return mockGetReturn; + }), + all: vi.fn((...args: unknown[]) => { + allCalls.push(args); + return mockAllReturn; + }), +}; + +const prepareCalls: string[] = []; + +const mockDb = { + pragma: vi.fn(), + prepare: vi.fn((sql: string) => { + prepareCalls.push(sql); + return mockStatement; + }), + close: vi.fn(), +}; + +vi.mock('better-sqlite3', () => ({ + default: class MockDatabase { + constructor() { + /* noop */ + } + pragma = mockDb.pragma; + prepare = mockDb.prepare; + close = mockDb.close; + }, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => os.tmpdir()), + }, +})); + +import { + initCueDb, + closeCueDb, + isCueDbReady, + recordCueEvent, + updateCueEventStatus, + getRecentCueEvents, + updateHeartbeat, + getLastHeartbeat, + pruneCueEvents, + isGitHubItemSeen, + markGitHubItemSeen, + hasAnyGitHubSeen, + pruneGitHubSeen, + clearGitHubSeenForSubscription, +} from '../../../main/cue/cue-db'; + +beforeEach(() => { + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + allCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + mockAllReturn = []; + + // Ensure the module's internal db is reset + closeCueDb(); +}); + +afterEach(() => { + closeCueDb(); +}); + +describe('cue-db lifecycle', () => { + it('should report ready after initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(isCueDbReady()).toBe(true); + }); + + it('should report not ready after close', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(isCueDbReady()).toBe(false); + }); + + it('should not double-initialize', () => { + const dbPath = path.join(os.tmpdir(), 'test-cue.db'); + initCueDb(undefined, dbPath); + const callCountAfterFirst = mockDb.pragma.mock.calls.length; + + initCueDb(undefined, dbPath); + // No new pragma calls because it short-circuited + expect(mockDb.pragma.mock.calls.length).toBe(callCountAfterFirst); + }); + + it('should set WAL mode on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + expect(mockDb.pragma).toHaveBeenCalledWith('journal_mode = WAL'); + }); + + it('should create tables and indexes on initialization', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + + // Should have prepared CREATE TABLE and CREATE INDEX statements + expect(prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_events'))).toBe( + true + ); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_heartbeat')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_created'))).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_events_session'))).toBe(true); + expect( + prepareCalls.some((sql) => sql.includes('CREATE TABLE IF NOT EXISTS cue_github_seen')) + ).toBe(true); + expect(prepareCalls.some((sql) => sql.includes('idx_cue_github_seen_at'))).toBe(true); + }); + + it('should throw when accessing before initialization', () => { + expect(() => + recordCueEvent({ + id: 'test-1', + type: 'time.heartbeat', + triggerName: 'test', + sessionId: 'session-1', + subscriptionName: 'test-sub', + status: 'running', + }) + ).toThrow('Cue database not initialized'); + }); + + it('should close the database', () => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + closeCueDb(); + expect(mockDb.close).toHaveBeenCalled(); + }); +}); + +describe('cue-db event journal', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should record an event with correct parameters', () => { + recordCueEvent({ + id: 'evt-1', + type: 'time.heartbeat', + triggerName: 'my-trigger', + sessionId: 'session-1', + subscriptionName: 'periodic-check', + status: 'running', + }); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_events') + ); + expect(runCalls.length).toBeGreaterThan(0); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('evt-1'); // id + expect(lastRun[1]).toBe('time.heartbeat'); // type + expect(lastRun[2]).toBe('my-trigger'); // trigger_name + expect(lastRun[3]).toBe('session-1'); // session_id + expect(lastRun[4]).toBe('periodic-check'); // subscription_name + expect(lastRun[5]).toBe('running'); // status + expect(typeof lastRun[6]).toBe('number'); // created_at (timestamp) + expect(lastRun[7]).toBeNull(); // payload (null when not provided) + }); + + it('should record an event with payload', () => { + const payload = JSON.stringify({ reconciled: true, missedCount: 3 }); + recordCueEvent({ + id: 'evt-2', + type: 'time.heartbeat', + triggerName: 'cron-trigger', + sessionId: 'session-2', + subscriptionName: 'cron-sub', + status: 'completed', + payload, + }); + + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[7]).toBe(payload); + }); + + it('should update event status with completed_at timestamp', () => { + updateCueEventStatus('evt-3', 'completed'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('UPDATE cue_events SET status') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('completed'); // status + expect(typeof lastRun[1]).toBe('number'); // completed_at + expect(lastRun[2]).toBe('evt-3'); // id + }); + + it('should query recent events with correct since parameter', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('FROM cue_events WHERE created_at >=') + ); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + }); + + it('should query recent events with limit', () => { + const since = Date.now() - 1000; + getRecentCueEvents(since, 10); + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('LIMIT')); + const lastAll = allCalls[allCalls.length - 1]; + expect(lastAll[0]).toBe(since); + expect(lastAll[1]).toBe(10); + }); + + it('should map row data to CueEventRecord correctly', () => { + mockAllReturn = [ + { + id: 'evt-mapped', + type: 'file.changed', + trigger_name: 'file-trigger', + session_id: 'session-1', + subscription_name: 'file-sub', + status: 'completed', + created_at: 1000000, + completed_at: 1000500, + payload: '{"file":"test.ts"}', + }, + ]; + + const events = getRecentCueEvents(0); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + id: 'evt-mapped', + type: 'file.changed', + triggerName: 'file-trigger', + sessionId: 'session-1', + subscriptionName: 'file-sub', + status: 'completed', + createdAt: 1000000, + completedAt: 1000500, + payload: '{"file":"test.ts"}', + }); + }); +}); + +describe('cue-db heartbeat', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should write heartbeat with INSERT OR REPLACE', () => { + updateHeartbeat(); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR REPLACE INTO cue_heartbeat') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(typeof lastRun[0]).toBe('number'); // current timestamp + }); + + it('should return null when no heartbeat exists', () => { + mockGetReturn = undefined; + const result = getLastHeartbeat(); + expect(result).toBeNull(); + }); + + it('should return the last_seen value when heartbeat exists', () => { + mockGetReturn = { last_seen: 1234567890 }; + const result = getLastHeartbeat(); + expect(result).toBe(1234567890); + }); +}); + +describe('cue-db pruning', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + prepareCalls.length = 0; + }); + + it('should delete events older than specified age', () => { + const olderThanMs = 7 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneCueEvents(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_events WHERE created_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + // The cutoff should be approximately Date.now() - olderThanMs + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); +}); + +describe('cue-db github seen tracking', () => { + beforeEach(() => { + initCueDb(undefined, path.join(os.tmpdir(), 'test-cue.db')); + vi.clearAllMocks(); + runCalls.length = 0; + getCalls.length = 0; + prepareCalls.length = 0; + mockGetReturn = undefined; + }); + + it('isGitHubItemSeen should return false when item not found', () => { + mockGetReturn = undefined; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining( + 'SELECT 1 FROM cue_github_seen WHERE subscription_id = ? AND item_key = ?' + ) + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + expect(lastGet[1]).toBe('pr:owner/repo:123'); + }); + + it('isGitHubItemSeen should return true when item exists', () => { + mockGetReturn = { '1': 1 }; + const result = isGitHubItemSeen('sub-1', 'pr:owner/repo:123'); + expect(result).toBe(true); + }); + + it('markGitHubItemSeen should INSERT OR IGNORE with correct parameters', () => { + markGitHubItemSeen('sub-1', 'pr:owner/repo:456'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('INSERT OR IGNORE INTO cue_github_seen') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + expect(lastRun[1]).toBe('pr:owner/repo:456'); + expect(typeof lastRun[2]).toBe('number'); // seen_at + }); + + it('hasAnyGitHubSeen should return false when no records exist', () => { + mockGetReturn = undefined; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(false); + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('SELECT 1 FROM cue_github_seen WHERE subscription_id = ? LIMIT 1') + ); + const lastGet = getCalls[getCalls.length - 1]; + expect(lastGet[0]).toBe('sub-1'); + }); + + it('hasAnyGitHubSeen should return true when records exist', () => { + mockGetReturn = { '1': 1 }; + const result = hasAnyGitHubSeen('sub-1'); + expect(result).toBe(true); + }); + + it('pruneGitHubSeen should delete old records with correct cutoff', () => { + const olderThanMs = 30 * 24 * 60 * 60 * 1000; + const before = Date.now(); + pruneGitHubSeen(olderThanMs); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE seen_at < ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + const cutoff = lastRun[0] as number; + expect(cutoff).toBeLessThanOrEqual(before); + expect(cutoff).toBeGreaterThan(before - olderThanMs - 1000); + }); + + it('clearGitHubSeenForSubscription should delete all records for a subscription', () => { + clearGitHubSeenForSubscription('sub-1'); + + expect(mockDb.prepare).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM cue_github_seen WHERE subscription_id = ?') + ); + const lastRun = runCalls[runCalls.length - 1]; + expect(lastRun[0]).toBe('sub-1'); + }); +}); diff --git a/src/__tests__/main/cue/cue-engine.test.ts b/src/__tests__/main/cue/cue-engine.test.ts new file mode 100644 index 000000000..605712b0b --- /dev/null +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -0,0 +1,1470 @@ +/** + * Tests for the Cue Engine core. + * + * Tests cover: + * - Engine lifecycle (start, stop, isEnabled) + * - Session initialization from YAML configs + * - Timer-based subscriptions (time.heartbeat) + * - File watcher subscriptions (file.changed) + * - Agent completion subscriptions (agent.completed) + * - Fan-in tracking for multi-source agent.completed + * - Active run tracking and stopping + * - Activity log ring buffer + * - Session refresh and removal + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +const mockCreateCueFileWatcher = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: (...args: unknown[]) => mockCreateCueFileWatcher(args[0]), +})); + +// Mock the GitHub poller +const mockCreateCueGitHubPoller = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-github-poller', () => ({ + createCueGitHubPoller: (...args: unknown[]) => mockCreateCueGitHubPoller(args[0]), +})); + +// Mock the task scanner +const mockCreateCueTaskScanner = vi.fn<(config: unknown) => () => void>(); +vi.mock('../../../main/cue/cue-task-scanner', () => ({ + createCueTaskScanner: (...args: unknown[]) => mockCreateCueTaskScanner(args[0]), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine', () => { + let yamlWatcherCleanup: ReturnType; + let fileWatcherCleanup: ReturnType; + + let gitHubPollerCleanup: ReturnType; + let taskScannerCleanup: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + yamlWatcherCleanup = vi.fn(); + mockWatchCueYaml.mockReturnValue(yamlWatcherCleanup); + + fileWatcherCleanup = vi.fn(); + mockCreateCueFileWatcher.mockReturnValue(fileWatcherCleanup); + + gitHubPollerCleanup = vi.fn(); + mockCreateCueGitHubPoller.mockReturnValue(gitHubPollerCleanup); + + taskScannerCleanup = vi.fn(); + mockCreateCueTaskScanner.mockReturnValue(taskScannerCleanup); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('lifecycle', () => { + it('starts as disabled', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.isEnabled()).toBe(false); + }); + + it('becomes enabled after start()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); + + it('becomes disabled after stop()', () => { + mockLoadCueConfig.mockReturnValue(null); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs start and stop events', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('started')); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('stopped')); + }); + }); + + describe('session initialization', () => { + it('scans all sessions on start', () => { + const sessions = [ + createMockSession({ id: 's1', projectRoot: '/proj1' }), + createMockSession({ id: 's2', projectRoot: '/proj2' }), + ]; + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj1'); + expect(mockLoadCueConfig).toHaveBeenCalledWith('/proj2'); + }); + + it('skips sessions without a cue config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(engine.getStatus()).toHaveLength(0); + }); + + it('initializes sessions with valid config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('sets up YAML file watcher for config changes', () => { + mockLoadCueConfig.mockReturnValue(createMockConfig()); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockWatchCueYaml).toHaveBeenCalled(); + }); + }); + + describe('time.heartbeat subscriptions', () => { + it('fires immediately on setup', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.heartbeat', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Should fire immediately + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'Run check', + expect.objectContaining({ type: 'time.heartbeat', triggerName: 'periodic' }) + ); + }); + + it('fires on the interval', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.heartbeat', + enabled: true, + prompt: 'Run check', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Flush microtasks to let the initial run complete and free the concurrency slot + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Advance 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Advance another 5 minutes + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('skips disabled subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'disabled', + event: 'time.heartbeat', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + engine.stop(); + }); + + it('clears timers on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.stop(); + + vi.advanceTimersByTime(60 * 1000); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('file.changed subscriptions', () => { + it('creates a file watcher with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'watch-src', + event: 'file.changed', + enabled: true, + prompt: 'lint', + watch: 'src/**/*.ts', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueFileWatcher).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + triggerName: 'watch-src', + }) + ); + + engine.stop(); + }); + + it('cleans up file watcher on stop', () => { + const config = createMockConfig({ + subscriptions: [ + { name: 'watch', event: 'file.changed', enabled: true, prompt: 'test', watch: '**/*.ts' }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + engine.stop(); + + expect(fileWatcherCleanup).toHaveBeenCalled(); + }); + }); + + describe('agent.completed subscriptions', () => { + it('fires for single source_session match', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-a'); + + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'follow up', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'on-done', + }) + ); + }); + + it('does not fire for non-matching session', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'on-done', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'agent-a', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.notifyAgentCompleted('agent-b'); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('tracks fan-in completions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // First completion — should not fire + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Second completion — should fire + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledWith( + 'session-1', + 'aggregate', + expect.objectContaining({ + type: 'agent.completed', + triggerName: 'all-done', + }) + ); + }); + + it('resets fan-in tracker after firing', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['agent-a', 'agent-b'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + engine.notifyAgentCompleted('agent-a'); + engine.notifyAgentCompleted('agent-b'); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // Start again — should need both to fire again + engine.notifyAgentCompleted('agent-a'); + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + }); + + describe('session management', () => { + it('removeSession tears down subscriptions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + expect(engine.getStatus()).toHaveLength(0); + expect(yamlWatcherCleanup).toHaveBeenCalled(); + }); + + it('refreshSession re-reads config', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-2', + event: 'time.heartbeat', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + engine.refreshSession('session-1', '/projects/test'); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(2); + }); + }); + + describe('YAML hot reload', () => { + it('logs "Config reloaded" with subscription count when config changes', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'old-sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'new-sub-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 10, + }, + { + name: 'new-sub-2', + event: 'time.heartbeat', + enabled: true, + prompt: 'test2', + interval_minutes: 15, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config1).mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded for "Test Session" (2 subscriptions)'), + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + }); + + it('passes data to onLog for IPC push on config reload', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + // Verify data parameter is passed (triggers cue:activityUpdate in main process) + const reloadCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config reloaded') + ); + expect(reloadCall).toBeDefined(); + expect(reloadCall![2]).toEqual( + expect.objectContaining({ type: 'configReloaded', sessionId: 'session-1' }) + ); + + engine.stop(); + }); + + it('logs "Config removed" when YAML file is deleted', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + // First call returns config (initial load), second returns null (file deleted) + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + engine.refreshSession('session-1', '/projects/test'); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config removed for "Test Session"'), + expect.objectContaining({ type: 'configRemoved', sessionId: 'session-1' }) + ); + expect(engine.getStatus()).toHaveLength(0); + }); + + it('sets up a pending yaml watcher after config deletion for re-creation', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const initialWatchCalls = mockWatchCueYaml.mock.calls.length; + engine.refreshSession('session-1', '/projects/test'); + + // A new yaml watcher should be created for watching re-creation + expect(mockWatchCueYaml.mock.calls.length).toBe(initialWatchCalls + 1); + }); + + it('recovers when config file is re-created after deletion', () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'original', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'recreated', + event: 'time.heartbeat', + enabled: true, + prompt: 'test2', + interval_minutes: 10, + }, + ], + }); + // First: initial config, second: null (deleted), third: new config (re-created) + mockLoadCueConfig + .mockReturnValueOnce(config1) + .mockReturnValueOnce(null) + .mockReturnValue(config2); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config + engine.refreshSession('session-1', '/projects/test'); + expect(engine.getStatus()).toHaveLength(0); + + // Capture the pending yaml watcher callback + const lastWatchCall = mockWatchCueYaml.mock.calls[mockWatchCueYaml.mock.calls.length - 1]; + const pendingOnChange = lastWatchCall[1] as () => void; + + // Simulate file re-creation by invoking the watcher callback + pendingOnChange(); + + // Session should be re-initialized with the new config + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].subscriptionCount).toBe(1); + }); + + it('cleans up pending yaml watchers on engine stop', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Stop engine — should clean up pending watcher + engine.stop(); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('cleans up pending yaml watchers on removeSession', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + const pendingCleanup = vi.fn(); + mockLoadCueConfig.mockReturnValueOnce(config).mockReturnValue(null); + mockWatchCueYaml.mockReturnValueOnce(yamlWatcherCleanup).mockReturnValue(pendingCleanup); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Delete config — creates pending yaml watcher + engine.refreshSession('session-1', '/projects/test'); + + // Remove session — should clean up pending watcher + engine.removeSession('session-1'); + expect(pendingCleanup).toHaveBeenCalled(); + }); + + it('triggers refresh via yaml watcher callback on file change', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Capture the yaml watcher callback + const watchCall = mockWatchCueYaml.mock.calls[0]; + const onChange = watchCall[1] as () => void; + + vi.clearAllMocks(); + mockLoadCueConfig.mockReturnValue(config); + mockWatchCueYaml.mockReturnValue(vi.fn()); + + // Simulate file change by invoking the watcher callback + onChange(); + + // refreshSession should have been called (loadCueConfig invoked for re-init) + expect(mockLoadCueConfig).toHaveBeenCalledWith('/projects/test'); + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config reloaded'), + expect.any(Object) + ); + }); + + it('does not log "Config removed" when session never had config', () => { + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + // Session never had a config, so refreshSession with null should not log "Config removed" + engine.refreshSession('session-1', '/projects/test'); + + const removedCall = (deps.onLog as ReturnType).mock.calls.find( + (call: unknown[]) => typeof call[1] === 'string' && call[1].includes('Config removed') + ); + expect(removedCall).toBeUndefined(); + }); + }); + + describe('activity log', () => { + it('records completed runs', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Wait for the async run to complete + await vi.advanceTimersByTimeAsync(100); + + const log = engine.getActivityLog(); + expect(log.length).toBeGreaterThan(0); + expect(log[0].subscriptionName).toBe('periodic'); + }); + + it('respects limit parameter', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'periodic', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Run multiple intervals + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + const limited = engine.getActivityLog(1); + expect(limited).toHaveLength(1); + + engine.stop(); + }); + }); + + describe('run management', () => { + it('stopRun returns false for non-existent run', () => { + const engine = new CueEngine(createMockDeps()); + expect(engine.stopRun('nonexistent')).toBe(false); + }); + + it('stopAll clears all active runs', async () => { + // Use a slow-resolving onCueRun to keep runs active + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), // Never resolves + }); + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(deps); + engine.start(); + + // Allow async execution to start + await vi.advanceTimersByTimeAsync(10); + + expect(engine.getActiveRuns().length).toBeGreaterThan(0); + engine.stopAll(); + expect(engine.getActiveRuns()).toHaveLength(0); + + engine.stop(); + }); + }); + + describe('github.pull_request / github.issue subscriptions', () => { + it('github.pull_request subscription creates a GitHub poller with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review PR', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 10, + projectRoot: '/projects/test', + triggerName: 'pr-watcher', + subscriptionId: 'session-1:pr-watcher', + }) + ); + + engine.stop(); + }); + + it('github.issue subscription creates a GitHub poller', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'issue-watcher', + event: 'github.issue', + enabled: true, + prompt: 'triage issue', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.issue', + pollMinutes: 5, // default + triggerName: 'issue-watcher', + subscriptionId: 'session-1:issue-watcher', + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: true, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(gitHubPollerCleanup).toHaveBeenCalled(); + }); + + it('disabled github subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + enabled: false, + prompt: 'review', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('task.pending subscriptions', () => { + it('creates a task scanner with correct config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + poll_minutes: 2, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + watchGlob: 'tasks/**/*.md', + pollMinutes: 2, + projectRoot: '/projects/test', + triggerName: 'task-queue', + }) + ); + + engine.stop(); + }); + + it('defaults poll_minutes to 1 when not specified', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).toHaveBeenCalledWith( + expect.objectContaining({ + pollMinutes: 1, + }) + ); + + engine.stop(); + }); + + it('cleanup function is called on session teardown', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: true, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + engine.removeSession('session-1'); + + expect(taskScannerCleanup).toHaveBeenCalled(); + }); + + it('disabled task.pending subscription is skipped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + enabled: false, + prompt: 'process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueTaskScanner).not.toHaveBeenCalled(); + + engine.stop(); + }); + }); + + describe('getStatus', () => { + it('returns correct status for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + { + name: 'disabled', + event: 'time.heartbeat', + enabled: false, + prompt: 'noop', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].subscriptionCount).toBe(1); // Only enabled ones + expect(status[0].enabled).toBe(true); + + engine.stop(); + }); + + it('returns sessions with cue configs when engine is disabled', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Engine never started — getStatus should still find configs on disk + + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].sessionId).toBe('session-1'); + expect(status[0].sessionName).toBe('Test Session'); + expect(status[0].enabled).toBe(false); + expect(status[0].subscriptionCount).toBe(1); + expect(status[0].activeRuns).toBe(0); + }); + + it('returns sessions with enabled=false after engine is stopped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // While running, enabled is true + expect(engine.getStatus()[0].enabled).toBe(true); + + engine.stop(); + + // After stopping, sessions should still appear but with enabled=false + const status = engine.getStatus(); + expect(status).toHaveLength(1); + expect(status[0].enabled).toBe(false); + }); + }); + + describe('output_prompt execution', () => { + it('executes output prompt after successful main task', async () => { + const mainResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main task output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const outputResult: CueRunResult = { + ...mainResult, + runId: 'run-2', + stdout: 'formatted output for downstream', + }; + const onCueRun = vi + .fn() + .mockResolvedValueOnce(mainResult) + .mockResolvedValueOnce(outputResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // onCueRun called twice: main task + output prompt + expect(onCueRun).toHaveBeenCalledTimes(2); + + // First call is the main prompt + expect(onCueRun.mock.calls[0][1]).toBe('do work'); + + // Second call is the output prompt with context appended + expect(onCueRun.mock.calls[1][1]).toContain('format results'); + expect(onCueRun.mock.calls[1][1]).toContain('main task output'); + + // Activity log should have the output prompt's stdout + const log = engine.getActivityLog(); + expect(log[0].stdout).toBe('formatted output for downstream'); + + engine.stop(); + }); + + it('skips output prompt when main task fails', async () => { + const failedResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'failed', + stdout: '', + stderr: 'error', + exitCode: 1, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const onCueRun = vi.fn().mockResolvedValue(failedResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Only called once — output prompt skipped + expect(onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('falls back to main output when output prompt fails', async () => { + const mainResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main task output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const failedOutputResult: CueRunResult = { + ...mainResult, + runId: 'run-2', + status: 'failed', + stdout: '', + stderr: 'output prompt error', + }; + const onCueRun = vi + .fn() + .mockResolvedValueOnce(mainResult) + .mockResolvedValueOnce(failedOutputResult); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + output_prompt: 'format results', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Both calls made + expect(onCueRun).toHaveBeenCalledTimes(2); + + // Activity log should retain main task output (fallback) + const log = engine.getActivityLog(); + expect(log[0].stdout).toBe('main task output'); + + engine.stop(); + }); + + it('does not execute output prompt when none is configured', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(100); + + // Only one call — no output prompt + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + }); + + describe('getGraphData', () => { + it('returns graph data for active sessions', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + expect(graph[0].subscriptions).toHaveLength(1); + + engine.stop(); + }); + + it('returns graph data from disk configs when engine is disabled', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Never started + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + expect(graph[0].sessionName).toBe('Test Session'); + expect(graph[0].subscriptions).toHaveLength(1); + }); + + it('returns graph data after engine is stopped', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + const graph = engine.getGraphData(); + expect(graph).toHaveLength(1); + expect(graph[0].sessionId).toBe('session-1'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts new file mode 100644 index 000000000..daa821e7e --- /dev/null +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -0,0 +1,1017 @@ +/** + * Tests for the Cue executor module. + * + * Tests cover: + * - Prompt file resolution (absolute and relative paths) + * - Prompt file read failures + * - Template variable substitution with Cue event context + * - Agent argument building (follows process:spawn pattern) + * - Process spawning and stdout/stderr capture + * - Timeout enforcement with SIGTERM → SIGKILL escalation + * - Successful completion and failure detection + * - SSH remote execution wrapping + * - stopCueRun process termination + * - recordCueHistoryEntry construction + * - History entry field population and response truncation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { ChildProcess } from 'child_process'; +import type { CueEvent, CueSubscription, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; +import type { TemplateContext } from '../../../shared/templateVariables'; + +// --- Mocks --- + +// Mock fs +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock substituteTemplateVariables +const mockSubstitute = vi.fn((template: string) => `substituted: ${template}`); +vi.mock('../../../shared/templateVariables', () => ({ + substituteTemplateVariables: (...args: unknown[]) => mockSubstitute(args[0] as string, args[1]), +})); + +// Mock agents module +const mockGetAgentDefinition = vi.fn(); +const mockGetAgentCapabilities = vi.fn(() => ({ + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: false, + supportsImageInputOnResume: false, + supportsSlashCommands: true, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsContextUsage: true, + supportsThinking: false, + supportsStdin: false, + supportsRawStdin: false, + supportsModelSelection: false, + supportsModelDiscovery: false, + supportsBatchMode: true, + supportsYoloMode: true, + supportsExitCodes: true, + supportsWorkingDir: false, +})); +vi.mock('../../../main/agents', () => ({ + getAgentDefinition: (...args: unknown[]) => mockGetAgentDefinition(...args), + getAgentCapabilities: (...args: unknown[]) => mockGetAgentCapabilities(...args), +})); + +// Mock buildAgentArgs and applyAgentConfigOverrides +const mockBuildAgentArgs = vi.fn((_agent: unknown, _opts: unknown) => [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + '--', + 'prompt-content', +]); +const mockApplyOverrides = vi.fn((_agent: unknown, args: string[], _overrides: unknown) => ({ + args, + effectiveCustomEnvVars: undefined, + customArgsSource: 'none' as const, + customEnvSource: 'none' as const, + modelSource: 'default' as const, +})); +vi.mock('../../../main/utils/agent-args', () => ({ + buildAgentArgs: (...args: unknown[]) => mockBuildAgentArgs(...args), + applyAgentConfigOverrides: (...args: unknown[]) => mockApplyOverrides(...args), +})); + +// Mock wrapSpawnWithSsh +const mockWrapSpawnWithSsh = vi.fn(); +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), +})); + +// Mock child_process.spawn +class MockChildProcess extends EventEmitter { + stdin = { + write: vi.fn(), + end: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + + kill(signal?: string) { + this.killed = true; + return true; + } + + constructor() { + super(); + // Set encoding methods on stdout/stderr + (this.stdout as any).setEncoding = vi.fn(); + (this.stderr as any).setEncoding = vi.fn(); + } +} + +let mockChild: MockChildProcess; +const mockSpawn = vi.fn(() => { + mockChild = new MockChildProcess(); + return mockChild as unknown as ChildProcess; +}); + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + default: { + ...actual, + spawn: (...args: unknown[]) => mockSpawn(...args), + }, + }; +}); + +// Must import after mocks +import { + executeCuePrompt, + stopCueRun, + getActiveProcesses, + recordCueHistoryEntry, + type CueExecutionConfig, +} from '../../../main/cue/cue-executor'; + +// --- Helpers --- + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockSubscription(overrides: Partial = {}): CueSubscription { + return { + name: 'Watch config', + event: 'file.changed', + enabled: true, + prompt: 'prompts/on-config-change.md', + watch: '**/*.yaml', + ...overrides, + }; +} + +function createMockEvent(overrides: Partial = {}): CueEvent { + return { + id: 'event-1', + type: 'file.changed', + timestamp: '2026-03-01T00:00:00.000Z', + triggerName: 'Watch config', + payload: { + path: '/projects/test/config.yaml', + filename: 'config.yaml', + directory: '/projects/test', + extension: '.yaml', + }, + ...overrides, + }; +} + +function createMockTemplateContext(): TemplateContext { + return { + session: { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + }, + }; +} + +function createExecutionConfig(overrides: Partial = {}): CueExecutionConfig { + return { + runId: 'run-1', + session: createMockSession(), + subscription: createMockSubscription(), + event: createMockEvent(), + promptPath: 'prompts/on-config-change.md', + toolType: 'claude-code', + projectRoot: '/projects/test', + templateContext: createMockTemplateContext(), + timeoutMs: 30000, + onLog: vi.fn(), + ...overrides, + }; +} + +const defaultAgentDef = { + id: 'claude-code', + name: 'Claude Code', + binaryName: 'claude', + command: 'claude', + args: [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', + ], +}; + +// --- Tests --- + +describe('cue-executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + getActiveProcesses().clear(); + + // Default mock implementations + mockReadFileSync.mockReturnValue('Prompt content: check {{CUE_FILE_PATH}}'); + mockGetAgentDefinition.mockReturnValue(defaultAgentDef); + mockSubstitute.mockImplementation((template: string) => `substituted: ${template}`); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('executeCuePrompt', () => { + it('should resolve relative prompt paths against projectRoot', async () => { + const config = createExecutionConfig({ + promptPath: 'prompts/check.md', + projectRoot: '/projects/test', + }); + + const resultPromise = executeCuePrompt(config); + // Let spawn happen + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/projects/test/prompts/check.md', 'utf-8'); + + // Close the process to resolve + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should use absolute prompt paths directly', async () => { + const config = createExecutionConfig({ + promptPath: '/absolute/path/prompt.md', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockReadFileSync).toHaveBeenCalledWith('/absolute/path/prompt.md', 'utf-8'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result when prompt file cannot be read', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file'); + }); + + const config = createExecutionConfig(); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Failed to read prompt file'); + expect(result.stderr).toContain('ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should populate Cue event data in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/src/app.ts', + filename: 'app.ts', + directory: '/projects/test/src', + extension: '.ts', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify template context was populated with cue data + expect(templateContext.cue).toEqual({ + eventType: 'file.changed', + eventTimestamp: event.timestamp, + triggerName: 'Watch config', + runId: 'run-1', + filePath: '/projects/test/src/app.ts', + fileName: 'app.ts', + fileDir: '/projects/test/src', + fileExt: '.ts', + fileChangeType: '', + sourceSession: '', + sourceOutput: '', + sourceStatus: '', + sourceExitCode: '', + sourceDuration: '', + sourceTriggeredBy: '', + }); + + // Verify substituteTemplateVariables was called + expect(mockSubstitute).toHaveBeenCalledWith( + 'Prompt content: check {{CUE_FILE_PATH}}', + templateContext + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should return failed result for unknown agent type', async () => { + mockGetAgentDefinition.mockReturnValue(undefined); + + const config = createExecutionConfig({ toolType: 'nonexistent' }); + const result = await executeCuePrompt(config); + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Unknown agent type: nonexistent'); + }); + + it('should build agent args using the same pipeline as process:spawn', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Verify buildAgentArgs was called with proper params + expect(mockBuildAgentArgs).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'claude-code', + binaryName: 'claude', + command: 'claude', + }), + expect.objectContaining({ + baseArgs: defaultAgentDef.args, + cwd: '/projects/test', + yoloMode: true, + }) + ); + + // Verify applyAgentConfigOverrides was called + expect(mockApplyOverrides).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should spawn the process with correct command and args', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.any(Array), + expect.objectContaining({ + cwd: '/projects/test', + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should capture stdout and stderr from the process', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Emit some output + mockChild.stdout.emit('data', 'Hello '); + mockChild.stdout.emit('data', 'world'); + mockChild.stderr.emit('data', 'Warning: something'); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('Hello world'); + expect(result.stderr).toBe('Warning: something'); + }); + + it('should return completed status on exit code 0', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.status).toBe('completed'); + expect(result.exitCode).toBe(0); + expect(result.runId).toBe('run-1'); + expect(result.sessionId).toBe('session-1'); + expect(result.sessionName).toBe('Test Session'); + expect(result.subscriptionName).toBe('Watch config'); + }); + + it('should return failed status on non-zero exit code', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('close', 1); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.exitCode).toBe(1); + }); + + it('should handle spawn errors gracefully', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.emit('error', new Error('spawn ENOENT')); + const result = await resultPromise; + + expect(result.status).toBe('failed'); + expect(result.stderr).toContain('Spawn error: spawn ENOENT'); + expect(result.exitCode).toBeNull(); + }); + + it('should track the process in activeProcesses while running', async () => { + const config = createExecutionConfig({ runId: 'tracked-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(getActiveProcesses().has('tracked-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + expect(getActiveProcesses().has('tracked-run')).toBe(false); + }); + + it('should use custom path when provided', async () => { + const config = createExecutionConfig({ + customPath: '/custom/claude', + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockSpawn).toHaveBeenCalledWith( + '/custom/claude', + expect.any(Array), + expect.any(Object) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should close stdin for local execution', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // For local (non-SSH) execution, stdin should just be closed + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + + describe('timeout enforcement', () => { + it('should send SIGTERM when timeout expires', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + const killSpy = vi.spyOn(mockChild, 'kill'); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Wait: re-spy after child is created + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Process exits after SIGTERM + mockChild.emit('close', null); + const result = await resultPromise; + + expect(result.status).toBe('timeout'); + }); + + it('should escalate to SIGKILL after SIGTERM + delay', async () => { + const config = createExecutionConfig({ timeoutMs: 5000 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + // Reset to track SIGKILL — but killed is already true so SIGKILL won't fire + // since child.killed is true. That's correct behavior. + mockChild.killed = false; + + // Advance past SIGKILL delay + await vi.advanceTimersByTimeAsync(5000); + expect(childKill).toHaveBeenCalledWith('SIGKILL'); + + mockChild.emit('close', null); + await resultPromise; + }); + + it('should not timeout when timeoutMs is 0', async () => { + const config = createExecutionConfig({ timeoutMs: 0 }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + // Advance a lot of time + await vi.advanceTimersByTimeAsync(60000); + expect(childKill).not.toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('SSH remote execution', () => { + it('should call wrapSpawnWithSsh when SSH is enabled', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['-o', 'BatchMode=yes', 'user@host', 'claude --print'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: { id: 'remote-1', name: 'My Server', host: 'host.example.com' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', + agentBinaryName: 'claude', + }), + { enabled: true, remoteId: 'remote-1' }, + mockSshStore + ); + + expect(mockSpawn).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-o', 'BatchMode=yes']), + expect.objectContaining({ cwd: '/Users/test' }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should write prompt to stdin for SSH large prompt mode', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['user@host'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: 'large prompt content', // SSH returns prompt for stdin delivery + sshRemoteUsed: { id: 'remote-1', name: 'Server', host: 'host' }, + }); + + const config = createExecutionConfig({ + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockChild.stdin.write).toHaveBeenCalledWith('large prompt content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + it('should pass custom model and args through config overrides', async () => { + const config = createExecutionConfig({ + customModel: 'claude-4-opus', + customArgs: '--max-tokens 1000', + customEnvVars: { API_KEY: 'test-key' }, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(mockApplyOverrides).toHaveBeenCalledWith( + expect.anything(), + expect.any(Array), + expect.objectContaining({ + sessionCustomModel: 'claude-4-opus', + sessionCustomArgs: '--max-tokens 1000', + sessionCustomEnvVars: { API_KEY: 'test-key' }, + }) + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should include event duration in the result', async () => { + const config = createExecutionConfig(); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Advance some time + await vi.advanceTimersByTimeAsync(1500); + + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.durationMs).toBeGreaterThanOrEqual(1500); + expect(result.startedAt).toBeTruthy(); + expect(result.endedAt).toBeTruthy(); + }); + + it('should populate github.pull_request event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'PR watcher', + event: 'github.pull_request', + }); + const event = createMockEvent({ + type: 'github.pull_request', + triggerName: 'PR watcher', + payload: { + type: 'pull_request', + number: 42, + title: 'Add feature X', + author: 'octocat', + url: 'https://github.com/owner/repo/pull/42', + body: 'This PR adds feature X', + labels: 'enhancement,review-needed', + state: 'open', + repo: 'owner/repo', + head_branch: 'feature-x', + base_branch: 'main', + assignees: '', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('pull_request'); + expect(templateContext.cue?.ghNumber).toBe('42'); + expect(templateContext.cue?.ghTitle).toBe('Add feature X'); + expect(templateContext.cue?.ghAuthor).toBe('octocat'); + expect(templateContext.cue?.ghUrl).toBe('https://github.com/owner/repo/pull/42'); + expect(templateContext.cue?.ghBranch).toBe('feature-x'); + expect(templateContext.cue?.ghBaseBranch).toBe('main'); + expect(templateContext.cue?.ghRepo).toBe('owner/repo'); + // Base cue fields should still be populated + expect(templateContext.cue?.eventType).toBe('github.pull_request'); + expect(templateContext.cue?.triggerName).toBe('PR watcher'); + expect(templateContext.cue?.runId).toBe('run-1'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate github.issue event context correctly', async () => { + const subscription = createMockSubscription({ + name: 'Issue watcher', + event: 'github.issue', + }); + const event = createMockEvent({ + type: 'github.issue', + triggerName: 'Issue watcher', + payload: { + type: 'issue', + number: 99, + title: 'Bug report', + author: 'user1', + url: 'https://github.com/owner/repo/issues/99', + body: 'Found a bug', + labels: 'bug', + state: 'open', + repo: 'owner/repo', + assignees: 'dev1,dev2', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, subscription, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.ghType).toBe('issue'); + expect(templateContext.cue?.ghNumber).toBe('99'); + expect(templateContext.cue?.ghAssignees).toBe('dev1,dev2'); + // head_branch / base_branch not in payload → empty string + expect(templateContext.cue?.ghBranch).toBe(''); + expect(templateContext.cue?.ghBaseBranch).toBe(''); + // Base cue fields preserved + expect(templateContext.cue?.eventType).toBe('github.issue'); + expect(templateContext.cue?.triggerName).toBe('Issue watcher'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate file.changed changeType in template context', async () => { + const event = createMockEvent({ + type: 'file.changed', + payload: { + path: '/projects/test/new-file.ts', + filename: 'new-file.ts', + directory: '/projects/test', + extension: '.ts', + changeType: 'add', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.fileChangeType).toBe('add'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should populate agent.completed event context correctly', async () => { + const event = createMockEvent({ + type: 'agent.completed', + triggerName: 'On agent done', + payload: { + sourceSession: 'builder-session', + sourceOutput: 'Build completed successfully', + status: 'completed', + exitCode: 0, + durationMs: 15000, + triggeredBy: 'lint-on-save', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.sourceSession).toBe('builder-session'); + expect(templateContext.cue?.sourceOutput).toBe('Build completed successfully'); + expect(templateContext.cue?.sourceStatus).toBe('completed'); + expect(templateContext.cue?.sourceExitCode).toBe('0'); + expect(templateContext.cue?.sourceDuration).toBe('15000'); + expect(templateContext.cue?.sourceTriggeredBy).toBe('lint-on-save'); + + mockChild.emit('close', 0); + await resultPromise; + }); + }); + + describe('stopCueRun', () => { + it('should return false for unknown runId', () => { + expect(stopCueRun('nonexistent')).toBe(false); + }); + + it('should send SIGTERM to a running process', async () => { + const config = createExecutionConfig({ runId: 'stop-test-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const childKill = vi.spyOn(mockChild, 'kill'); + + const stopped = stopCueRun('stop-test-run'); + expect(stopped).toBe(true); + expect(childKill).toHaveBeenCalledWith('SIGTERM'); + + mockChild.emit('close', null); + await resultPromise; + }); + }); + + describe('recordCueHistoryEntry', () => { + it('should construct a proper CUE history entry', () => { + const result: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Watch config', + event: createMockEvent(), + status: 'completed', + stdout: 'Task completed successfully', + stderr: '', + exitCode: 0, + durationMs: 5000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:05.000Z', + }; + + const session = createMockSession(); + const entry = recordCueHistoryEntry(result, session); + + expect(entry.type).toBe('CUE'); + expect(entry.id).toBe('test-uuid-1234'); + expect(entry.summary).toBe('[CUE] "Watch config" (file.changed)'); + expect(entry.fullResponse).toBe('Task completed successfully'); + expect(entry.projectPath).toBe('/projects/test'); + expect(entry.sessionId).toBe('session-1'); + expect(entry.sessionName).toBe('Test Session'); + expect(entry.success).toBe(true); + expect(entry.elapsedTimeMs).toBe(5000); + expect(entry.cueTriggerName).toBe('Watch config'); + expect(entry.cueEventType).toBe('file.changed'); + }); + + it('should set success to false for failed runs', () => { + const result: CueRunResult = { + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Periodic check', + event: createMockEvent({ type: 'time.heartbeat' }), + status: 'failed', + stdout: '', + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:02.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.success).toBe(false); + expect(entry.summary).toBe('[CUE] "Periodic check" (time.heartbeat)'); + }); + + it('should truncate long stdout in fullResponse', () => { + const longOutput = 'x'.repeat(15000); + const result: CueRunResult = { + runId: 'run-3', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Large output', + event: createMockEvent(), + status: 'completed', + stdout: longOutput, + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse?.length).toBe(10000); + }); + + it('should set fullResponse to undefined when stdout is empty', () => { + const result: CueRunResult = { + runId: 'run-4', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Silent run', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 500, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.500Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.fullResponse).toBeUndefined(); + }); + + it('should populate cueSourceSession from agent.completed event payload', () => { + const result: CueRunResult = { + runId: 'run-5', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'On build done', + event: createMockEvent({ + type: 'agent.completed', + payload: { + sourceSession: 'builder-agent', + }, + }), + status: 'completed', + stdout: 'Done', + stderr: '', + exitCode: 0, + durationMs: 3000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:03.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBe('builder-agent'); + expect(entry.cueEventType).toBe('agent.completed'); + }); + + it('should set cueSourceSession to undefined when not present in payload', () => { + const result: CueRunResult = { + runId: 'run-6', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'Timer check', + event: createMockEvent({ + type: 'time.heartbeat', + payload: { interval_minutes: 5 }, + }), + status: 'completed', + stdout: 'OK', + stderr: '', + exitCode: 0, + durationMs: 1000, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:01.000Z', + }; + + const entry = recordCueHistoryEntry(result, createMockSession()); + + expect(entry.cueSourceSession).toBeUndefined(); + }); + + it('should use projectRoot for projectPath, falling back to cwd', () => { + const session = createMockSession({ projectRoot: '', cwd: '/fallback/cwd' }); + const result: CueRunResult = { + runId: 'run-7', + sessionId: 'session-1', + sessionName: 'Test', + subscriptionName: 'Test', + event: createMockEvent(), + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-03-01T00:00:00.100Z', + }; + + const entry = recordCueHistoryEntry(result, session); + + // Empty string is falsy, so should fall back to cwd + expect(entry.projectPath).toBe('/fallback/cwd'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-file-watcher.test.ts b/src/__tests__/main/cue/cue-file-watcher.test.ts new file mode 100644 index 000000000..7d4d8e5d9 --- /dev/null +++ b/src/__tests__/main/cue/cue-file-watcher.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for the Cue file watcher provider. + * + * Tests cover: + * - Chokidar watcher creation with correct options + * - Per-file debouncing of change events + * - CueEvent construction with correct payload + * - Cleanup of timers and watcher + * - Error handling + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock crypto.randomUUID +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => 'test-uuid-1234'), +})); + +// Mock chokidar +const mockOn = vi.fn().mockReturnThis(); +const mockClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockOn, + close: mockClose, + })), +})); + +import { createCueFileWatcher } from '../../../main/cue/cue-file-watcher'; +import type { CueEvent } from '../../../main/cue/cue-types'; +import * as chokidar from 'chokidar'; + +describe('cue-file-watcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('creates a chokidar watcher with correct options', () => { + createCueFileWatcher({ + watchGlob: 'src/**/*.ts', + projectRoot: '/projects/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test-trigger', + }); + + expect(chokidar.watch).toHaveBeenCalledWith('src/**/*.ts', { + cwd: '/projects/test', + ignoreInitial: true, + persistent: true, + }); + }); + + it('registers change, add, and unlink handlers', () => { + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const registeredEvents = mockOn.mock.calls.map((call) => call[0]); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('unlink'); + expect(registeredEvents).toContain('error'); + }); + + it('debounces events per file', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + expect(changeHandler).toBeDefined(); + + // Rapid changes to the same file + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + changeHandler('src/index.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce events from different files', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + + changeHandler('src/a.ts'); + changeHandler('src/b.ts'); + + vi.advanceTimersByTime(5000); + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it('constructs a CueEvent with correct payload for change events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'my-trigger', + }); + + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + vi.advanceTimersByTime(100); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.id).toBe('test-uuid-1234'); + expect(event.type).toBe('file.changed'); + expect(event.triggerName).toBe('my-trigger'); + expect(event.payload.filename).toBe('index.ts'); + expect(event.payload.extension).toBe('.ts'); + expect(event.payload.changeType).toBe('change'); + }); + + it('reports correct changeType for add events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const addHandler = mockOn.mock.calls.find((call) => call[0] === 'add')?.[1]; + addHandler('src/new.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('add'); + }); + + it('reports correct changeType for unlink events', () => { + const onEvent = vi.fn(); + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 100, + onEvent, + triggerName: 'test', + }); + + const unlinkHandler = mockOn.mock.calls.find((call) => call[0] === 'unlink')?.[1]; + unlinkHandler('src/deleted.ts'); + vi.advanceTimersByTime(100); + + const event: CueEvent = onEvent.mock.calls[0][0]; + expect(event.payload.changeType).toBe('unlink'); + }); + + it('cleanup function clears timers and closes watcher', () => { + const onEvent = vi.fn(); + const cleanup = createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent, + triggerName: 'test', + }); + + // Trigger a change to create a pending timer + const changeHandler = mockOn.mock.calls.find((call) => call[0] === 'change')?.[1]; + changeHandler('src/index.ts'); + + cleanup(); + + // Advance past debounce — event should NOT fire since cleanup was called + vi.advanceTimersByTime(5000); + expect(onEvent).not.toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + }); + + it('handles watcher errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'test', + }); + + const errorHandler = mockOn.mock.calls.find((call) => call[0] === 'error')?.[1]; + expect(errorHandler).toBeDefined(); + + // Should not throw + errorHandler(new Error('Watch error')); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/main/cue/cue-filter.test.ts b/src/__tests__/main/cue/cue-filter.test.ts new file mode 100644 index 000000000..03203b396 --- /dev/null +++ b/src/__tests__/main/cue/cue-filter.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for the Cue filter matching engine. + * + * Tests cover: + * - Exact string matching + * - Negation (!value) + * - Numeric comparisons (>, <, >=, <=) + * - Glob pattern matching (*) + * - Boolean matching + * - Numeric equality + * - Dot-notation nested key access + * - AND logic (all conditions must pass) + * - Missing payload fields + * - describeFilter human-readable output + */ + +import { describe, it, expect } from 'vitest'; +import { matchesFilter, describeFilter } from '../../../main/cue/cue-filter'; + +describe('cue-filter', () => { + describe('matchesFilter', () => { + describe('exact string matching', () => { + it('matches exact string values', () => { + expect(matchesFilter({ extension: '.ts' }, { extension: '.ts' })).toBe(true); + }); + + it('rejects non-matching string values', () => { + expect(matchesFilter({ extension: '.js' }, { extension: '.ts' })).toBe(false); + }); + + it('coerces payload value to string for comparison', () => { + expect(matchesFilter({ count: 42 }, { count: '42' })).toBe(true); + }); + }); + + describe('negation (!value)', () => { + it('matches when value does not equal', () => { + expect(matchesFilter({ status: 'active' }, { status: '!archived' })).toBe(true); + }); + + it('rejects when value equals the negated term', () => { + expect(matchesFilter({ status: 'archived' }, { status: '!archived' })).toBe(false); + }); + }); + + describe('numeric comparisons', () => { + it('matches greater than', () => { + expect(matchesFilter({ size: 1500 }, { size: '>1000' })).toBe(true); + }); + + it('rejects not greater than', () => { + expect(matchesFilter({ size: 500 }, { size: '>1000' })).toBe(false); + }); + + it('rejects equal for greater than', () => { + expect(matchesFilter({ size: 1000 }, { size: '>1000' })).toBe(false); + }); + + it('matches less than', () => { + expect(matchesFilter({ priority: 3 }, { priority: '<5' })).toBe(true); + }); + + it('rejects not less than', () => { + expect(matchesFilter({ priority: 7 }, { priority: '<5' })).toBe(false); + }); + + it('matches greater than or equal', () => { + expect(matchesFilter({ score: 100 }, { score: '>=100' })).toBe(true); + expect(matchesFilter({ score: 101 }, { score: '>=100' })).toBe(true); + }); + + it('rejects less than for >=', () => { + expect(matchesFilter({ score: 99 }, { score: '>=100' })).toBe(false); + }); + + it('matches less than or equal', () => { + expect(matchesFilter({ count: 10 }, { count: '<=10' })).toBe(true); + expect(matchesFilter({ count: 9 }, { count: '<=10' })).toBe(true); + }); + + it('rejects greater than for <=', () => { + expect(matchesFilter({ count: 11 }, { count: '<=10' })).toBe(false); + }); + + it('handles string payload values with numeric comparison', () => { + expect(matchesFilter({ size: '1500' }, { size: '>1000' })).toBe(true); + }); + }); + + describe('glob pattern matching', () => { + it('matches simple glob patterns', () => { + expect(matchesFilter({ path: 'file.ts' }, { path: '*.ts' })).toBe(true); + }); + + it('rejects non-matching glob patterns', () => { + expect(matchesFilter({ path: 'file.js' }, { path: '*.ts' })).toBe(false); + }); + + it('matches complex glob patterns', () => { + expect(matchesFilter({ path: 'src/components/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe( + true + ); + }); + + it('rejects non-matching complex patterns', () => { + expect(matchesFilter({ path: 'test/Button.tsx' }, { path: 'src/**/*.tsx' })).toBe(false); + }); + }); + + describe('boolean matching', () => { + it('matches true boolean', () => { + expect(matchesFilter({ active: true }, { active: true })).toBe(true); + }); + + it('rejects false when expecting true', () => { + expect(matchesFilter({ active: false }, { active: true })).toBe(false); + }); + + it('matches false boolean', () => { + expect(matchesFilter({ active: false }, { active: false })).toBe(true); + }); + + it('rejects true when expecting false', () => { + expect(matchesFilter({ active: true }, { active: false })).toBe(false); + }); + }); + + describe('numeric equality', () => { + it('matches exact numeric values', () => { + expect(matchesFilter({ exitCode: 0 }, { exitCode: 0 })).toBe(true); + }); + + it('rejects non-matching numeric values', () => { + expect(matchesFilter({ exitCode: 1 }, { exitCode: 0 })).toBe(false); + }); + }); + + describe('dot-notation nested access', () => { + it('resolves nested payload fields', () => { + const payload = { source: { status: 'completed' } }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(true); + }); + + it('returns false for missing nested path', () => { + const payload = { source: {} }; + expect(matchesFilter(payload, { 'source.status': 'completed' })).toBe(false); + }); + + it('handles deeply nested access', () => { + const payload = { a: { b: { c: 'deep' } } }; + expect(matchesFilter(payload, { 'a.b.c': 'deep' })).toBe(true); + }); + }); + + describe('AND logic', () => { + it('requires all conditions to pass', () => { + const payload = { extension: '.ts', changeType: 'change', path: 'src/index.ts' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(true); + }); + + it('fails if any condition does not pass', () => { + const payload = { extension: '.js', changeType: 'change' }; + const filter = { extension: '.ts', changeType: 'change' }; + expect(matchesFilter(payload, filter)).toBe(false); + }); + }); + + describe('missing payload fields', () => { + it('fails when payload field is undefined', () => { + expect(matchesFilter({}, { extension: '.ts' })).toBe(false); + }); + + it('fails when nested payload field is undefined', () => { + expect(matchesFilter({ source: {} }, { 'source.missing': 'value' })).toBe(false); + }); + }); + + describe('empty filter', () => { + it('matches everything when filter is empty', () => { + expect(matchesFilter({ any: 'value' }, {})).toBe(true); + }); + }); + }); + + describe('describeFilter', () => { + it('describes exact string match', () => { + expect(describeFilter({ extension: '.ts' })).toBe('extension == ".ts"'); + }); + + it('describes negation', () => { + expect(describeFilter({ status: '!archived' })).toBe('status != archived'); + }); + + it('describes greater than', () => { + expect(describeFilter({ size: '>1000' })).toBe('size > 1000'); + }); + + it('describes less than', () => { + expect(describeFilter({ priority: '<5' })).toBe('priority < 5'); + }); + + it('describes greater than or equal', () => { + expect(describeFilter({ score: '>=100' })).toBe('score >= 100'); + }); + + it('describes less than or equal', () => { + expect(describeFilter({ count: '<=10' })).toBe('count <= 10'); + }); + + it('describes glob pattern', () => { + expect(describeFilter({ path: '*.ts' })).toBe('path matches *.ts'); + }); + + it('describes boolean', () => { + expect(describeFilter({ active: true })).toBe('active is true'); + }); + + it('describes numeric equality', () => { + expect(describeFilter({ exitCode: 0 })).toBe('exitCode == 0'); + }); + + it('joins multiple conditions with AND', () => { + const result = describeFilter({ extension: '.ts', status: '!archived' }); + expect(result).toBe('extension == ".ts" AND status != archived'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-github-poller.test.ts b/src/__tests__/main/cue/cue-github-poller.test.ts new file mode 100644 index 000000000..fd95faa06 --- /dev/null +++ b/src/__tests__/main/cue/cue-github-poller.test.ts @@ -0,0 +1,602 @@ +/** + * Tests for the Cue GitHub poller provider. + * + * Tests cover: + * - gh CLI availability check + * - Repo auto-detection + * - PR and issue polling with event emission + * - Seen-item tracking and first-run seeding + * - CueEvent payload shapes + * - Body truncation + * - Cleanup and timer management + * - Error handling + * + * Note: The poller uses execFile (not exec) to avoid shell injection. + * The mock here simulates execFile's callback-based API via promisify. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Hoisted mock references (vi.hoisted runs before vi.mock hoisting) +const { + mockExecFile, + mockIsGitHubItemSeen, + mockMarkGitHubItemSeen, + mockHasAnyGitHubSeen, + mockPruneGitHubSeen, +} = vi.hoisted(() => ({ + mockExecFile: vi.fn(), + mockIsGitHubItemSeen: vi.fn<(subId: string, key: string) => boolean>().mockReturnValue(false), + mockMarkGitHubItemSeen: vi.fn<(subId: string, key: string) => void>(), + mockHasAnyGitHubSeen: vi.fn<(subId: string) => boolean>().mockReturnValue(true), + mockPruneGitHubSeen: vi.fn<(olderThanMs: number) => void>(), +})); + +// Mock crypto.randomUUID +let uuidCounter = 0; +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `test-uuid-${++uuidCounter}`), +})); + +// Mock child_process.execFile (safe — no shell injection risk) +vi.mock('child_process', () => ({ + default: { execFile: mockExecFile }, + execFile: mockExecFile, +})); + +// Mock cue-db functions +vi.mock('../../../main/cue/cue-db', () => ({ + isGitHubItemSeen: (subId: string, key: string) => mockIsGitHubItemSeen(subId, key), + markGitHubItemSeen: (subId: string, key: string) => mockMarkGitHubItemSeen(subId, key), + hasAnyGitHubSeen: (subId: string) => mockHasAnyGitHubSeen(subId), + pruneGitHubSeen: (olderThanMs: number) => mockPruneGitHubSeen(olderThanMs), +})); + +import { + createCueGitHubPoller, + type CueGitHubPollerConfig, +} from '../../../main/cue/cue-github-poller'; + +// Helper: make mockExecFile (callback-style) resolve/reject +function setupExecFile(responses: Record) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + for (const [pattern, stdout] of Object.entries(responses)) { + if (key.includes(pattern)) { + cb(null, stdout, ''); + return; + } + } + cb(new Error(`Command not found: ${key}`), '', ''); + } + ); +} + +function setupExecFileReject(pattern: string, errorMsg: string) { + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes(pattern)) { + cb(new Error(errorMsg), '', ''); + return; + } + cb(null, '', ''); + } + ); +} + +const samplePRs = [ + { + number: 1, + title: 'Add feature', + author: { login: 'alice' }, + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'OPEN', + isDraft: false, + labels: [{ name: 'enhancement' }], + headRefName: 'feature-branch', + baseRefName: 'main', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 2, + title: 'Fix bug', + author: { login: 'bob' }, + url: 'https://github.com/owner/repo/pull/2', + body: 'Bug fix', + state: 'OPEN', + isDraft: true, + labels: [{ name: 'bug' }, { name: 'urgent' }], + headRefName: 'fix-branch', + baseRefName: 'main', + createdAt: '2026-03-01T12:00:00Z', + updatedAt: '2026-03-02T12:00:00Z', + }, + { + number: 3, + title: 'Docs update', + author: { login: 'charlie' }, + url: 'https://github.com/owner/repo/pull/3', + body: null, + state: 'OPEN', + isDraft: false, + labels: [], + headRefName: 'docs', + baseRefName: 'main', + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +const sampleIssues = [ + { + number: 10, + title: 'Bug report', + author: { login: 'dave' }, + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'OPEN', + labels: [{ name: 'bug' }], + assignees: [{ login: 'alice' }, { login: 'bob' }], + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + }, + { + number: 11, + title: 'Feature request', + author: { login: 'eve' }, + url: 'https://github.com/owner/repo/issues/11', + body: 'Please add this', + state: 'OPEN', + labels: [], + assignees: [], + createdAt: '2026-03-02T00:00:00Z', + updatedAt: '2026-03-03T00:00:00Z', + }, +]; + +function makeConfig(overrides: Partial = {}): CueGitHubPollerConfig { + return { + eventType: 'github.pull_request', + repo: 'owner/repo', + pollMinutes: 5, + projectRoot: '/projects/test', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-trigger', + subscriptionId: 'session-1:test-sub', + ...overrides, + }; +} + +describe('cue-github-poller', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + uuidCounter = 0; + mockIsGitHubItemSeen.mockReturnValue(false); + mockHasAnyGitHubSeen.mockReturnValue(true); // not first run by default + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('gh CLI not available — warning logged, no events fired, no crash', async () => { + const config = makeConfig(); + setupExecFileReject('--version', 'gh not found'); + + const cleanup = createCueGitHubPoller(config); + + // Advance past initial 2s delay + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('GitHub CLI (gh) not found') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('repo auto-detection — resolves from gh repo view', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ + '--version': '2.0.0', + 'repo view': 'auto-owner/auto-repo\n', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have auto-detected repo and used it in pr list + expect(mockExecFile).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining(['repo', 'view']), + expect.anything(), + expect.any(Function) + ); + + cleanup(); + }); + + it('repo auto-detection failure — warning logged, poll skipped', async () => { + const config = makeConfig({ repo: undefined }); + setupExecFile({ '--version': '2.0.0' }); + // repo view will hit the default reject + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Could not auto-detect repo') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('PR polling — new items fire events', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(3); + + cleanup(); + }); + + it('PR polling — seen items are skipped', async () => { + mockIsGitHubItemSeen.mockImplementation(((_subId: string, itemKey: string) => { + return itemKey === 'pr:owner/repo:2'; // PR #2 already seen + }) as (subId: string, key: string) => boolean); + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + + cleanup(); + }); + + it('PR polling — marks items as seen with correct keys', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:1'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:2'); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', 'pr:owner/repo:3'); + + cleanup(); + }); + + it('issue polling — new items fire events with assignees', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify(sampleIssues), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).toHaveBeenCalledTimes(2); + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.assignees).toBe('alice,bob'); + + cleanup(); + }); + + it('CueEvent payload shape for PRs', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([samplePRs[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.pull_request'); + expect(event.triggerName).toBe('test-trigger'); + expect(event.payload).toEqual({ + type: 'pull_request', + number: 1, + title: 'Add feature', + author: 'alice', + url: 'https://github.com/owner/repo/pull/1', + body: 'Feature description', + state: 'open', + draft: false, + labels: 'enhancement', + head_branch: 'feature-branch', + base_branch: 'main', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('CueEvent payload shape for issues', async () => { + const config = makeConfig({ eventType: 'github.issue' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify([sampleIssues[0]]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.type).toBe('github.issue'); + expect(event.payload).toEqual({ + type: 'issue', + number: 10, + title: 'Bug report', + author: 'dave', + url: 'https://github.com/owner/repo/issues/10', + body: 'Something is broken', + state: 'open', + labels: 'bug', + assignees: 'alice,bob', + repo: 'owner/repo', + created_at: '2026-03-01T00:00:00Z', + updated_at: '2026-03-02T00:00:00Z', + }); + + cleanup(); + }); + + it('body truncation — body exceeding 5000 chars is truncated', async () => { + const longBody = 'x'.repeat(6000); + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([{ ...samplePRs[0], body: longBody }]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.body).toHaveLength(5000); + + cleanup(); + }); + + it('first-run seeding — no events on first poll', async () => { + mockHasAnyGitHubSeen.mockReturnValue(false); // first run + + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onEvent).not.toHaveBeenCalled(); + expect(mockMarkGitHubItemSeen).toHaveBeenCalledTimes(3); + expect(config.onLog).toHaveBeenCalledWith( + 'info', + expect.stringContaining('seeded 3 existing pull_request(s)') + ); + + cleanup(); + }); + + it('second poll fires events after seeding', async () => { + // First poll: seeding (no seen records) + mockHasAnyGitHubSeen.mockReturnValueOnce(false); + // Second poll: has seen records now + mockHasAnyGitHubSeen.mockReturnValue(true); + + const newPR = { + ...samplePRs[0], + number: 99, + title: 'New PR', + }; + + const config = makeConfig({ pollMinutes: 1 }); + + let callCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + callCount++; + if (callCount === 1) { + cb(null, JSON.stringify(samplePRs), ''); + } else { + cb(null, JSON.stringify([newPR]), ''); + } + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // First poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(config.onEvent).not.toHaveBeenCalled(); // seeded + + // Second poll at 2s + 1min + await vi.advanceTimersByTimeAsync(60000); + expect(config.onEvent).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('cleanup stops polling', async () => { + const config = makeConfig({ pollMinutes: 1 }); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + const cleanup = createCueGitHubPoller(config); + + // First poll + await vi.advanceTimersByTimeAsync(2000); + const callCountAfterFirst = (config.onEvent as ReturnType).mock.calls.length; + + cleanup(); + + // Advance past poll interval — no new polls should occur + await vi.advanceTimersByTimeAsync(600000); + expect((config.onEvent as ReturnType).mock.calls.length).toBe( + callCountAfterFirst + ); + }); + + it('initial poll delay — first poll at 2s, not immediately', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + createCueGitHubPoller(config); + + // At 0ms, nothing should have happened + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 1999ms, still nothing + await vi.advanceTimersByTimeAsync(1999); + expect(mockExecFile).not.toHaveBeenCalled(); + + // At 2000ms, poll starts + await vi.advanceTimersByTimeAsync(1); + expect(mockExecFile).toHaveBeenCalled(); + }); + + it('poll interval — subsequent polls at configured interval', async () => { + const config = makeConfig({ pollMinutes: 2 }); + let pollCount = 0; + mockExecFile.mockImplementation( + ( + cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void + ) => { + const key = `${cmd} ${args.join(' ')}`; + if (key.includes('--version')) { + cb(null, '2.0.0', ''); + } else if (key.includes('pr list')) { + pollCount++; + cb(null, JSON.stringify([]), ''); + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + + // Initial poll at 2s + await vi.advanceTimersByTimeAsync(2000); + expect(pollCount).toBe(1); + + // Second poll at 2s + 2min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(2); + + // Third poll at 2s + 4min + await vi.advanceTimersByTimeAsync(120000); + expect(pollCount).toBe(3); + + cleanup(); + }); + + it('gh parse error — invalid JSON from gh, error logged, no crash', async () => { + const config = makeConfig(); + setupExecFile({ + '--version': '2.0.0', + 'pr list': 'not valid json{{{', + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(config.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('GitHub poll error') + ); + expect(config.onEvent).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('stopped during iteration — remaining items skipped', async () => { + const config = makeConfig(); + + // Track onEvent calls to call cleanup mid-iteration + let cleanupFn: (() => void) | null = null; + let eventCallCount = 0; + const originalOnEvent = vi.fn(() => { + eventCallCount++; + if (eventCallCount === 1 && cleanupFn) { + cleanupFn(); // Stop after first event + } + }); + config.onEvent = originalOnEvent; + + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(samplePRs), + }); + + cleanupFn = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should have fired 1 event then stopped (remaining 2 skipped) + expect(eventCallCount).toBe(1); + }); +}); diff --git a/src/__tests__/main/cue/cue-ipc-handlers.test.ts b/src/__tests__/main/cue/cue-ipc-handlers.test.ts new file mode 100644 index 000000000..94f09595c --- /dev/null +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -0,0 +1,378 @@ +/** + * Tests for Cue IPC handlers. + * + * Tests cover: + * - Handler registration with ipcMain.handle + * - Delegation to CueEngine methods (getStatus, getActiveRuns, etc.) + * - YAML read/write/validate operations + * - Engine enable/disable controls + * - Error handling when engine is not initialized + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; + +// Track registered IPC handlers +const registeredHandlers = new Map unknown>(); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => `/mock-user-data/${name}`), + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(channel, handler); + }), + }, +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + join: vi.fn((...args: string[]) => args.join('/')), + }; +}); + +vi.mock('js-yaml', () => ({ + load: vi.fn(), +})); + +vi.mock('../../../main/utils/ipcHandler', () => ({ + withIpcErrorLogging: vi.fn( + ( + _opts: unknown, + handler: (...args: unknown[]) => unknown + ): ((_event: unknown, ...args: unknown[]) => unknown) => { + return (_event: unknown, ...args: unknown[]) => handler(...args); + } + ), +})); + +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + validateCueConfig: vi.fn(), + resolveCueConfigPath: vi.fn(), +})); + +vi.mock('../../../main/cue/cue-types', () => ({ + CUE_YAML_FILENAME: 'maestro-cue.yaml', // legacy name kept in cue-types for compat +})); + +vi.mock('../../../shared/maestro-paths', () => ({ + CUE_CONFIG_PATH: '.maestro/cue.yaml', + MAESTRO_DIR: '.maestro', +})); + +import { registerCueHandlers } from '../../../main/ipc/handlers/cue'; +import { validateCueConfig, resolveCueConfigPath } from '../../../main/cue/cue-yaml-loader'; +import * as yaml from 'js-yaml'; + +// Create a mock CueEngine +function createMockEngine() { + return { + getStatus: vi.fn().mockReturnValue([]), + getActiveRuns: vi.fn().mockReturnValue([]), + getActivityLog: vi.fn().mockReturnValue([]), + start: vi.fn(), + stop: vi.fn(), + stopRun: vi.fn().mockReturnValue(true), + stopAll: vi.fn(), + refreshSession: vi.fn(), + isEnabled: vi.fn().mockReturnValue(false), + }; +} + +describe('Cue IPC Handlers', () => { + let mockEngine: ReturnType; + + beforeEach(() => { + registeredHandlers.clear(); + vi.clearAllMocks(); + mockEngine = createMockEngine(); + }); + + afterEach(() => { + registeredHandlers.clear(); + }); + + function registerAndGetHandler(channel: string) { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`Handler for channel "${channel}" not registered`); + } + return handler; + } + + describe('handler registration', () => { + it('should register all expected IPC channels', () => { + registerCueHandlers({ + getCueEngine: () => mockEngine as any, + }); + + const expectedChannels = [ + 'cue:getStatus', + 'cue:getActiveRuns', + 'cue:getActivityLog', + 'cue:enable', + 'cue:disable', + 'cue:stopRun', + 'cue:stopAll', + 'cue:refreshSession', + 'cue:readYaml', + 'cue:writeYaml', + 'cue:deleteYaml', + 'cue:validateYaml', + 'cue:savePipelineLayout', + 'cue:loadPipelineLayout', + ]; + + for (const channel of expectedChannels) { + expect(registeredHandlers.has(channel)).toBe(true); + } + }); + }); + + describe('engine not initialized', () => { + it('should throw when engine is null', async () => { + registerCueHandlers({ + getCueEngine: () => null, + }); + + const handler = registeredHandlers.get('cue:getStatus')!; + await expect(handler(null)).rejects.toThrow('Cue engine not initialized'); + }); + }); + + describe('cue:getStatus', () => { + it('should delegate to engine.getStatus()', async () => { + const mockStatus = [ + { + sessionId: 's1', + sessionName: 'Test', + toolType: 'claude-code', + enabled: true, + subscriptionCount: 2, + activeRuns: 0, + }, + ]; + mockEngine.getStatus.mockReturnValue(mockStatus); + + const handler = registerAndGetHandler('cue:getStatus'); + const result = await handler(null); + expect(result).toEqual(mockStatus); + expect(mockEngine.getStatus).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActiveRuns', () => { + it('should delegate to engine.getActiveRuns()', async () => { + const mockRuns = [{ runId: 'r1', status: 'running' }]; + mockEngine.getActiveRuns.mockReturnValue(mockRuns); + + const handler = registerAndGetHandler('cue:getActiveRuns'); + const result = await handler(null); + expect(result).toEqual(mockRuns); + expect(mockEngine.getActiveRuns).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:getActivityLog', () => { + it('should delegate to engine.getActivityLog() with limit', async () => { + const mockLog = [{ runId: 'r1', status: 'completed' }]; + mockEngine.getActivityLog.mockReturnValue(mockLog); + + const handler = registerAndGetHandler('cue:getActivityLog'); + const result = await handler(null, { limit: 10 }); + expect(result).toEqual(mockLog); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(10); + }); + + it('should pass undefined limit when not provided', async () => { + const handler = registerAndGetHandler('cue:getActivityLog'); + await handler(null, {}); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(undefined); + }); + }); + + describe('cue:enable', () => { + it('should call engine.start()', async () => { + const handler = registerAndGetHandler('cue:enable'); + await handler(null); + expect(mockEngine.start).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:disable', () => { + it('should call engine.stop()', async () => { + const handler = registerAndGetHandler('cue:disable'); + await handler(null); + expect(mockEngine.stop).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:stopRun', () => { + it('should delegate to engine.stopRun() with runId', async () => { + mockEngine.stopRun.mockReturnValue(true); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'run-123' }); + expect(result).toBe(true); + expect(mockEngine.stopRun).toHaveBeenCalledWith('run-123'); + }); + + it('should return false when run not found', async () => { + mockEngine.stopRun.mockReturnValue(false); + const handler = registerAndGetHandler('cue:stopRun'); + const result = await handler(null, { runId: 'nonexistent' }); + expect(result).toBe(false); + }); + }); + + describe('cue:stopAll', () => { + it('should call engine.stopAll()', async () => { + const handler = registerAndGetHandler('cue:stopAll'); + await handler(null); + expect(mockEngine.stopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('cue:refreshSession', () => { + it('should delegate to engine.refreshSession()', async () => { + const handler = registerAndGetHandler('cue:refreshSession'); + await handler(null, { sessionId: 's1', projectRoot: '/projects/test' }); + expect(mockEngine.refreshSession).toHaveBeenCalledWith('s1', '/projects/test'); + }); + }); + + describe('cue:readYaml', () => { + it('should return file content when file exists', async () => { + vi.mocked(resolveCueConfigPath).mockReturnValue('/projects/test/.maestro/cue.yaml'); + vi.mocked(fs.readFileSync).mockReturnValue('subscriptions: []'); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBe('subscriptions: []'); + expect(resolveCueConfigPath).toHaveBeenCalledWith('/projects/test'); + expect(fs.readFileSync).toHaveBeenCalledWith('/projects/test/.maestro/cue.yaml', 'utf-8'); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(resolveCueConfigPath).mockReturnValue(null); + + const handler = registerAndGetHandler('cue:readYaml'); + const result = await handler(null, { projectRoot: '/projects/test' }); + expect(result).toBeNull(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('cue:writeYaml', () => { + it('should write content to the correct file path', async () => { + const content = 'subscriptions:\n - name: test\n event: time.heartbeat'; + vi.mocked(fs.existsSync).mockReturnValue(true); // .maestro dir exists + + const handler = registerAndGetHandler('cue:writeYaml'); + await handler(null, { projectRoot: '/projects/test', content }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/projects/test/.maestro/cue.yaml', + content, + 'utf-8' + ); + }); + }); + + describe('cue:validateYaml', () => { + it('should return valid result for valid YAML', async () => { + const content = 'subscriptions: []'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: [] }); + vi.mocked(validateCueConfig).mockReturnValue({ valid: true, errors: [] }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ valid: true, errors: [] }); + expect(yaml.load).toHaveBeenCalledWith(content); + expect(validateCueConfig).toHaveBeenCalledWith({ subscriptions: [] }); + }); + + it('should return errors for invalid config', async () => { + const content = 'subscriptions: invalid'; + vi.mocked(yaml.load).mockReturnValue({ subscriptions: 'invalid' }); + vi.mocked(validateCueConfig).mockReturnValue({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + }); + + it('should return parse error for malformed YAML', async () => { + const content = '{{invalid yaml'; + vi.mocked(yaml.load).mockImplementation(() => { + throw new Error('bad indentation'); + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = await handler(null, { content }); + expect(result).toEqual({ + valid: false, + errors: ['YAML parse error: bad indentation'], + }); + }); + }); + + describe('cue:savePipelineLayout', () => { + it('should write layout to JSON file', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 0, y: 0, zoom: 1 }, + }; + + const handler = registerAndGetHandler('cue:savePipelineLayout'); + await handler(null, { layout }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('cue-pipeline-layout.json'), + JSON.stringify(layout, null, 2), + 'utf-8' + ); + }); + }); + + describe('cue:loadPipelineLayout', () => { + it('should return layout when file exists', async () => { + const layout = { + pipelines: [{ id: 'p1', name: 'Pipeline 1', color: '#06b6d4', nodes: [], edges: [] }], + selectedPipelineId: 'p1', + viewport: { x: 100, y: 200, zoom: 1.5 }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(layout)); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toEqual(layout); + }); + + it('should return null when file does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const handler = registerAndGetHandler('cue:loadPipelineLayout'); + const result = await handler(null); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-reconciler.test.ts b/src/__tests__/main/cue/cue-reconciler.test.ts new file mode 100644 index 000000000..b212c79bc --- /dev/null +++ b/src/__tests__/main/cue/cue-reconciler.test.ts @@ -0,0 +1,393 @@ +/** + * Tests for the Cue Time Event Reconciler (cue-reconciler.ts). + * + * Tests cover: + * - Missed interval calculation + * - Single catch-up event per subscription (no flooding) + * - Skipping file.changed and agent.completed events + * - Skipping disabled subscriptions + * - Reconciled payload metadata (reconciled: true, missedCount) + * - Zero-gap and negative-gap edge cases + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { reconcileMissedTimeEvents } from '../../../main/cue/cue-reconciler'; +import type { ReconcileConfig, ReconcileSessionInfo } from '../../../main/cue/cue-reconciler'; +import type { CueConfig, CueEvent, CueSubscription } from '../../../main/cue/cue-types'; + +function createConfig(subscriptions: CueSubscription[]): CueConfig { + return { + subscriptions, + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + }; +} + +describe('reconcileMissedTimeEvents', () => { + let dispatched: Array<{ sessionId: string; sub: CueSubscription; event: CueEvent }>; + let logged: Array<{ level: string; message: string }>; + + beforeEach(() => { + dispatched = []; + logged = []; + }); + + function makeConfig(overrides: Partial = {}): ReconcileConfig { + return { + sleepStartMs: Date.now() - 60 * 60 * 1000, // 1 hour ago + wakeTimeMs: Date.now(), + sessions: new Map(), + onDispatch: (sessionId, sub, event) => { + dispatched.push({ sessionId, sub, event }); + }, + onLog: (level, message) => { + logged.push({ level, message }); + }, + ...overrides, + }; + } + + it('should fire one catch-up event for a missed interval', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-15m', + event: 'time.heartbeat', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 1 hour means 4 intervals of 15m were missed + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // Should fire exactly one catch-up event (not 4) + expect(dispatched).toHaveLength(1); + expect(dispatched[0].sessionId).toBe('session-1'); + expect(dispatched[0].event.type).toBe('time.heartbeat'); + expect(dispatched[0].event.triggerName).toBe('every-15m'); + expect(dispatched[0].event.payload.reconciled).toBe(true); + expect(dispatched[0].event.payload.missedCount).toBe(4); + }); + + it('should skip when no intervals were missed', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'every-2h', + event: 'time.heartbeat', + enabled: true, + prompt: 'long check', + interval_minutes: 120, + }, + ]), + sessionName: 'Test Session', + }); + + // Sleep for 30 minutes — interval is 2 hours, so 0 missed + const config = makeConfig({ + sleepStartMs: Date.now() - 30 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile file.changed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'check files', + watch: 'src/**/*.ts', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should not reconcile agent.completed subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'chain-reaction', + event: 'agent.completed', + enabled: true, + prompt: 'follow up', + source_session: 'other-agent', + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should skip disabled subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'disabled-timer', + event: 'time.heartbeat', + enabled: false, + prompt: 'disabled', + interval_minutes: 5, + }, + ]), + sessionName: 'Test Session', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should handle multiple sessions with multiple subscriptions', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'fast-timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'fast check', + interval_minutes: 10, + }, + { + name: 'slow-timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'slow check', + interval_minutes: 60, + }, + { + name: 'file-watcher', + event: 'file.changed', + enabled: true, + prompt: 'watch files', + watch: '*.ts', + }, + ]), + sessionName: 'Session A', + }); + sessions.set('session-2', { + config: createConfig([ + { + name: 'another-timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'another check', + interval_minutes: 30, + }, + ]), + sessionName: 'Session B', + }); + + // 90 minutes of sleep + const config = makeConfig({ + sleepStartMs: Date.now() - 90 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + // fast-timer: 90/10 = 9 missed → 1 catch-up + // slow-timer: 90/60 = 1 missed → 1 catch-up + // file-watcher: skipped (not time.heartbeat) + // another-timer: 90/30 = 3 missed → 1 catch-up + expect(dispatched).toHaveLength(3); + + const fastTimer = dispatched.find((d) => d.event.triggerName === 'fast-timer'); + expect(fastTimer?.event.payload.missedCount).toBe(9); + + const slowTimer = dispatched.find((d) => d.event.triggerName === 'slow-timer'); + expect(slowTimer?.event.payload.missedCount).toBe(1); + + const anotherTimer = dispatched.find((d) => d.event.triggerName === 'another-timer'); + expect(anotherTimer?.event.payload.missedCount).toBe(3); + expect(anotherTimer?.sessionId).toBe('session-2'); + }); + + it('should include sleepDurationMs in the event payload', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const sleepDuration = 60 * 60 * 1000; // 1 hour + const config = makeConfig({ + sleepStartMs: Date.now() - sleepDuration, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched[0].event.payload.sleepDurationMs).toBe(sleepDuration); + }); + + it('should do nothing with zero gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now, + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should do nothing with negative gap', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'check', + interval_minutes: 5, + }, + ]), + sessionName: 'Test', + }); + + const now = Date.now(); + const config = makeConfig({ + sleepStartMs: now, + wakeTimeMs: now - 1000, // Wake before sleep (shouldn't happen, but edge case) + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); + + it('should log reconciliation for each fired catch-up', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'my-timer', + event: 'time.heartbeat', + enabled: true, + prompt: 'check', + interval_minutes: 10, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(logged.some((l) => l.message.includes('Reconciling "my-timer"'))).toBe(true); + expect(logged.some((l) => l.message.includes('6 interval(s) missed'))).toBe(true); + }); + + it('should skip subscriptions with zero interval_minutes', () => { + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'zero-interval', + event: 'time.heartbeat', + enabled: true, + prompt: 'check', + interval_minutes: 0, + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 60 * 60 * 1000, + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); +}); diff --git a/src/__tests__/main/cue/cue-sleep-wake.test.ts b/src/__tests__/main/cue/cue-sleep-wake.test.ts new file mode 100644 index 000000000..022582455 --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -0,0 +1,308 @@ +/** + * Tests for the CueEngine sleep/wake detection and reconciliation. + * + * Tests cover: + * - Heartbeat starts on engine.start() and stops on engine.stop() + * - Sleep detection triggers reconciler when gap >= 2 minutes + * - No reconciliation when gap < 2 minutes + * - Database pruning on start + * - Graceful handling of missing/uninitialized database + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; + +// Track cue-db calls +const mockInitCueDb = vi.fn(); +const mockCloseCueDb = vi.fn(); +const mockUpdateHeartbeat = vi.fn(); +const mockGetLastHeartbeat = vi.fn<() => number | null>(); +const mockPruneCueEvents = vi.fn(); + +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: (...args: unknown[]) => mockInitCueDb(...args), + closeCueDb: () => mockCloseCueDb(), + updateHeartbeat: () => mockUpdateHeartbeat(), + getLastHeartbeat: () => mockGetLastHeartbeat(), + pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args), +})); + +// Track reconciler calls +const mockReconcileMissedTimeEvents = vi.fn(); +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: (...args: unknown[]) => mockReconcileMissedTimeEvents(...args), +})); + +// Mock the yaml loader +const mockLoadCueConfig = vi.fn<(projectRoot: string) => CueConfig | null>(); +const mockWatchCueYaml = vi.fn<(projectRoot: string, onChange: () => void) => () => void>(); +vi.mock('../../../main/cue/cue-yaml-loader', () => ({ + loadCueConfig: (...args: unknown[]) => mockLoadCueConfig(args[0] as string), + watchCueYaml: (...args: unknown[]) => mockWatchCueYaml(args[0] as string, args[1] as () => void), +})); + +// Mock the file watcher +vi.mock('../../../main/cue/cue-file-watcher', () => ({ + createCueFileWatcher: vi.fn(() => vi.fn()), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { CueEngine, type CueEngineDeps } from '../../../main/cue/cue-engine'; + +function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [ + { + name: 'timer-sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'check status', + interval_minutes: 15, + }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'test', + event: {} as CueEvent, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onLog: vi.fn(), + ...overrides, + }; +} + +describe('CueEngine sleep/wake detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockLoadCueConfig.mockReturnValue(createMockConfig()); + mockGetLastHeartbeat.mockReturnValue(null); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize the Cue database on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockInitCueDb).toHaveBeenCalledTimes(1); + expect(mockInitCueDb).toHaveBeenCalledWith(expect.any(Function)); + + engine.stop(); + }); + + it('should prune old events on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockPruneCueEvents).toHaveBeenCalledTimes(1); + // 7 days in milliseconds + expect(mockPruneCueEvents).toHaveBeenCalledWith(7 * 24 * 60 * 60 * 1000); + + engine.stop(); + }); + + it('should write heartbeat immediately on start', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('should write heartbeat every 30 seconds', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Initial call + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(1); + + // Advance 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(2); + + // Advance another 30 seconds + vi.advanceTimersByTime(30_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(3); + + engine.stop(); + }); + + it('should stop heartbeat on engine stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const callCount = mockUpdateHeartbeat.mock.calls.length; + engine.stop(); + + // Advance time — no more heartbeats should fire + vi.advanceTimersByTime(60_000); + expect(mockUpdateHeartbeat).toHaveBeenCalledTimes(callCount); + }); + + it('should close the database on stop', () => { + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(mockCloseCueDb).toHaveBeenCalledTimes(1); + }); + + it('should not reconcile on first start (no previous heartbeat)', () => { + mockGetLastHeartbeat.mockReturnValue(null); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should not reconcile when gap is less than 2 minutes', () => { + // Last heartbeat was 60 seconds ago (below 120s threshold) + mockGetLastHeartbeat.mockReturnValue(Date.now() - 60_000); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('should reconcile when gap exceeds 2 minutes', () => { + // Last heartbeat was 10 minutes ago + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(mockReconcileMissedTimeEvents).toHaveBeenCalledTimes(1); + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + expect(reconcileArgs.sleepStartMs).toBe(tenMinutesAgo); + expect(reconcileArgs.sessions).toBeInstanceOf(Map); + expect(typeof reconcileArgs.onDispatch).toBe('function'); + expect(typeof reconcileArgs.onLog).toBe('function'); + + engine.stop(); + }); + + it('should log sleep detection with gap duration', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(fiveMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Sleep detected (gap: 5m)') + ); + + engine.stop(); + }); + + it('should handle database initialization failure gracefully', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB init failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + // Should log the warning + expect(deps.onLog).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Failed to initialize Cue database') + ); + + engine.stop(); + }); + + it('should handle heartbeat read failure gracefully during sleep detection', () => { + mockGetLastHeartbeat.mockImplementation(() => { + throw new Error('DB read failed'); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Should not throw + expect(() => engine.start()).not.toThrow(); + + engine.stop(); + }); + + it('should pass session info to the reconciler', () => { + const tenMinutesAgo = Date.now() - 10 * 60 * 1000; + mockGetLastHeartbeat.mockReturnValue(tenMinutesAgo); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const reconcileArgs = mockReconcileMissedTimeEvents.mock.calls[0][0]; + const sessions = reconcileArgs.sessions as Map; + + // Should contain the session from our mock + expect(sessions.size).toBe(1); + expect(sessions.has('session-1')).toBe(true); + + const sessionInfo = sessions.get('session-1') as { config: CueConfig; sessionName: string }; + expect(sessionInfo.sessionName).toBe('Test Session'); + expect(sessionInfo.config.subscriptions).toHaveLength(1); + + engine.stop(); + }); +}); diff --git a/src/__tests__/main/cue/cue-task-scanner.test.ts b/src/__tests__/main/cue/cue-task-scanner.test.ts new file mode 100644 index 000000000..1446fa6cb --- /dev/null +++ b/src/__tests__/main/cue/cue-task-scanner.test.ts @@ -0,0 +1,305 @@ +/** + * Tests for the Cue task scanner module. + * + * Tests cover: + * - extractPendingTasks: parsing markdown for unchecked tasks + * - createCueTaskScanner: polling lifecycle, hash tracking, event emission + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock fs +const mockReadFileSync = vi.fn(); +const mockReaddirSync = vi.fn(); +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + readdirSync: (...args: unknown[]) => mockReaddirSync(...args), +})); + +// Mock picomatch +vi.mock('picomatch', () => ({ + default: (pattern: string) => { + // Simple mock: match files ending in .md for "**/*.md" pattern + if (pattern === '**/*.md' || pattern === 'tasks/**/*.md') { + return (file: string) => file.endsWith('.md'); + } + return () => true; + }, +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), + createHash: () => ({ + update: (content: string) => ({ + digest: () => `hash-${content.length}`, + }), + }), +})); + +import { extractPendingTasks, createCueTaskScanner } from '../../../main/cue/cue-task-scanner'; + +describe('cue-task-scanner', () => { + describe('extractPendingTasks', () => { + it('extracts unchecked tasks from markdown', () => { + const content = `# Tasks +- [ ] First task +- [x] Completed task +- [ ] Second task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0]).toEqual({ line: 2, text: 'First task' }); + expect(tasks[1]).toEqual({ line: 4, text: 'Second task' }); + }); + + it('handles indented tasks', () => { + const content = `# Project + - [ ] Nested task + - [ ] Deeply nested +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(2); + expect(tasks[0].text).toBe('Nested task'); + expect(tasks[1].text).toBe('Deeply nested'); + }); + + it('handles different list markers', () => { + const content = `- [ ] Dash task +* [ ] Star task ++ [ ] Plus task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(3); + }); + + it('returns empty array for no pending tasks', () => { + const content = `# Done +- [x] All done +- [x] Also done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(0); + }); + + it('returns empty array for empty content', () => { + const tasks = extractPendingTasks(''); + expect(tasks).toHaveLength(0); + }); + + it('skips tasks with empty text', () => { + const content = `- [ ] +- [ ] Real task +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Real task'); + }); + + it('does not match checked tasks', () => { + const content = `- [x] Done +- [X] Also done +- [ ] Not done +`; + const tasks = extractPendingTasks(content); + expect(tasks).toHaveLength(1); + expect(tasks[0].text).toBe('Not done'); + }); + }); + + describe('createCueTaskScanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a cleanup function', () => { + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('cleanup stops polling', () => { + const onEvent = vi.fn(); + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + cleanup(); + + // Advance past initial delay + vi.advanceTimersByTime(3000); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('seeds hashes on first scan without firing events', async () => { + const onEvent = vi.fn(); + + // Mock directory walk: one .md file with pending tasks + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + mockReadFileSync.mockReturnValue('- [ ] Pending task\n'); + + createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Advance past initial delay + await vi.advanceTimersByTimeAsync(3000); + + // First scan seeds hashes — should NOT fire events + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('fires event on second scan when content has changed and has pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: seed with initial content + mockReadFileSync.mockReturnValueOnce('- [ ] Initial task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan (seed) + await vi.advanceTimersByTimeAsync(3000); + expect(onEvent).not.toHaveBeenCalled(); + + // Second scan: content changed, has pending tasks + mockReadFileSync.mockReturnValue('- [ ] Initial task\n- [ ] New task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).toHaveBeenCalledTimes(1); + const event = onEvent.mock.calls[0][0]; + expect(event.type).toBe('task.pending'); + expect(event.triggerName).toBe('test-scanner'); + expect(event.payload.taskCount).toBe(2); + expect(event.payload.filename).toBe('task.md'); + + cleanup(); + }); + + it('does not fire when content unchanged', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // Same content every scan + mockReadFileSync.mockReturnValue('- [ ] Same task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // First scan + second scan + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('does not fire when content changed but no pending tasks', async () => { + const onEvent = vi.fn(); + + mockReaddirSync.mockImplementation((_dir: string, opts: { withFileTypes: boolean }) => { + if (opts?.withFileTypes) { + return [{ name: 'task.md', isDirectory: () => false, isFile: () => true }]; + } + return []; + }); + + // First scan: has pending tasks + mockReadFileSync.mockReturnValueOnce('- [ ] Task\n'); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent, + onLog: vi.fn(), + triggerName: 'test-scanner', + }); + + // Seed + await vi.advanceTimersByTimeAsync(3000); + + // Second scan: all tasks completed + mockReadFileSync.mockReturnValue('- [x] Task\n'); + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(onEvent).not.toHaveBeenCalled(); + cleanup(); + }); + + it('logs error when scan fails', async () => { + const onLog = vi.fn(); + + mockReaddirSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const cleanup = createCueTaskScanner({ + watchGlob: '**/*.md', + pollMinutes: 1, + projectRoot: '/project', + onEvent: vi.fn(), + onLog, + triggerName: 'test-scanner', + }); + + await vi.advanceTimersByTimeAsync(3000); + + expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('Task scan error')); + + cleanup(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-yaml-loader.test.ts b/src/__tests__/main/cue/cue-yaml-loader.test.ts new file mode 100644 index 000000000..47cde0e1e --- /dev/null +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -0,0 +1,869 @@ +/** + * Tests for the Cue YAML loader module. + * + * Tests cover: + * - Loading and parsing maestro-cue.yaml files + * - Handling missing files + * - Merging with default settings + * - Validation of subscription fields per event type + * - YAML file watching with debounce + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock chokidar +const mockChokidarOn = vi.fn().mockReturnThis(); +const mockChokidarClose = vi.fn(); +vi.mock('chokidar', () => ({ + watch: vi.fn(() => ({ + on: mockChokidarOn, + close: mockChokidarClose, + })), +})); + +// Mock fs +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), +})); + +// Must import after mocks +import { loadCueConfig, watchCueYaml, validateCueConfig } from '../../../main/cue/cue-yaml-loader'; +import * as chokidar from 'chokidar'; + +describe('cue-yaml-loader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('loadCueConfig', () => { + it('returns null when neither canonical nor legacy file exists', () => { + mockExistsSync.mockReturnValue(false); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('loads from canonical .maestro/cue.yaml path first', () => { + // Canonical path exists + mockExistsSync.mockImplementation((p: string) => String(p).includes('.maestro/cue.yaml')); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: canonical-sub + event: time.heartbeat + prompt: From canonical + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].name).toBe('canonical-sub'); + }); + + it('falls back to legacy maestro-cue.yaml when canonical does not exist', () => { + // Only legacy path exists + mockExistsSync.mockImplementation( + (p: string) => String(p).includes('maestro-cue.yaml') && !String(p).includes('.maestro/') + ); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: legacy-sub + event: time.heartbeat + prompt: From legacy + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].name).toBe('legacy-sub'); + }); + + it('parses a valid YAML config with subscriptions and settings', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: daily-check + event: time.heartbeat + enabled: true + prompt: Check all tests + interval_minutes: 60 + - name: watch-src + event: file.changed + enabled: true + prompt: Run lint + watch: "src/**/*.ts" +settings: + timeout_minutes: 15 + timeout_on_fail: continue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions).toHaveLength(2); + expect(result!.subscriptions[0].name).toBe('daily-check'); + expect(result!.subscriptions[0].event).toBe('time.heartbeat'); + expect(result!.subscriptions[0].interval_minutes).toBe(60); + expect(result!.subscriptions[1].name).toBe('watch-src'); + expect(result!.subscriptions[1].watch).toBe('src/**/*.ts'); + expect(result!.settings.timeout_minutes).toBe(15); + expect(result!.settings.timeout_on_fail).toBe('continue'); + }); + + it('uses default settings when settings section is missing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Do stuff + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.settings.timeout_minutes).toBe(30); + expect(result!.settings.timeout_on_fail).toBe('break'); + expect(result!.settings.max_concurrent).toBe(1); + expect(result!.settings.queue_size).toBe(10); + }); + + it('defaults enabled to true when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(true); + }); + + it('respects enabled: false', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: disabled-sub + event: time.heartbeat + enabled: false + prompt: Do stuff + interval_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].enabled).toBe(false); + }); + + it('returns null for empty YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(''); + const result = loadCueConfig('/projects/test'); + expect(result).toBeNull(); + }); + + it('throws on malformed YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid yaml ['); + expect(() => loadCueConfig('/projects/test')).toThrow(); + }); + + it('resolves prompt_file to prompt content when prompt is empty', () => { + // First call: existsSync for config file (true), then for prompt file path (true) + let readCallCount = 0; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + readCallCount++; + if (String(p).endsWith('.maestro/prompts/worker-pipeline.md')) { + return 'Prompt from external file'; + } + return ` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt_file: .maestro/prompts/worker-pipeline.md + interval_minutes: 5 +`; + }); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].prompt).toBe('Prompt from external file'); + expect(result!.subscriptions[0].prompt_file).toBe('.maestro/prompts/worker-pipeline.md'); + }); + + it('keeps inline prompt when both prompt and prompt_file exist', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Inline prompt text + prompt_file: .maestro/prompts/should-be-ignored.md + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].prompt).toBe('Inline prompt text'); + }); + + it('handles agent.completed with source_session array', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: fan-in-trigger + event: agent.completed + prompt: All agents done + source_session: + - agent-1 + - agent-2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].source_session).toEqual(['agent-1', 'agent-2']); + }); + }); + + describe('watchCueYaml', () => { + it('watches both canonical and legacy file paths', () => { + watchCueYaml('/projects/test', vi.fn()); + // Should watch both .maestro/cue.yaml (canonical) and maestro-cue.yaml (legacy) + expect(chokidar.watch).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.stringContaining('.maestro/cue.yaml'), + expect.stringContaining('maestro-cue.yaml'), + ]), + expect.objectContaining({ persistent: true, ignoreInitial: true }) + ); + }); + + it('calls onChange with debounce on file change', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + // Simulate a 'change' event via the mock's on handler + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + expect(changeHandler).toBeDefined(); + + changeHandler!(); + expect(onChange).not.toHaveBeenCalled(); // Not yet — debounced + + vi.advanceTimersByTime(1000); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('debounces multiple rapid changes', () => { + const onChange = vi.fn(); + watchCueYaml('/projects/test', onChange); + + const changeHandler = mockChokidarOn.mock.calls.find( + (call: unknown[]) => call[0] === 'change' + )?.[1]; + + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(500); + changeHandler!(); + vi.advanceTimersByTime(1000); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('cleanup function closes watcher', () => { + const cleanup = watchCueYaml('/projects/test', vi.fn()); + cleanup(); + expect(mockChokidarClose).toHaveBeenCalled(); + }); + + it('registers handlers for add, change, and unlink events', () => { + watchCueYaml('/projects/test', vi.fn()); + const registeredEvents = mockChokidarOn.mock.calls.map((call: unknown[]) => call[0]); + expect(registeredEvents).toContain('add'); + expect(registeredEvents).toContain('change'); + expect(registeredEvents).toContain('unlink'); + }); + }); + + describe('validateCueConfig', () => { + it('returns valid for a correct config', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'time.heartbeat', prompt: 'Do it', interval_minutes: 5 }, + ], + settings: { timeout_minutes: 30, timeout_on_fail: 'break' }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects non-object config', () => { + const result = validateCueConfig(null); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('non-null object'); + }); + + it('requires subscriptions array', () => { + const result = validateCueConfig({ settings: {} }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('subscriptions'); + }); + + it('requires name on subscriptions', () => { + const result = validateCueConfig({ + subscriptions: [{ event: 'time.heartbeat', prompt: 'Test', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"name"')])); + }); + + it('requires interval_minutes for time.heartbeat', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.heartbeat', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('requires watch for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'file.changed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('requires source_session for agent.completed', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'agent.completed', prompt: 'Do it' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('source_session')]) + ); + }); + + it('accepts prompt_file as alternative to prompt', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt_file: '.maestro/prompts/test.md', + interval_minutes: 5, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects subscription with neither prompt nor prompt_file', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.heartbeat', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"prompt" or "prompt_file"')]) + ); + }); + + it('rejects invalid timeout_on_fail value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'invalid' }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('timeout_on_fail')]) + ); + }); + + it('accepts valid timeout_on_fail values', () => { + const breakResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'break' }, + }); + expect(breakResult.valid).toBe(true); + + const continueResult = validateCueConfig({ + subscriptions: [], + settings: { timeout_on_fail: 'continue' }, + }); + expect(continueResult.valid).toBe(true); + }); + + it('rejects invalid max_concurrent value', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 0 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects max_concurrent above 10', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 11 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('rejects non-integer max_concurrent', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 1.5 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('max_concurrent')]) + ); + }); + + it('accepts valid max_concurrent values', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { max_concurrent: 5 }, + }); + expect(result.valid).toBe(true); + }); + + it('rejects negative queue_size', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: -1 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('rejects queue_size above 50', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 51 }, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('queue_size')]) + ); + }); + + it('accepts valid queue_size values including 0', () => { + const result = validateCueConfig({ + subscriptions: [], + settings: { queue_size: 0 }, + }); + expect(result.valid).toBe(true); + }); + + it('requires prompt to be a non-empty string', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'time.heartbeat', interval_minutes: 5 }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('"prompt"')])); + }); + + it('accepts valid filter with string/number/boolean values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { extension: '.ts', active: true, priority: 5 }, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects filter with nested object values', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: { nested: { deep: 'value' } }, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('filter key "nested"')]) + ); + }); + + it('rejects filter that is an array', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: ['not', 'valid'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + + it('rejects filter with null value', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'file.changed', + prompt: 'Do it', + watch: 'src/**', + filter: null, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"filter" must be a plain object')]) + ); + }); + }); + + describe('loadCueConfig with GitHub events', () => { + it('parses repo and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: pr-watch + event: github.pull_request + prompt: Review the PR + repo: owner/repo + poll_minutes: 10 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].repo).toBe('owner/repo'); + expect(result!.subscriptions[0].poll_minutes).toBe(10); + }); + + it('defaults poll_minutes to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: issue-watch + event: github.issue + prompt: Triage issue +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].poll_minutes).toBeUndefined(); + expect(result!.subscriptions[0].repo).toBeUndefined(); + }); + }); + + describe('validateCueConfig for GitHub events', () => { + it('accepts valid github.pull_request subscription', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'pr-watch', event: 'github.pull_request', prompt: 'Review it' }], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts github.pull_request with repo and poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review it', + repo: 'owner/repo', + poll_minutes: 10, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects github.pull_request with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0.5, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.pull_request with poll_minutes = 0', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'pr-watch', + event: 'github.pull_request', + prompt: 'Review', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + + it('rejects github.issue with non-string repo', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + repo: 123, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"repo" must be a string')]) + ); + }); + + it('accepts github.issue with filter', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'issue-watch', + event: 'github.issue', + prompt: 'Triage', + filter: { author: 'octocat', labels: 'bug' }, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('validateCueConfig for task.pending events', () => { + it('accepts valid task.pending subscription', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process tasks', + watch: 'tasks/**/*.md', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('requires watch for task.pending', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'task-queue', event: 'task.pending', prompt: 'Process tasks' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('watch')])); + }); + + it('accepts task.pending with poll_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 5, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects task.pending with poll_minutes < 1', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'task-queue', + event: 'task.pending', + prompt: 'Process', + watch: 'tasks/**/*.md', + poll_minutes: 0, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('poll_minutes')]) + ); + }); + }); + + describe('loadCueConfig with task.pending', () => { + it('parses watch and poll_minutes from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: task-queue + event: task.pending + prompt: Process the tasks + watch: "tasks/**/*.md" + poll_minutes: 2 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].event).toBe('task.pending'); + expect(result!.subscriptions[0].watch).toBe('tasks/**/*.md'); + expect(result!.subscriptions[0].poll_minutes).toBe(2); + }); + }); + + describe('loadCueConfig with agent_id', () => { + it('parses agent_id from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bound-sub + event: time.heartbeat + prompt: Do something + interval_minutes: 5 + agent_id: session-abc-123 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].agent_id).toBe('session-abc-123'); + }); + + it('defaults agent_id to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: unbound-sub + event: time.heartbeat + prompt: Do something + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].agent_id).toBeUndefined(); + }); + + it('ignores non-string agent_id', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-id + event: time.heartbeat + prompt: Do something + interval_minutes: 5 + agent_id: 12345 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].agent_id).toBeUndefined(); + }); + }); + + describe('loadCueConfig with filter', () => { + it('parses filter field from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: ts-only + event: file.changed + prompt: Review it + watch: "src/**/*" + filter: + extension: ".ts" + path: "!*.test.ts" +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + extension: '.ts', + path: '!*.test.ts', + }); + }); + + it('parses filter with boolean and numeric values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: filtered + event: agent.completed + prompt: Do it + source_session: agent-1 + filter: + active: true + exitCode: 0 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toEqual({ + active: true, + exitCode: 0, + }); + }); + + it('ignores filter with invalid nested values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-filter + event: file.changed + prompt: Do it + watch: "src/**" + filter: + nested: + deep: value +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].filter).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts new file mode 100644 index 000000000..25b373897 --- /dev/null +++ b/src/__tests__/main/deep-links.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for deep link URL parsing + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock electron before importing the module under test +vi.mock('electron', () => ({ + app: { + isPackaged: false, + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn().mockReturnValue(true), + on: vi.fn(), + quit: vi.fn(), + }, + BrowserWindow: { + getAllWindows: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../main/utils/safe-send', () => ({ + isWebContentsAvailable: vi.fn().mockReturnValue(true), +})); + +import { parseDeepLink } from '../../main/deep-links'; + +describe('parseDeepLink', () => { + describe('focus action', () => { + it('should parse maestro://focus', () => { + expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' }); + }); + + it('should parse empty path as focus', () => { + expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' }); + }); + + it('should parse protocol-only as focus', () => { + expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' }); + }); + }); + + describe('session action', () => { + it('should parse session URL', () => { + expect(parseDeepLink('maestro://session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should parse session URL with tab', () => { + expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({ + action: 'session', + sessionId: 'abc123', + tabId: 'tab456', + }); + }); + + it('should decode URI-encoded session IDs', () => { + expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({ + action: 'session', + sessionId: 'session with space', + }); + }); + + it('should decode URI-encoded tab IDs', () => { + expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab/slash', + }); + }); + + it('should return null for session without ID', () => { + expect(parseDeepLink('maestro://session')).toBeNull(); + expect(parseDeepLink('maestro://session/')).toBeNull(); + }); + + it('should ignore extra path segments after tab ID', () => { + const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff'); + expect(result).toEqual({ + action: 'session', + sessionId: 'abc', + tabId: 'tab1', + }); + }); + }); + + describe('group action', () => { + it('should parse group URL', () => { + expect(parseDeepLink('maestro://group/grp789')).toEqual({ + action: 'group', + groupId: 'grp789', + }); + }); + + it('should decode URI-encoded group IDs', () => { + expect(parseDeepLink('maestro://group/group%20name')).toEqual({ + action: 'group', + groupId: 'group name', + }); + }); + + it('should return null for group without ID', () => { + expect(parseDeepLink('maestro://group')).toBeNull(); + expect(parseDeepLink('maestro://group/')).toBeNull(); + }); + }); + + describe('Windows compatibility', () => { + it('should handle Windows maestro: prefix (no double slash)', () => { + expect(parseDeepLink('maestro:session/abc123')).toEqual({ + action: 'session', + sessionId: 'abc123', + }); + }); + + it('should handle Windows focus without double slash', () => { + expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' }); + }); + }); + + describe('error handling', () => { + it('should return null for unrecognized resource', () => { + expect(parseDeepLink('maestro://unknown/abc')).toBeNull(); + }); + + it('should return null for completely malformed URLs', () => { + // parseDeepLink is tolerant of most inputs, but unrecognized resources return null + expect(parseDeepLink('maestro://settings')).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/group-chat/group-chat-log.test.ts b/src/__tests__/main/group-chat/group-chat-log.test.ts index fbb88905f..c7f467821 100644 --- a/src/__tests__/main/group-chat/group-chat-log.test.ts +++ b/src/__tests__/main/group-chat/group-chat-log.test.ts @@ -177,6 +177,20 @@ describe('group-chat-log', () => { expect(content).toContain('Line1\\nLine2\\|Data'); }); + it('appends with image filenames', async () => { + const logPath = path.join(testDir, 'image-append.log'); + await appendToLog(logPath, 'user', 'Check this', false, ['img-001.png', 'img-002.jpg']); + const content = await fs.readFile(logPath, 'utf-8'); + expect(content).toContain('|images:img-001.png,img-002.jpg'); + }); + + it('appends with readOnly and image filenames', async () => { + const logPath = path.join(testDir, 'ro-image.log'); + await appendToLog(logPath, 'user', 'Read only with images', true, ['screenshot.png']); + const content = await fs.readFile(logPath, 'utf-8'); + expect(content).toContain('|readOnly|images:screenshot.png'); + }); + it('uses ISO 8601 timestamp format', async () => { const logPath = path.join(testDir, 'timestamp-chat.log'); const beforeTime = new Date().toISOString(); @@ -277,6 +291,39 @@ describe('group-chat-log', () => { expect(messages).toHaveLength(2); }); + it('parses image filenames from log', async () => { + const logPath = path.join(testDir, 'images-parse.log'); + await fs.writeFile( + logPath, + '2024-01-15T10:30:00.000Z|user|Check this|images:img-001.png,img-002.jpg\n' + ); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Check this'); + expect(messages[0].images).toEqual(['img-001.png', 'img-002.jpg']); + }); + + it('parses readOnly and images together', async () => { + const logPath = path.join(testDir, 'ro-images.log'); + await fs.writeFile( + logPath, + '2024-01-15T10:30:00.000Z|user|Hello|readOnly|images:screenshot.png\n' + ); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].readOnly).toBe(true); + expect(messages[0].images).toEqual(['screenshot.png']); + }); + + it('round-trips with appendToLog including images', async () => { + const logPath = path.join(testDir, 'round-trip-images.log'); + await appendToLog(logPath, 'user', 'With images', false, ['img.png']); + const messages = await readLog(logPath); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('With images'); + expect(messages[0].images).toEqual(['img.png']); + }); + it('round-trips with appendToLog', async () => { const logPath = path.join(testDir, 'round-trip.log'); const testContent = 'Hello\nWorld|Test'; diff --git a/src/__tests__/main/ipc/handlers/autorun.test.ts b/src/__tests__/main/ipc/handlers/autorun.test.ts index ab5dd2133..007e800a7 100644 --- a/src/__tests__/main/ipc/handlers/autorun.test.ts +++ b/src/__tests__/main/ipc/handlers/autorun.test.ts @@ -548,14 +548,15 @@ describe('autorun IPC handlers', () => { expect(fs.readFile).toHaveBeenCalledWith(expect.stringContaining('doc2.md'), 'utf-8'); }); - it('should return error for missing file', async () => { + it('should return empty content with notFound flag for missing file', async () => { vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); const handler = handlers.get('autorun:readDoc'); const result = await handler!({} as any, '/test/folder', 'nonexistent'); - expect(result.success).toBe(false); - expect(result.error).toContain('File not found'); + expect(result.success).toBe(true); + expect(result.content).toBe(''); + expect(result.notFound).toBe(true); }); it('should return error for directory traversal attempts', async () => { @@ -664,7 +665,7 @@ describe('autorun IPC handlers', () => { }); describe('autorun:deleteFolder', () => { - it('should remove the Auto Run Docs folder', async () => { + it('should remove the playbooks folder', async () => { vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => true, } as any); @@ -674,7 +675,7 @@ describe('autorun IPC handlers', () => { const result = await handler!({} as any, '/test/project'); expect(result.success).toBe(true); - expect(fs.rm).toHaveBeenCalledWith(path.join('/test/project', 'Auto Run Docs'), { + expect(fs.rm).toHaveBeenCalledWith(path.join('/test/project', '.maestro/playbooks'), { recursive: true, force: true, }); @@ -691,7 +692,7 @@ describe('autorun IPC handlers', () => { expect(fs.rm).not.toHaveBeenCalled(); }); - it('should return error if path is not a directory', async () => { + it('should skip non-directory paths without error', async () => { vi.mocked(fs.stat).mockResolvedValue({ isDirectory: () => false, } as any); @@ -699,8 +700,9 @@ describe('autorun IPC handlers', () => { const handler = handlers.get('autorun:deleteFolder'); const result = await handler!({} as any, '/test/project'); - expect(result.success).toBe(false); - expect(result.error).toContain('Auto Run Docs path is not a directory'); + // Both canonical and legacy are non-directories, so nothing to delete + expect(result.success).toBe(true); + expect(fs.rm).not.toHaveBeenCalled(); }); it('should return error for invalid project path', async () => { @@ -1389,14 +1391,14 @@ describe('autorun IPC handlers', () => { const result = await handler!({} as any, '/remote/folder', 'doc1', 1, 'ssh-remote-1'); expect(result.success).toBe(true); - expect(result.workingCopyPath).toMatch(/^Runs\/doc1-\d+-loop-1$/); + expect(result.workingCopyPath).toMatch(/^runs\/doc1-\d+-loop-1$/); expect(result.originalPath).toBe('doc1'); // Verify remote operations were called expect(mockReadFileRemote).toHaveBeenCalledWith('/remote/folder/doc1.md', sampleSshRemote); - expect(mockMkdirRemote).toHaveBeenCalledWith('/remote/folder/Runs', sampleSshRemote, true); + expect(mockMkdirRemote).toHaveBeenCalledWith('/remote/folder/runs', sampleSshRemote, true); expect(mockWriteFileRemote).toHaveBeenCalledWith( - expect.stringContaining('/remote/folder/Runs/doc1-'), + expect.stringContaining('/remote/folder/runs/doc1-'), '# Source Content', sampleSshRemote ); @@ -1425,12 +1427,12 @@ describe('autorun IPC handlers', () => { ); expect(result.success).toBe(true); - expect(result.workingCopyPath).toMatch(/^Runs\/subdir\/nested-doc-\d+-loop-2$/); + expect(result.workingCopyPath).toMatch(/^runs\/subdir\/nested-doc-\d+-loop-2$/); expect(result.originalPath).toBe('subdir/nested-doc'); // Verify remote mkdir creates the correct subdirectory expect(mockMkdirRemote).toHaveBeenCalledWith( - '/remote/folder/Runs/subdir', + '/remote/folder/runs/subdir', sampleSshRemote, true ); diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index ae1cf96e3..db5301e96 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -245,6 +245,37 @@ describe('director-notes IPC handlers', () => { expect(result.stats.totalCount).toBe(3); }); + it('should only count agents with entries in lookback window for agentCount', async () => { + const now = Date.now(); + const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + // 3 sessions on disk, but only 2 have entries within 7-day lookback + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + 'session-1', + 'session-2', + 'session-3', + ]); + + vi.mocked(mockHistoryManager.getEntries) + .mockReturnValueOnce([ + createMockEntry({ id: 'e1', timestamp: twoDaysAgo, agentSessionId: 'as-1' }), + ]) + .mockReturnValueOnce([ + // session-2 only has old entries outside lookback + createMockEntry({ id: 'e2', timestamp: tenDaysAgo, agentSessionId: 'as-2' }), + ]) + .mockReturnValueOnce([ + createMockEntry({ id: 'e3', timestamp: twoDaysAgo, agentSessionId: 'as-3' }), + ]); + + const handler = handlers.get('director-notes:getUnifiedHistory'); + const result = await handler!({} as any, { lookbackDays: 7 }); + + expect(result.stats.agentCount).toBe(2); // Only 2 agents had entries in window + expect(result.entries).toHaveLength(2); + }); + it('should filter by lookbackDays', async () => { const now = Date.now(); const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; diff --git a/src/__tests__/main/ipc/handlers/groupChat.test.ts b/src/__tests__/main/ipc/handlers/groupChat.test.ts index 4d3fffc46..83c9bf4d4 100644 --- a/src/__tests__/main/ipc/handlers/groupChat.test.ts +++ b/src/__tests__/main/ipc/handlers/groupChat.test.ts @@ -688,7 +688,8 @@ describe('groupChat IPC handlers', () => { 'Hello moderator', mockProcessManager, mockAgentDetector, - false + false, + undefined ); }); @@ -703,7 +704,8 @@ describe('groupChat IPC handlers', () => { 'Analyze this', mockProcessManager, mockAgentDetector, - true + true, + undefined ); }); }); diff --git a/src/__tests__/main/ipc/handlers/history.test.ts b/src/__tests__/main/ipc/handlers/history.test.ts index e612489d7..ff1216e06 100644 --- a/src/__tests__/main/ipc/handlers/history.test.ts +++ b/src/__tests__/main/ipc/handlers/history.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../../main/utils/logger', () => ({ describe('history IPC handlers', () => { let handlers: Map; let mockHistoryManager: Partial; + let mockSafeSend: ReturnType; // Sample history entries for testing const createMockEntry = (overrides: Partial = {}): HistoryEntry => ({ @@ -54,6 +55,8 @@ describe('history IPC handlers', () => { // Clear mocks vi.clearAllMocks(); + mockSafeSend = vi.fn(); + // Create mock history manager mockHistoryManager = { getEntries: vi.fn().mockReturnValue([]), @@ -101,8 +104,8 @@ describe('history IPC handlers', () => { handlers.set(channel, handler); }); - // Register handlers - registerHistoryHandlers(); + // Register handlers with mock safeSend + registerHistoryHandlers({ safeSend: mockSafeSend }); }); afterEach(() => { @@ -282,6 +285,15 @@ describe('history IPC handlers', () => { expect(result).toBe(true); }); + it('should broadcast entry via safeSend after adding', async () => { + const entry = createMockEntry({ sessionId: 'session-1', projectPath: '/test' }); + + const handler = handlers.get('history:add'); + await handler!({} as any, entry); + + expect(mockSafeSend).toHaveBeenCalledWith('history:entryAdded', entry, 'session-1'); + }); + it('should use orphaned session ID when sessionId is missing', async () => { const entry = createMockEntry({ sessionId: undefined, projectPath: '/test' }); diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts index add55b37c..4e128308b 100644 --- a/src/__tests__/main/ipc/handlers/notifications.test.ts +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -17,6 +17,7 @@ import { ipcMain } from 'electron'; const mocks = vi.hoisted(() => ({ mockNotificationShow: vi.fn(), mockNotificationIsSupported: vi.fn().mockReturnValue(true), + mockNotificationOn: vi.fn(), })); // Mock electron with a proper class for Notification @@ -29,6 +30,9 @@ vi.mock('electron', () => { show() { mocks.mockNotificationShow(); } + on(event: string, handler: () => void) { + mocks.mockNotificationOn(event, handler); + } static isSupported() { return mocks.mockNotificationIsSupported(); } @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({ }, })); +// Mock deep-links module (used by notification click handler) +vi.mock('../../../../main/deep-links', () => ({ + parseDeepLink: vi.fn((url: string) => { + if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' }; + return { action: 'focus' }; + }), + dispatchDeepLink: vi.fn(), +})); + // Mock child_process - must include default export vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -99,6 +112,8 @@ import { describe('Notification IPC Handlers', () => { let handlers: Map; + const mockGetMainWindow = vi.fn().mockReturnValue(null); + beforeEach(() => { vi.clearAllMocks(); resetNotificationState(); @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => { // Reset mocks mocks.mockNotificationIsSupported.mockReturnValue(true); mocks.mockNotificationShow.mockClear(); + mocks.mockNotificationOn.mockClear(); // Capture registered handlers vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { handlers.set(channel, handler); }); - registerNotificationsHandlers(); + registerNotificationsHandlers({ getMainWindow: mockGetMainWindow }); }); afterEach(() => { @@ -186,6 +202,68 @@ describe('Notification IPC Handlers', () => { }); }); + describe('notification:show click-to-navigate', () => { + it('should register close handler to prevent GC on all notifications', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('close', expect.any(Function)); + }); + + it('should register click handler when sessionId is provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('close', expect.any(Function)); + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should register click handler when sessionId and tabId are provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'session-123', 'tab-456'); + + expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('should URI-encode sessionId and tabId in deep link URL', async () => { + const { parseDeepLink } = await import('../../../../main/deep-links'); + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special'); + + // Find the click handler (not the close handler) + const clickCall = mocks.mockNotificationOn.mock.calls.find( + (call: any[]) => call[0] === 'click' + ); + expect(clickCall).toBeDefined(); + clickCall![1](); + + expect(parseDeepLink).toHaveBeenCalledWith( + `maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}` + ); + }); + + it('should not register click handler when sessionId is not provided', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body'); + + // close handler is registered, but not click + const clickCalls = mocks.mockNotificationOn.mock.calls.filter( + (call: any[]) => call[0] === 'click' + ); + expect(clickCalls).toHaveLength(0); + }); + + it('should not register click handler when sessionId is undefined', async () => { + const handler = handlers.get('notification:show')!; + await handler({}, 'Title', 'Body', undefined, undefined); + + const clickCalls = mocks.mockNotificationOn.mock.calls.filter( + (call: any[]) => call[0] === 'click' + ); + expect(clickCalls).toHaveLength(0); + }); + }); + describe('notification:stopSpeak', () => { it('should return error when no active notification process', async () => { const handler = handlers.get('notification:stopSpeak')!; diff --git a/src/__tests__/main/ipc/handlers/process.test.ts b/src/__tests__/main/ipc/handlers/process.test.ts index 29b01fefc..9d8499bdc 100644 --- a/src/__tests__/main/ipc/handlers/process.test.ts +++ b/src/__tests__/main/ipc/handlers/process.test.ts @@ -200,6 +200,7 @@ describe('process IPC handlers', () => { resize: ReturnType; getAll: ReturnType; runCommand: ReturnType; + spawnTerminalTab: ReturnType; }; let mockAgentDetector: { getAgent: ReturnType; @@ -227,6 +228,7 @@ describe('process IPC handlers', () => { resize: vi.fn(), getAll: vi.fn(), runCommand: vi.fn(), + spawnTerminalTab: vi.fn(), }; // Create mock agent detector @@ -287,6 +289,7 @@ describe('process IPC handlers', () => { 'process:kill', 'process:resize', 'process:getActiveProcesses', + 'process:spawnTerminalTab', 'process:runCommand', ]; @@ -976,7 +979,181 @@ describe('process IPC handlers', () => { }); }); - describe('SSH remote execution (session-level only)', () => { + describe('process:spawnTerminalTab', () => { + const mockSshRemoteForTerminal = { + id: 'remote-1', + name: 'Dev Server', + host: 'dev.example.com', + port: 22, + username: 'devuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + }; + + it('should spawn local terminal when no SSH config is provided', async () => { + mockProcessManager.spawnTerminalTab.mockReturnValue({ pid: 5000, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + }); + + expect(mockProcessManager.spawnTerminalTab).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + }) + ); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + expect(result).toEqual({ pid: 5000, success: true }); + }); + + it('should spawn SSH session when sessionSshRemoteConfig is enabled', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5001, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + }, + }); + + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + args: expect.arrayContaining(['devuser@dev.example.com']), + toolType: 'terminal', + }) + ); + expect(mockProcessManager.spawnTerminalTab).not.toHaveBeenCalled(); + expect(result).toEqual({ pid: 5001, success: true }); + }); + + it('should add -t flag and remote cd command when workingDirOverride is set', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5002, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + workingDirOverride: '/remote/project', + }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + expect(spawnCall.command).toBe('ssh'); + // -t must appear before the host in the args + const tIndex = spawnCall.args.indexOf('-t'); + const hostIndex = spawnCall.args.indexOf('devuser@dev.example.com'); + expect(tIndex).toBeGreaterThanOrEqual(0); + expect(tIndex).toBeLessThan(hostIndex); + // Remote command to cd and exec shell must be the last arg + const lastArg = spawnCall.args[spawnCall.args.length - 1]; + expect(lastArg).toContain('/remote/project'); + expect(lastArg).toContain('exec $SHELL'); + }); + + it('should include port flag for non-default SSH port', async () => { + const remoteWithPort = { ...mockSshRemoteForTerminal, port: 2222 }; + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [remoteWithPort]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5003, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const portIndex = spawnCall.args.indexOf('-p'); + expect(portIndex).toBeGreaterThanOrEqual(0); + expect(spawnCall.args[portIndex + 1]).toBe('2222'); + }); + + it('should include identity file flag when privateKeyPath is set', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 5004, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const keyIndex = spawnCall.args.indexOf('-i'); + expect(keyIndex).toBeGreaterThanOrEqual(0); + expect(spawnCall.args[keyIndex + 1]).toBe('~/.ssh/id_ed25519'); + }); + + it('should return failure when SSH is enabled but remote config not found', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return []; // No remotes configured + return defaultValue; + }); + + const handler = handlers.get('process:spawnTerminalTab'); + const result = await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'nonexistent-remote', + }, + }); + + // Must NOT silently fall through to local spawn + expect(mockProcessManager.spawnTerminalTab).not.toHaveBeenCalled(); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + expect(result).toEqual({ success: false, pid: 0 }); + }); + + it('should spawn local terminal when SSH config is present but disabled', async () => { + mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'sshRemotes') return [mockSshRemoteForTerminal]; + return defaultValue; + }); + mockProcessManager.spawnTerminalTab.mockReturnValue({ pid: 5005, success: true }); + + const handler = handlers.get('process:spawnTerminalTab'); + await handler!({} as any, { + sessionId: 'session-1-terminal-tab-1', + cwd: '/local/project', + sessionSshRemoteConfig: { + enabled: false, // Explicitly disabled + remoteId: 'remote-1', + }, + }); + + expect(mockProcessManager.spawnTerminalTab).toHaveBeenCalled(); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + }); + }); + + describe('SSH remote execution (session-level only)', () => { // SSH is SESSION-LEVEL ONLY - no agent-level or global defaults const mockSshRemote = { id: 'remote-1', diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts index 4f07ce826..4cc6ea4ef 100644 --- a/src/__tests__/main/ipc/handlers/symphony.test.ts +++ b/src/__tests__/main/ipc/handlers/symphony.test.ts @@ -101,11 +101,18 @@ describe('Symphony IPC handlers', () => { set: vi.fn(), }; + // Setup mock settings store + const mockSettingsStore = { + get: vi.fn().mockReturnValue([]), + set: vi.fn(), + }; + // Setup dependencies mockDeps = { app: mockApp, getMainWindow: () => mockMainWindow, sessionsStore: mockSessionsStore as any, + settingsStore: mockSettingsStore as any, }; // Default mock for fs operations @@ -1075,7 +1082,9 @@ describe('Symphony IPC handlers', () => { const result = await handler!({} as any, false); expect(result.fromCache).toBe(false); - expect(result.registry).toEqual(freshRegistry); + expect(result.registry).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should fetch fresh data when forceRefresh is true', async () => { @@ -1098,7 +1107,9 @@ describe('Symphony IPC handlers', () => { const result = await handler!({} as any, true); // forceRefresh = true expect(result.fromCache).toBe(false); - expect(result.registry).toEqual(freshRegistry); + expect(result.registry).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should update cache after fresh fetch', async () => { @@ -1116,7 +1127,9 @@ describe('Symphony IPC handlers', () => { expect(fs.writeFile).toHaveBeenCalled(); const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; const writtenData = JSON.parse(writeCall[1] as string); - expect(writtenData.registry.data).toEqual(freshRegistry); + expect(writtenData.registry.data).toEqual( + expect.objectContaining({ repositories: freshRegistry.repositories }) + ); }); it('should handle network errors gracefully', async () => { @@ -1129,7 +1142,7 @@ describe('Symphony IPC handlers', () => { // The IPC handler wrapper catches errors and returns success: false expect(result.success).toBe(false); - expect(result.error).toContain('Network error'); + expect(result.error).toContain('Failed to fetch registry'); }); }); diff --git a/src/__tests__/main/ipc/handlers/tabNaming.test.ts b/src/__tests__/main/ipc/handlers/tabNaming.test.ts index b93836fee..46277ad76 100644 --- a/src/__tests__/main/ipc/handlers/tabNaming.test.ts +++ b/src/__tests__/main/ipc/handlers/tabNaming.test.ts @@ -371,8 +371,8 @@ describe('Tab Naming IPC Handlers', () => { expect(mockProcessManager.spawn).toHaveBeenCalled(); }); - // Advance time past the timeout (30 seconds) - vi.advanceTimersByTime(31000); + // Advance time past the timeout (45 seconds) + vi.advanceTimersByTime(46000); const result = await resultPromise; expect(result).toBeNull(); diff --git a/src/__tests__/main/preload/notifications.test.ts b/src/__tests__/main/preload/notifications.test.ts index 093eb3368..4de6284b7 100644 --- a/src/__tests__/main/preload/notifications.test.ts +++ b/src/__tests__/main/preload/notifications.test.ts @@ -36,7 +36,13 @@ describe('Notification Preload API', () => { const result = await api.show('Test Title', 'Test Body'); - expect(mockInvoke).toHaveBeenCalledWith('notification:show', 'Test Title', 'Test Body'); + expect(mockInvoke).toHaveBeenCalledWith( + 'notification:show', + 'Test Title', + 'Test Body', + undefined, + undefined + ); expect(result).toEqual({ success: true }); }); diff --git a/src/__tests__/main/process-listeners/exit-listener.test.ts b/src/__tests__/main/process-listeners/exit-listener.test.ts index 7988edeeb..d45b9f104 100644 --- a/src/__tests__/main/process-listeners/exit-listener.test.ts +++ b/src/__tests__/main/process-listeners/exit-listener.test.ts @@ -350,6 +350,102 @@ describe('Exit Listener', () => { }); }); + describe('Cue Completion Notification', () => { + it('should notify Cue engine on regular session exit when enabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'completed', + exitCode: 0, + }); + }); + + it('should pass failed status when exit code is non-zero', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 1); + + expect(mockCueEngine.notifyAgentCompleted).toHaveBeenCalledWith('regular-session-123', { + status: 'failed', + exitCode: 1, + }); + }); + + it('should not notify when Cue feature is disabled', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => false; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify when no completion subscribers exist', () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(false), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + handler?.('regular-session-123', 0); + + expect(mockCueEngine.hasCompletionSubscribers).toHaveBeenCalledWith('regular-session-123'); + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + + it('should not notify for group chat sessions', async () => { + const mockCueEngine = { + hasCompletionSubscribers: vi.fn().mockReturnValue(true), + notifyAgentCompleted: vi.fn(), + }; + mockDeps.getCueEngine = () => mockCueEngine as any; + mockDeps.isCueEnabled = () => true; + + setupListener(); + const handler = eventHandlers.get('exit'); + + // Moderator session + handler?.('group-chat-test-chat-123-moderator-1234567890', 0); + + await vi.waitFor(() => { + expect(mockDeps.groupChatRouter.routeModeratorResponse).toHaveBeenCalled(); + }); + + // Moderator exits return early before reaching Cue notification + expect(mockCueEngine.notifyAgentCompleted).not.toHaveBeenCalled(); + }); + }); + describe('Error Handling', () => { beforeEach(() => { mockDeps.outputParser.parseParticipantSessionId = vi.fn().mockReturnValue({ diff --git a/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts new file mode 100644 index 000000000..de136176a --- /dev/null +++ b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for src/main/process-manager/spawners/PtySpawner.ts + * + * Key behaviors verified: + * - Shell terminal: uses `shell` field with -l/-i flags (login+interactive) + * - SSH terminal: when no `shell` is provided, uses `command`/`args` directly + * (this is the fix for SSH terminal tabs connecting to remote hosts) + * - AI agent PTY: uses `command`/`args` directly (toolType !== 'terminal') + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +const mockPtySpawn = vi.fn(); +const mockPtyProcess = { + pid: 99999, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), +}; + +vi.mock('node-pty', () => ({ + spawn: (...args: unknown[]) => { + mockPtySpawn(...args); + return mockPtyProcess; + }, +})); + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../main/utils/terminalFilter', () => ({ + stripControlSequences: vi.fn((data: string) => data), +})); + +vi.mock('../../../../main/process-manager/utils/envBuilder', () => ({ + buildPtyTerminalEnv: vi.fn(() => ({ TERM: 'xterm-256color' })), + buildChildProcessEnv: vi.fn(() => ({ PATH: '/usr/bin' })), +})); + +vi.mock('../../../../shared/platformDetection', () => ({ + isWindows: vi.fn(() => false), +})); + +// ── Imports (after mocks) ────────────────────────────────────────────────── + +import { PtySpawner } from '../../../../main/process-manager/spawners/PtySpawner'; +import type { ManagedProcess, ProcessConfig } from '../../../../main/process-manager/types'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function createTestContext() { + const processes = new Map(); + const emitter = new EventEmitter(); + const bufferManager = { + emitDataBuffered: vi.fn(), + flushDataBuffer: vi.fn(), + }; + const spawner = new PtySpawner(processes, emitter, bufferManager as any); + return { processes, emitter, bufferManager, spawner }; +} + +function createBaseConfig(overrides: Partial = {}): ProcessConfig { + return { + sessionId: 'test-session', + toolType: 'terminal', + cwd: '/home/user', + command: 'zsh', + args: [], + shell: 'zsh', + ...overrides, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('PtySpawner', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPtyProcess.onData.mockImplementation(() => {}); + mockPtyProcess.onExit.mockImplementation(() => {}); + }); + + describe('shell terminal (toolType=terminal, shell provided)', () => { + it('spawns the shell with -l -i flags', () => { + const { spawner } = createTestContext(); + spawner.spawn(createBaseConfig({ shell: 'zsh' })); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'zsh', + ['-l', '-i'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('appends custom shellArgs after -l -i', () => { + const { spawner } = createTestContext(); + spawner.spawn(createBaseConfig({ shell: 'zsh', shellArgs: '--login --no-rcs' })); + + const [, args] = mockPtySpawn.mock.calls[0]; + expect(args[0]).toBe('-l'); + expect(args[1]).toBe('-i'); + expect(args).toContain('--login'); + expect(args).toContain('--no-rcs'); + }); + + it('returns success with pid from PTY process', () => { + const { spawner } = createTestContext(); + const result = spawner.spawn(createBaseConfig({ shell: 'bash' })); + + expect(result.success).toBe(true); + expect(result.pid).toBe(99999); + }); + }); + + describe('SSH terminal (toolType=terminal, no shell provided)', () => { + it('uses command and args directly without -l/-i flags', () => { + const { spawner } = createTestContext(); + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: ['pedram@pedtome.example.com'], + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'ssh', + ['pedram@pedtome.example.com'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('passes through ssh args including -t flag and remote command', () => { + const { spawner } = createTestContext(); + const sshArgs = ['-t', 'pedram@pedtome.example.com', 'cd "/project" && exec $SHELL']; + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: sshArgs, + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'ssh', + sshArgs, + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + + it('passes through ssh args with -i and -p flags', () => { + const { spawner } = createTestContext(); + const sshArgs = ['-i', '/home/user/.ssh/id_rsa', '-p', '2222', 'pedram@pedtome.example.com']; + spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: sshArgs, + }) + ); + + const [cmd, args] = mockPtySpawn.mock.calls[0]; + expect(cmd).toBe('ssh'); + expect(args).toEqual(sshArgs); + // Must NOT contain -l or -i (shell flags) + expect(args).not.toContain('-l'); + }); + + it('returns success with pid from PTY process', () => { + const { spawner } = createTestContext(); + const result = spawner.spawn( + createBaseConfig({ + shell: undefined, + command: 'ssh', + args: ['user@remote.example.com'], + }) + ); + + expect(result.success).toBe(true); + expect(result.pid).toBe(99999); + }); + }); + + describe('AI agent PTY (toolType !== terminal)', () => { + it('uses command and args directly regardless of shell field', () => { + const { spawner } = createTestContext(); + spawner.spawn( + createBaseConfig({ + toolType: 'claude-code', + command: 'claude', + args: ['--print'], + shell: 'zsh', + }) + ); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'claude', + ['--print'], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + }); + + describe('process registration', () => { + it('registers the managed process by sessionId', () => { + const { spawner, processes } = createTestContext(); + spawner.spawn(createBaseConfig({ sessionId: 'my-session', shell: 'zsh' })); + + expect(processes.has('my-session')).toBe(true); + expect(processes.get('my-session')?.pid).toBe(99999); + }); + + it('sets isTerminal=true for all PTY processes', () => { + const { spawner, processes } = createTestContext(); + + // Shell terminal + spawner.spawn(createBaseConfig({ sessionId: 'shell-session', shell: 'zsh' })); + expect(processes.get('shell-session')?.isTerminal).toBe(true); + + // SSH terminal + spawner.spawn( + createBaseConfig({ sessionId: 'ssh-session', shell: undefined, command: 'ssh', args: ['host'] }) + ); + expect(processes.get('ssh-session')?.isTerminal).toBe(true); + }); + }); +}); diff --git a/src/__tests__/main/services/symphony-runner.test.ts b/src/__tests__/main/services/symphony-runner.test.ts index 7ae084a8a..4995a1605 100644 --- a/src/__tests__/main/services/symphony-runner.test.ts +++ b/src/__tests__/main/services/symphony-runner.test.ts @@ -708,7 +708,7 @@ describe('Symphony Runner Service', () => { expect(mockFetch).toHaveBeenCalledWith('https://example.com/doc.md'); expect(fs.writeFile).toHaveBeenCalledWith( - '/tmp/test-repo/Auto Run Docs/doc.md', + '/tmp/test-repo/.maestro/playbooks/doc.md', expect.any(Buffer) ); }); @@ -789,11 +789,11 @@ describe('Symphony Runner Service', () => { }); // ============================================================================ - // Setup Auto Run Docs Tests + // Setup .maestro/playbooks Tests // ============================================================================ describe('setupAutoRunDocs', () => { - it('creates Auto Run Docs directory', async () => { + it('creates .maestro/playbooks directory', async () => { mockSuccessfulWorkflow(); await startContribution({ @@ -807,7 +807,9 @@ describe('Symphony Runner Service', () => { branchName: 'symphony/test-branch', }); - expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-repo/Auto Run Docs', { recursive: true }); + expect(fs.mkdir).toHaveBeenCalledWith('/tmp/test-repo/.maestro/playbooks', { + recursive: true, + }); }); it('downloads external documents (isExternal: true)', async () => { @@ -856,7 +858,7 @@ describe('Symphony Runner Service', () => { expect(fs.copyFile).toHaveBeenCalledWith( '/tmp/test-repo/docs/internal.md', - '/tmp/test-repo/Auto Run Docs/internal.md' + '/tmp/test-repo/.maestro/playbooks/internal.md' ); }); @@ -910,7 +912,7 @@ describe('Symphony Runner Service', () => { ); }); - it('returns path to Auto Run Docs directory', async () => { + it('returns path to .maestro/playbooks directory', async () => { mockSuccessfulWorkflow(); const result = await startContribution({ @@ -924,7 +926,7 @@ describe('Symphony Runner Service', () => { branchName: 'symphony/test-branch', }); - expect(result.autoRunPath).toBe('/tmp/test-repo/Auto Run Docs'); + expect(result.autoRunPath).toBe('/tmp/test-repo/.maestro/playbooks'); }); }); @@ -1110,7 +1112,7 @@ describe('Symphony Runner Service', () => { expect(result.success).toBe(true); expect(result.draftPrUrl).toBe('https://github.com/owner/repo/pull/42'); expect(result.draftPrNumber).toBe(42); - expect(result.autoRunPath).toBe('/tmp/test-repo/Auto Run Docs'); + expect(result.autoRunPath).toBe('/tmp/test-repo/.maestro/playbooks'); }); it('handles unexpected errors gracefully', async () => { diff --git a/src/__tests__/main/stats/auto-run.test.ts b/src/__tests__/main/stats/auto-run.test.ts index 51ab6f417..bc1ef483a 100644 --- a/src/__tests__/main/stats/auto-run.test.ts +++ b/src/__tests__/main/stats/auto-run.test.ts @@ -296,7 +296,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const sessionId = db.insertAutoRunSession({ sessionId: 'maestro-session-123', agentType: 'claude-code', - documentPath: 'Auto Run Docs/PHASE-1.md', + documentPath: '.maestro/playbooks/PHASE-1.md', startTime, duration: 0, // Duration is 0 at start tasksTotal: 10, @@ -314,7 +314,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { // INSERT parameters: id, session_id, agent_type, document_path, start_time, duration, tasks_total, tasks_completed, project_path expect(lastCall[1]).toBe('maestro-session-123'); // session_id expect(lastCall[2]).toBe('claude-code'); // agent_type - expect(lastCall[3]).toBe('Auto Run Docs/PHASE-1.md'); // document_path + expect(lastCall[3]).toBe('.maestro/playbooks/PHASE-1.md'); // document_path expect(lastCall[4]).toBe(startTime); // start_time expect(lastCall[5]).toBe(0); // duration (0 at start) expect(lastCall[6]).toBe(10); // tasks_total diff --git a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx index 7ebc0d7c5..da8212253 100644 --- a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx +++ b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx @@ -882,7 +882,7 @@ describe('AutoRun Memory Leak Detection', () => { for (const sessionId of sessions) { const props = createDefaultProps({ sessionId, - folderPath: `/projects/${sessionId}/Auto Run Docs`, + folderPath: `/projects/${sessionId}/.maestro/playbooks`, content: `# ${sessionId} Content cycle ${cycle}`, }); @@ -894,7 +894,7 @@ describe('AutoRun Memory Leak Detection', () => { // Add some cache entries imageCache.set( - `/projects/${sessionId}/Auto Run Docs:images/img${cycle}.png`, + `/projects/${sessionId}/.maestro/playbooks:images/img${cycle}.png`, `data${cycle}` ); diff --git a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx index 37a7e22bb..4dd6aad21 100644 --- a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx +++ b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx @@ -267,7 +267,7 @@ function generateSessionData( sessions.push({ id: `session-${i}`, content: `# Session ${i} Content\n\n- [ ] Task ${i}.1\n- [x] Task ${i}.2\n- [ ] Task ${i}.3\n\nContent specific to session ${i}.`, - folderPath: `/projects/project-${i}/Auto Run Docs`, + folderPath: `/projects/project-${i}/.maestro/playbooks`, }); } return sessions; diff --git a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx index cf594c6fd..d439cf0b7 100644 --- a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx @@ -70,6 +70,8 @@ const createMockSession = (overrides: Partial = {}): Session => fileTree: [], fileExplorerExpanded: [], messageQueue: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }) as Session; @@ -590,8 +592,7 @@ describe('AgentSessionsModal', () => { }); it('should display minutes ago', async () => { - const date = new Date(); - date.setMinutes(date.getMinutes() - 15); + const date = new Date(Date.now() - 15 * 60 * 1000); const mockSessions = [createMockClaudeSession({ modifiedAt: date.toISOString() })]; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions: mockSessions, @@ -615,8 +616,7 @@ describe('AgentSessionsModal', () => { }); it('should display hours ago', async () => { - const date = new Date(); - date.setHours(date.getHours() - 5); + const date = new Date(Date.now() - 5 * 60 * 60 * 1000); const mockSessions = [createMockClaudeSession({ modifiedAt: date.toISOString() })]; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions: mockSessions, @@ -665,8 +665,7 @@ describe('AgentSessionsModal', () => { }); it('should display full date for old timestamps', async () => { - const date = new Date(); - date.setDate(date.getDate() - 30); + const date = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const mockSessions = [createMockClaudeSession({ modifiedAt: date.toISOString() })]; vi.mocked(window.maestro.agentSessions.listPaginated).mockResolvedValue({ sessions: mockSessions, @@ -1455,7 +1454,7 @@ describe('AgentSessionsModal', () => { await waitFor(() => { const messageBubble = screen.getByText('Dark mode message').closest('.rounded-lg'); expect(messageBubble).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); - expect(messageBubble).toHaveStyle({ color: '#000' }); // Dark mode uses black text + expect(messageBubble).toHaveStyle({ color: mockTheme.colors.accentForeground }); }); }); @@ -1488,7 +1487,7 @@ describe('AgentSessionsModal', () => { await waitFor(() => { const messageBubble = screen.getByText('Light mode message').closest('.rounded-lg'); - expect(messageBubble).toHaveStyle({ color: '#fff' }); // Light mode uses white text + expect(messageBubble).toHaveStyle({ color: lightTheme.colors.accentForeground }); }); }); }); diff --git a/src/__tests__/renderer/components/AppConfirmModals.test.tsx b/src/__tests__/renderer/components/AppConfirmModals.test.tsx index 6e2f2e182..08c1080e4 100644 --- a/src/__tests__/renderer/components/AppConfirmModals.test.tsx +++ b/src/__tests__/renderer/components/AppConfirmModals.test.tsx @@ -50,6 +50,8 @@ function createMockSession(overrides: Partial): Session { state: 'idle', toolType: 'claude-code', cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, ...overrides, } as Session; } diff --git a/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx b/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx index 27686728e..e5876603a 100644 --- a/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx +++ b/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx @@ -170,6 +170,8 @@ function createMockSession(overrides: Partial = {}): Session { state: 'idle', toolType: 'claude-code', cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, ...overrides, } as Session; } diff --git a/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx b/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx index 886d6f625..68c34e786 100644 --- a/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx +++ b/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx @@ -42,6 +42,12 @@ vi.mock('lucide-react', () => ({ ), })); +// Mock theme utils (getExplorerFileIcon returns JSX with lucide-react icons) +vi.mock('../../../renderer/utils/theme', () => ({ + getExplorerFileIcon: () => 📄, + getExplorerFolderIcon: () => 📁, +})); + // Test theme const mockTheme: Theme = { id: 'test-theme', diff --git a/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx b/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx index 50e491d5a..c1acc813d 100644 --- a/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx +++ b/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx @@ -127,6 +127,7 @@ const createMockTheme = (): Theme => ({ success: '#00aa00', warning: '#ffaa00', error: '#ff0000', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', }, }); @@ -701,7 +702,7 @@ describe('AutoRunExpandedModal', () => { const { container } = renderWithProvider(); const overlay = container.querySelector('.fixed.inset-0'); - expect(overlay).toHaveStyle({ backgroundColor: 'rgba(0,0,0,0.7)' }); + expect(overlay).toHaveStyle({ backgroundColor: props.theme.colors.overlayHeavy }); }); it('should have 90vw width and 80vh height', () => { diff --git a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx index 6cec49599..a16137b48 100644 --- a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx +++ b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx @@ -209,7 +209,7 @@ describe('AutoRun Session Isolation', () => { const propsA = createDefaultProps({ sessionId: 'session-a', - folderPath: '/projects/session-a/Auto Run Docs', + folderPath: '/projects/session-a/.maestro/playbooks', selectedFile: 'Phase 1', content: sessionAContent, }); @@ -226,7 +226,7 @@ describe('AutoRun Session Isolation', () => { // Now switch to Session B - the content should reset to Session B's content const propsB = createDefaultProps({ sessionId: 'session-b', - folderPath: '/projects/session-b/Auto Run Docs', + folderPath: '/projects/session-b/.maestro/playbooks', selectedFile: 'Phase 1', content: sessionBContent, }); @@ -650,7 +650,7 @@ describe('AutoRun Folder Path Isolation', () => { it('different sessions can have different folder paths', async () => { const propsA = createDefaultProps({ sessionId: 'session-a', - folderPath: '/projects/alpha/Auto Run Docs', + folderPath: '/projects/alpha/.maestro/playbooks', selectedFile: 'Phase 1', content: 'Alpha project content', }); @@ -663,7 +663,7 @@ describe('AutoRun Folder Path Isolation', () => { // Switch to session B with different folder const propsB = createDefaultProps({ sessionId: 'session-b', - folderPath: '/projects/beta/Auto Run Docs', + folderPath: '/projects/beta/.maestro/playbooks', selectedFile: 'Phase 1', content: 'Beta project content', }); diff --git a/src/__tests__/renderer/components/CueHelpModal.test.tsx b/src/__tests__/renderer/components/CueHelpModal.test.tsx new file mode 100644 index 000000000..f46318291 --- /dev/null +++ b/src/__tests__/renderer/components/CueHelpModal.test.tsx @@ -0,0 +1,188 @@ +/** + * Tests for CueHelpContent component + * + * CueHelpContent displays comprehensive documentation about the Maestro Cue + * event-driven automation feature. It renders inline within the CueModal. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { CueHelpContent } from '../../../renderer/components/CueHelpModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock formatShortcutKeys to return predictable output +vi.mock('../../../renderer/utils/shortcutFormatter', () => ({ + formatShortcutKeys: (keys: string[]) => keys.join('+'), + isMacOS: () => false, +})); + +// Sample theme for testing +const mockTheme: Theme = { + id: 'test-dark' as Theme['id'], + name: 'Test Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a1a', + bgSidebar: '#252525', + bgActivity: '#2d2d2d', + border: '#444444', + textMain: '#ffffff', + textDim: '#888888', + accent: '#007acc', + accentDim: '#007acc40', + accentText: '#007acc', + accentForeground: '#ffffff', + error: '#ff4444', + success: '#44ff44', + warning: '#ffaa00', + }, +}; + +describe('CueHelpContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Content Sections', () => { + beforeEach(() => { + render(); + }); + + it('should render What is Maestro Cue section', () => { + expect(screen.getByText('What is Maestro Cue?')).toBeInTheDocument(); + expect(screen.getByText(/event-driven automation system/)).toBeInTheDocument(); + }); + + it('should render Getting Started section', () => { + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + expect(screen.getByText(/\.maestro\/cue\.yaml/)).toBeInTheDocument(); + }); + + it('should render minimal YAML example', () => { + expect(screen.getByText(/My First Cue/)).toBeInTheDocument(); + }); + + it('should render Event Types section', () => { + expect(screen.getByText('Event Types')).toBeInTheDocument(); + }); + + it('should render all event types', () => { + expect(screen.getAllByText('Heartbeat').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('File Watch')).toBeInTheDocument(); + expect(screen.getByText('Agent Completed')).toBeInTheDocument(); + }); + + it('should render event type codes', () => { + expect(screen.getByText('time.heartbeat')).toBeInTheDocument(); + expect(screen.getByText('file.changed')).toBeInTheDocument(); + expect(screen.getByText('agent.completed')).toBeInTheDocument(); + }); + + it('should render Template Variables section', () => { + expect(screen.getByText('Template Variables')).toBeInTheDocument(); + }); + + it('should render CUE template variables', () => { + expect(screen.getByText('{{CUE_EVENT_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_EVENT_TIMESTAMP}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_TRIGGER_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_RUN_ID}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_FILE_PATH}}')).toBeInTheDocument(); + }); + + it('should render new file and agent completion template variables', () => { + expect(screen.getByText('{{CUE_FILE_CHANGE_TYPE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_STATUS}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_EXIT_CODE}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_DURATION}}')).toBeInTheDocument(); + expect(screen.getByText('{{CUE_SOURCE_TRIGGERED_BY}}')).toBeInTheDocument(); + }); + + it('should mention standard Maestro template variables', () => { + expect(screen.getByText('{{AGENT_NAME}}')).toBeInTheDocument(); + expect(screen.getByText('{{DATE}}')).toBeInTheDocument(); + }); + + it('should render Multi-Agent Orchestration section', () => { + expect(screen.getByText('Multi-Agent Orchestration')).toBeInTheDocument(); + }); + + it('should render fan-out and fan-in patterns', () => { + expect(screen.getByText(/Fan-Out:/)).toBeInTheDocument(); + expect(screen.getByText(/Fan-In:/)).toBeInTheDocument(); + }); + + it('should render Timeouts & Failure Handling section', () => { + expect(screen.getByText('Timeouts & Failure Handling')).toBeInTheDocument(); + expect(screen.getByText(/Default timeout is 30 minutes/)).toBeInTheDocument(); + }); + + it('should render Visual Pipeline Editor section', () => { + expect(screen.getByText('Visual Pipeline Editor')).toBeInTheDocument(); + }); + + it('should render Coordination Patterns section', () => { + expect(screen.getByText('Coordination Patterns')).toBeInTheDocument(); + }); + + it('should render all coordination pattern names', () => { + expect(screen.getAllByText('Heartbeat').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Scheduled').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('File Enrichment')).toBeInTheDocument(); + expect(screen.getByText('Research Swarm')).toBeInTheDocument(); + expect(screen.getByText('Sequential Chain')).toBeInTheDocument(); + expect(screen.getByText('Debate')).toBeInTheDocument(); + }); + + it('should render Event Filtering section', () => { + expect(screen.getByText('Event Filtering')).toBeInTheDocument(); + }); + + it('should mention triggeredBy filter', () => { + const elements = screen.getAllByText(/triggeredBy/); + expect(elements.length).toBeGreaterThan(0); + }); + }); + + describe('Shortcut Keys', () => { + it('should render keyboard shortcut tip', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + expect(kbdElements.length).toBeGreaterThan(0); + expect(screen.getByText(/to open the Cue dashboard/)).toBeInTheDocument(); + }); + + it('should render custom shortcut keys when provided', () => { + render(); + + const kbdElements = document.querySelectorAll('kbd'); + const hasCustomShortcut = Array.from(kbdElements).some((kbd) => { + const text = kbd.textContent || ''; + return text.includes('C') || text.includes('c'); + }); + expect(hasCustomShortcut).toBe(true); + }); + }); + + describe('Structure', () => { + it('should render icons for each section', () => { + render(); + + const svgElements = document.querySelectorAll('svg'); + expect(svgElements.length).toBeGreaterThan(5); + }); + + it('should render code elements for technical content', () => { + render(); + + const codeElements = document.querySelectorAll('code'); + expect(codeElements.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CueModal.test.tsx b/src/__tests__/renderer/components/CueModal.test.tsx new file mode 100644 index 000000000..e49c88eaf --- /dev/null +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -0,0 +1,658 @@ +/** + * Tests for CueModal component + * + * Tests the Cue Modal dashboard including: + * - Sessions table rendering (empty state and populated) + * - Active runs section with stop controls + * - Activity log rendering with success/failure indicators + * - Master enable/disable toggle + * - Close button and backdrop click + * - Help view escape-to-go-back behavior + * - Unsaved changes confirmation on close + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { CueModal } from '../../../renderer/components/CueModal'; +import type { Theme } from '../../../renderer/types'; + +// Mock LayerStackContext +const mockRegisterLayer = vi.fn(() => 'layer-cue-modal'); +const mockUnregisterLayer = vi.fn(); + +vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: mockRegisterLayer, + unregisterLayer: mockUnregisterLayer, + }), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_MODAL: 460, + CUE_YAML_EDITOR: 463, + }, +})); + +// Mock CueYamlEditor +vi.mock('../../../renderer/components/CueYamlEditor', () => ({ + CueYamlEditor: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
YAML Editor Mock
: null, +})); + +// Mock CueGraphView (kept for reference - replaced by CuePipelineEditor) +vi.mock('../../../renderer/components/CueGraphView', () => ({ + CueGraphView: () => null, +})); + +// Capture the onDirtyChange callback from CuePipelineEditor +let capturedOnDirtyChange: ((isDirty: boolean) => void) | undefined; + +vi.mock('../../../renderer/components/CuePipelineEditor', () => ({ + CuePipelineEditor: ({ onDirtyChange }: { onDirtyChange?: (isDirty: boolean) => void }) => { + capturedOnDirtyChange = onDirtyChange; + return
Pipeline Editor Mock
; + }, +})); + +// Mock sessionStore +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => { + const mockState = { + sessions: [], + groups: [], + setActiveSessionId: vi.fn(), + }; + return selector(mockState); + }, +})); + +// Mock window.maestro.cue +const mockGetGraphData = vi.fn().mockResolvedValue([]); +const mockDeleteYaml = vi.fn().mockResolvedValue(undefined); +if (!window.maestro) { + (window as unknown as Record).maestro = {}; +} +if (!(window.maestro as Record).cue) { + (window.maestro as Record).cue = {}; +} +(window.maestro.cue as Record).getGraphData = mockGetGraphData; +(window.maestro.cue as Record).deleteYaml = mockDeleteYaml; + +// Mock useCue hook +const mockEnable = vi.fn().mockResolvedValue(undefined); +const mockDisable = vi.fn().mockResolvedValue(undefined); +const mockStopRun = vi.fn().mockResolvedValue(undefined); +const mockStopAll = vi.fn().mockResolvedValue(undefined); +const mockRefresh = vi.fn().mockResolvedValue(undefined); + +const defaultUseCueReturn = { + sessions: [], + activeRuns: [], + activityLog: [], + queueStatus: {} as Record, + loading: false, + enable: mockEnable, + disable: mockDisable, + stopRun: mockStopRun, + stopAll: mockStopAll, + refresh: mockRefresh, +}; + +let mockUseCueReturn = { ...defaultUseCueReturn }; + +vi.mock('../../../renderer/hooks/useCue', () => ({ + useCue: () => mockUseCueReturn, +})); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const mockSession = { + sessionId: 'sess-1', + sessionName: 'Test Session', + toolType: 'claude-code', + projectRoot: '/test/project', + enabled: true, + subscriptionCount: 3, + activeRuns: 1, + lastTriggered: new Date().toISOString(), +}; + +const mockActiveRun = { + runId: 'run-1', + sessionId: 'sess-1', + sessionName: 'Test Session', + subscriptionName: 'on-save', + event: { + id: 'evt-1', + type: 'file.changed' as const, + timestamp: new Date().toISOString(), + triggerName: 'on-save', + payload: { file: '/src/index.ts' }, + }, + status: 'running' as const, + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: '', +}; + +const mockCompletedRun = { + ...mockActiveRun, + runId: 'run-2', + status: 'completed' as const, + stdout: 'Done', + exitCode: 0, + durationMs: 5000, + endedAt: new Date().toISOString(), +}; + +const mockFailedRun = { + ...mockActiveRun, + runId: 'run-3', + status: 'failed' as const, + stderr: 'Error occurred', + exitCode: 1, + durationMs: 2000, + endedAt: new Date().toISOString(), +}; + +describe('CueModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseCueReturn = { ...defaultUseCueReturn }; + capturedOnDirtyChange = undefined; + }); + + describe('rendering', () => { + it('should render the modal with header', () => { + render(); + + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should register layer on mount and unregister on unmount', () => { + const { unmount } = render(); + + expect(mockRegisterLayer).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal', + priority: 460, + }) + ); + + unmount(); + expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-cue-modal'); + }); + + it('should show loading state on dashboard tab', () => { + mockUseCueReturn = { ...defaultUseCueReturn, loading: true }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Loading Cue status...')).toBeInTheDocument(); + }); + }); + + describe('sessions table', () => { + it('should show empty state when no sessions have Cue configs', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/No sessions have a cue config file/)).toBeInTheDocument(); + }); + + it('should render sessions with status indicators', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Test Session')).toBeInTheDocument(); + expect(screen.getByText('claude-code')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should show Paused status for disabled sessions', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [{ ...mockSession, enabled: false }], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Paused')).toBeInTheDocument(); + }); + }); + + describe('active runs', () => { + it('should show "No active runs" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No active runs')).toBeInTheDocument(); + }); + + it('should render active runs with stop buttons', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('"on-save"')).toBeInTheDocument(); + expect(screen.getByTitle('Stop run')).toBeInTheDocument(); + }); + + it('should call stopRun when stop button is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + fireEvent.click(screen.getByTitle('Stop run')); + expect(mockStopRun).toHaveBeenCalledWith('run-1'); + }); + + it('should show Stop All button when multiple runs active', () => { + const secondRun = { ...mockActiveRun, runId: 'run-2', subscriptionName: 'on-timer' }; + mockUseCueReturn = { + ...defaultUseCueReturn, + activeRuns: [mockActiveRun, secondRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + const stopAllButton = screen.getByText('Stop All'); + expect(stopAllButton).toBeInTheDocument(); + + fireEvent.click(stopAllButton); + expect(mockStopAll).toHaveBeenCalledOnce(); + }); + }); + + describe('activity log', () => { + it('should show "No activity yet" when empty', () => { + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('No activity yet')).toBeInTheDocument(); + }); + + it('should render completed runs with checkmark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockCompletedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/completed in 5s/)).toBeInTheDocument(); + }); + + it('should render failed runs with cross mark', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [mockFailedRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/failed/)).toBeInTheDocument(); + }); + }); + + describe('master toggle', () => { + it('should show Disabled when no sessions are enabled', () => { + render(); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('should show Enabled when sessions are enabled', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('should call disable when toggling off', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + fireEvent.click(screen.getByText('Enabled')); + expect(mockDisable).toHaveBeenCalledOnce(); + }); + + it('should call enable when toggling on', () => { + render(); + + fireEvent.click(screen.getByText('Disabled')); + expect(mockEnable).toHaveBeenCalledOnce(); + }); + }); + + describe('tabs', () => { + it('should render Dashboard and Pipeline Editor tabs', () => { + render(); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Pipeline Editor')).toBeInTheDocument(); + }); + + it('should show Pipeline Editor content by default', () => { + render(); + + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + // Dashboard content should not be visible by default + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + + it('should switch to dashboard when Dashboard tab is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + // Pipeline editor should not be visible + expect(screen.queryByTestId('cue-pipeline-editor')).not.toBeInTheDocument(); + }); + + it('should switch back to Pipeline Editor when Pipeline Editor tab is clicked', () => { + render(); + + // Switch to dashboard + fireEvent.click(screen.getByText('Dashboard')); + expect(screen.getByText('Sessions with Cue')).toBeInTheDocument(); + + // Switch back to pipeline editor + fireEvent.click(screen.getByText('Pipeline Editor')); + expect(screen.getByTestId('cue-pipeline-editor')).toBeInTheDocument(); + expect(screen.queryByText('Sessions with Cue')).not.toBeInTheDocument(); + }); + }); + + describe('toggle styling', () => { + it('should use theme accent color for enabled toggle', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + + const enabledButton = screen.getByText('Enabled').closest('button'); + expect(enabledButton).toHaveStyle({ + color: mockTheme.colors.accent, + }); + + // The toggle pill should use theme accent + const togglePill = enabledButton?.querySelector('.rounded-full'); + expect(togglePill).toHaveStyle({ + backgroundColor: mockTheme.colors.accent, + }); + }); + + it('should use dim colors for disabled toggle', () => { + render(); + + const disabledButton = screen.getByText('Disabled').closest('button'); + expect(disabledButton).toHaveStyle({ + color: mockTheme.colors.textDim, + }); + }); + }); + + describe('Edit YAML button', () => { + it('should render Edit YAML button for each session', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText('Edit YAML')).toBeInTheDocument(); + }); + + it('should not crash when Edit YAML is clicked (handler is a stub)', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + // Edit YAML handler is currently a stub (CueYamlEditor was replaced by pipeline editor) + expect(() => fireEvent.click(screen.getByText('Edit YAML'))).not.toThrow(); + }); + }); + + describe('close behavior', () => { + it('should call onClose when close button is clicked (no unsaved changes)', () => { + render(); + + // The close button has an X icon + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((b) => b.querySelector('.lucide-x')); + if (closeButton) { + fireEvent.click(closeButton); + expect(mockOnClose).toHaveBeenCalledOnce(); + } + }); + + it('should show confirmation when closing with unsaved pipeline changes via escape', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Simulate pipeline becoming dirty + expect(capturedOnDirtyChange).toBeDefined(); + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape (which goes through the same dirty check) + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalledWith( + 'You have unsaved changes in the pipeline editor. Discard and close?' + ); + // User declined, so onClose should NOT be called + expect(mockOnClose).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + + it('should close when user confirms discarding unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + // Simulate pipeline becoming dirty + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(mockOnClose).toHaveBeenCalledOnce(); + + confirmSpy.mockRestore(); + }); + + it('should not show confirmation after pipeline changes are saved (dirty cleared)', () => { + render(); + + // Simulate pipeline becoming dirty then saved + act(() => { + capturedOnDirtyChange!(true); + }); + act(() => { + capturedOnDirtyChange!(false); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + // Should close without confirmation + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + }); + + describe('help view escape behavior', () => { + it('should navigate to help view when help button is clicked', () => { + render(); + + // Click help button + const helpButton = screen.getByTitle('Help'); + fireEvent.click(helpButton); + + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + }); + + it('should go back from help view when escape is pressed (not close modal)', () => { + render(); + + // Click help button to enter help view + const helpButton = screen.getByTitle('Help'); + fireEvent.click(helpButton); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Trigger the onEscape callback from the registered layer + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + act(() => { + layerConfig.onEscape(); + }); + + // Should go back to main view, not close the modal + expect(mockOnClose).not.toHaveBeenCalled(); + // Help view should be gone, main header should be back + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should go back from help view via back arrow button', () => { + render(); + + // Click help button + fireEvent.click(screen.getByTitle('Help')); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Click the back arrow + fireEvent.click(screen.getByTitle('Back to dashboard')); + + // Should be back to main view + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + }); + + it('should close modal on escape when not in help view', () => { + render(); + + // Trigger the onEscape callback + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + + it('should show confirmation on escape when pipeline has unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Simulate dirty pipeline + act(() => { + capturedOnDirtyChange!(true); + }); + + // Trigger escape + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + layerConfig.onEscape(); + + expect(confirmSpy).toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); + + it('should not show confirmation on escape from help view even with unsaved changes', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + // Make pipeline dirty + act(() => { + capturedOnDirtyChange!(true); + }); + + // Enter help view + fireEvent.click(screen.getByTitle('Help')); + expect(screen.getByText('Maestro Cue Guide')).toBeInTheDocument(); + + // Press escape — should go back from help, not trigger confirmation + const layerConfig = mockRegisterLayer.mock.calls[0][0]; + act(() => { + layerConfig.onEscape(); + }); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Maestro Cue')).toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx new file mode 100644 index 000000000..d6004d895 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineSelector.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PipelineSelector } from '../../../../renderer/components/CuePipelineEditor/PipelineSelector'; +import type { CuePipeline } from '../../../../shared/cue-pipeline-types'; + +const mockPipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'Deploy Pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + }, + { + id: 'p2', + name: 'Review Pipeline', + color: '#8b5cf6', + nodes: [], + edges: [], + }, +]; + +const defaultProps = { + pipelines: mockPipelines, + selectedPipelineId: null as string | null, + onSelect: vi.fn(), + onCreatePipeline: vi.fn(), + onDeletePipeline: vi.fn(), + onRenamePipeline: vi.fn(), + onChangePipelineColor: vi.fn(), +}; + +describe('PipelineSelector', () => { + it('should show "All Pipelines" when no pipeline is selected', () => { + render(); + expect(screen.getByText('All Pipelines')).toBeInTheDocument(); + }); + + it('should show selected pipeline name', () => { + render(); + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should open dropdown on click and list all pipelines', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + // Dropdown shows All Pipelines option + each pipeline + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + expect(screen.getByText('Review Pipeline')).toBeInTheDocument(); + expect(screen.getByText('New Pipeline')).toBeInTheDocument(); + }); + + it('should call onSelect when a pipeline is clicked', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('Deploy Pipeline')); + + expect(onSelect).toHaveBeenCalledWith('p1'); + }); + + it('should call onCreatePipeline when New Pipeline is clicked', () => { + const onCreatePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + fireEvent.click(screen.getByText('New Pipeline')); + + expect(onCreatePipeline).toHaveBeenCalled(); + }); + + it('should enter rename mode on double-click', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + expect(input).toBeInTheDocument(); + }); + + it('should call onRenamePipeline on Enter in rename mode', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.change(input, { target: { value: 'Renamed Pipeline' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onRenamePipeline).toHaveBeenCalledWith('p1', 'Renamed Pipeline'); + }); + + it('should cancel rename on Escape', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pipelineItem = screen.getByText('Deploy Pipeline').closest('div[class]')!; + fireEvent.doubleClick(pipelineItem); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(onRenamePipeline).not.toHaveBeenCalled(); + // Should be back to showing text, not input + expect(screen.getByText('Deploy Pipeline')).toBeInTheDocument(); + }); + + it('should enter rename mode when pencil icon is clicked', () => { + const onRenamePipeline = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + const pencilButtons = screen.getAllByTitle('Rename pipeline'); + expect(pencilButtons.length).toBeGreaterThan(0); + + fireEvent.click(pencilButtons[0]); + + const input = screen.getByDisplayValue('Deploy Pipeline'); + expect(input).toBeInTheDocument(); + }); + + it('should show color picker when color dot is clicked', () => { + const onChangePipelineColor = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /All Pipelines/i })); + + // Click the first color dot (has title "Change color") + const colorDots = screen.getAllByTitle('Change color'); + expect(colorDots.length).toBeGreaterThan(0); + fireEvent.click(colorDots[0]); + + // Color palette should appear with 12 swatches + const swatches = screen.getAllByTitle(/^#/); + expect(swatches.length).toBe(12); + + // Click a swatch + fireEvent.click(swatches[2]); // yellow #eab308 + expect(onChangePipelineColor).toHaveBeenCalledWith('p1', '#eab308'); + }); + + it('should apply custom textColor and borderColor', () => { + const { container } = render( + + ); + + const button = container.querySelector('button')!; + // JSDOM normalizes hex to rgb + expect(button.style.color).toBe('rgb(255, 0, 0)'); + expect(button.style.border).toContain('rgb(0, 255, 0)'); + }); + + it('should use default colors when textColor and borderColor are not provided', () => { + const { container } = render(); + + const button = container.querySelector('button')!; + // Browser normalizes rgba spacing + expect(button.style.color).toContain('rgba'); + expect(button.style.color).toContain('0.9'); + expect(button.style.border).toContain('rgba'); + expect(button.style.border).toContain('0.12'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx new file mode 100644 index 000000000..22a8a6ef5 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/AgentDrawer.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AgentDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/AgentDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +const mockGroups = [ + { id: 'grp-1', name: 'Dev', emoji: '🛠️' }, + { id: 'grp-2', name: 'Ops', emoji: '🚀' }, +]; + +const mockSessions = [ + { id: 'sess-1', name: 'Maestro', toolType: 'claude-code', groupId: 'grp-1' }, + { id: 'sess-2', name: 'Codex Helper', toolType: 'codex', groupId: 'grp-2' }, + { id: 'sess-3', name: 'Review Bot', toolType: 'claude-code', groupId: 'grp-1' }, +]; + +describe('AgentDrawer', () => { + it('should render all sessions when open', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.getByText('Review Bot')).toBeInTheDocument(); + }); + + it('should filter sessions by name', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'maestro' } }); + + expect(screen.getByText('Maestro')).toBeInTheDocument(); + expect(screen.queryByText('Codex Helper')).not.toBeInTheDocument(); + expect(screen.queryByText('Review Bot')).not.toBeInTheDocument(); + }); + + it('should filter sessions by toolType', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'codex' } }); + + expect(screen.getByText('Codex Helper')).toBeInTheDocument(); + expect(screen.queryByText('Maestro')).not.toBeInTheDocument(); + }); + + it('should show empty state when no agents match', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No agents match')).toBeInTheDocument(); + }); + + it('should show empty state when no sessions provided', () => { + render( {}} sessions={[]} theme={mockTheme} />); + + expect(screen.getByText('No agents available')).toBeInTheDocument(); + }); + + it('should show on-canvas indicator for agents already on canvas', () => { + const onCanvas = new Set(['sess-1']); + render( + {}} + sessions={mockSessions} + onCanvasSessionIds={onCanvas} + theme={mockTheme} + /> + ); + + const indicators = screen.getAllByTitle('On canvas'); + expect(indicators).toHaveLength(1); + }); + + it('should group agents by user-defined groups', () => { + render( + {}} + sessions={mockSessions} + groups={mockGroups} + theme={mockTheme} + /> + ); + + expect(screen.getByText('🛠️ Dev')).toBeInTheDocument(); + expect(screen.getByText('🚀 Ops')).toBeInTheDocument(); + }); + + it('should alphabetize groups and agents within groups', () => { + const groups = [ + { id: 'grp-z', name: 'Zeta', emoji: '⚡' }, + { id: 'grp-a', name: 'Alpha', emoji: '🅰️' }, + ]; + const sessions = [ + { id: 's1', name: 'Charlie', toolType: 'claude-code', groupId: 'grp-a' }, + { id: 's2', name: 'Alice', toolType: 'claude-code', groupId: 'grp-a' }, + { id: 's3', name: 'Bravo', toolType: 'codex', groupId: 'grp-z' }, + { id: 's4', name: 'Delta', toolType: 'codex', groupId: 'grp-z' }, + { id: 's5', name: 'Echo', toolType: 'codex' }, // ungrouped + ]; + + const { container } = render( + {}} + sessions={sessions} + groups={groups} + theme={mockTheme} + /> + ); + + // Verify group order: Alpha before Zeta, Ungrouped last + const groupHeaders = container.querySelectorAll('[style*="text-transform: uppercase"]'); + const headerTexts = Array.from(groupHeaders).map((el) => el.textContent); + expect(headerTexts).toEqual(['🅰️ Alpha', '⚡ Zeta', 'Ungrouped']); + + // Verify agent order within each group by checking DOM order + // Each draggable row:
> > > + + // The name is in the first div child with fontWeight:500 + const agentNames = Array.from(container.querySelectorAll('[draggable="true"]')).map( + (el) => el.querySelector('[style*="font-weight: 500"]')?.textContent + ); + expect(agentNames).toEqual(['Alice', 'Charlie', 'Bravo', 'Delta', 'Echo']); + }); + + it('should use theme colors for styling', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const header = screen.getByText('Agents'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make agent items draggable', () => { + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const maestro = screen.getByText('Maestro').closest('[draggable]'); + expect(maestro).toHaveAttribute('draggable', 'true'); + }); + + it('should auto-focus search input when drawer opens', () => { + vi.useFakeTimers(); + render( + {}} sessions={mockSessions} theme={mockTheme} /> + ); + + const input = screen.getByPlaceholderText('Search agents...'); + vi.advanceTimersByTime(100); + expect(input).toHaveFocus(); + vi.useRealTimers(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx new file mode 100644 index 000000000..9ab167c53 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TriggerDrawer } from '../../../../../renderer/components/CuePipelineEditor/drawers/TriggerDrawer'; +import type { Theme } from '../../../../../renderer/types'; + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentDim: '#bd93f940', + accentText: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + }, +}; + +describe('TriggerDrawer', () => { + it('should render all trigger types when open', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Heartbeat')).toBeInTheDocument(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.getByText('Agent Done')).toBeInTheDocument(); + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.getByText('Pending Task')).toBeInTheDocument(); + }); + + it('should render descriptions for each trigger', () => { + render( {}} theme={mockTheme} />); + + expect(screen.getByText('Run every N minutes')).toBeInTheDocument(); + expect(screen.getByText('Run at specific times & days')).toBeInTheDocument(); + expect(screen.getByText('Watch for file modifications')).toBeInTheDocument(); + expect(screen.getByText('After an agent finishes')).toBeInTheDocument(); + }); + + it('should filter triggers by label', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'file' } }); + + expect(screen.getByText('File Change')).toBeInTheDocument(); + expect(screen.queryByText('Heartbeat')).not.toBeInTheDocument(); + expect(screen.queryByText('Pull Request')).not.toBeInTheDocument(); + }); + + it('should filter triggers by event type', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'github' } }); + + expect(screen.getByText('Pull Request')).toBeInTheDocument(); + expect(screen.getByText('Issue')).toBeInTheDocument(); + expect(screen.queryByText('Heartbeat')).not.toBeInTheDocument(); + }); + + it('should filter triggers by description', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'minutes' } }); + + expect(screen.getByText('Heartbeat')).toBeInTheDocument(); + expect(screen.queryByText('File Change')).not.toBeInTheDocument(); + }); + + it('should show empty state when no triggers match', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'zzzznothing' } }); + + expect(screen.getByText('No triggers match')).toBeInTheDocument(); + }); + + it('should use theme colors for styling', () => { + render( {}} theme={mockTheme} />); + + const header = screen.getByText('Triggers'); + expect(header).toHaveStyle({ color: mockTheme.colors.textMain }); + }); + + it('should be hidden when not open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(-100%)'); + }); + + it('should be visible when open', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + const drawer = container.firstChild as HTMLElement; + expect(drawer.style.transform).toBe('translateX(0)'); + }); + + it('should make trigger items draggable', () => { + render( {}} theme={mockTheme} />); + + const heartbeat = screen.getByText('Heartbeat').closest('[draggable]'); + expect(heartbeat).toHaveAttribute('draggable', 'true'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx new file mode 100644 index 000000000..88e8fd059 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AgentNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; +import { ReactFlowProvider } from 'reactflow'; +import type { NodeProps } from 'reactflow'; +import type { AgentNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/AgentNode'; + +const defaultData: AgentNodeDataProps = { + compositeId: 'pipeline-1:agent-1', + sessionId: 'sess-1', + sessionName: 'Test Agent', + toolType: 'claude-code', + hasPrompt: false, + hasOutgoingEdge: false, + pipelineColor: '#06b6d4', + pipelineCount: 1, + pipelineColors: ['#06b6d4'], +}; + +function renderAgentNode(overrides: Partial = {}) { + const data = { ...defaultData, ...overrides }; + const props = { + id: 'test-node', + data, + type: 'agent', + selected: false, + isConnectable: true, + xPos: 0, + yPos: 0, + zIndex: 0, + dragging: false, + } as NodeProps; + + return render( + + + + ); +} + +describe('AgentNode', () => { + it('should render session name and tool type', () => { + const { getByText } = renderAgentNode(); + + expect(getByText('Test Agent')).toBeInTheDocument(); + expect(getByText('claude-code')).toBeInTheDocument(); + }); + + it('should not clip badge overflow (overflow: visible on root)', () => { + const { container } = renderAgentNode({ pipelineCount: 3 }); + + // Find the agent node root div (variable width with min-width, position: relative) + const rootDiv = container.querySelector('div[style*="min-width: 180px"]') as HTMLElement; + expect(rootDiv).not.toBeNull(); + expect(rootDiv.style.overflow).toBe('visible'); + }); + + it('should render a drag handle with the drag-handle class', () => { + const { container } = renderAgentNode(); + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeNull(); + }); + + it('should render a gear icon for configuration', () => { + const { container } = renderAgentNode(); + // Gear icon area has title="Configure" + const gearButton = container.querySelector('[title="Configure"]'); + expect(gearButton).not.toBeNull(); + }); + + it('should show pipeline count badge when pipelineCount > 1', () => { + const { getByText } = renderAgentNode({ pipelineCount: 3 }); + + expect(getByText('3')).toBeInTheDocument(); + }); + + it('should not show pipeline count badge when pipelineCount is 1', () => { + const { queryByText } = renderAgentNode({ pipelineCount: 1 }); + + // No badge number should be rendered + const badge = queryByText('1'); + expect(badge).toBeNull(); + }); + + it('should show multi-pipeline color dots when multiple colors', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4', '#8b5cf6', '#f59e0b'], + }); + + // Find color dots (8x8 circles) + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(3); + }); + + it('should not show multi-pipeline dots with single color', () => { + const { container } = renderAgentNode({ + pipelineColors: ['#06b6d4'], + }); + + // No color strip should render + const dots = container.querySelectorAll( + 'div[style*="border-radius: 50%"][style*="width: 8px"]' + ); + expect(dots.length).toBe(0); + }); + + it('should show prompt icon when hasPrompt is true', () => { + const { container } = renderAgentNode({ hasPrompt: true }); + + // MessageSquare icon renders as an SVG + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts new file mode 100644 index 000000000..c7cfa3642 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for pipeline layout merge/restore utilities. + * + * Verifies that saved layout state is correctly merged with live pipeline + * data, including the critical case where selectedPipelineId is null + * ("All Pipelines" mode). + */ + +import { describe, it, expect } from 'vitest'; +import { mergePipelinesWithSavedLayout } from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineLayout'; +import type { CuePipeline, PipelineLayoutState } from '../../../../../shared/cue-pipeline-types'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Timer', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + }, + }, + ], + edges: [{ id: 'e1', source: 'trigger-1', target: 'agent-1', mode: 'pass' }], + ...overrides, + }; +} + +describe('mergePipelinesWithSavedLayout', () => { + it('preserves null selectedPipelineId (All Pipelines mode)', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline()], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBeNull(); + }); + + it('preserves a specific selectedPipelineId from saved layout', () => { + const livePipelines = [makePipeline(), makePipeline({ id: 'p2', name: 'second' })]; + const savedLayout: PipelineLayoutState = { + pipelines: livePipelines, + selectedPipelineId: 'p2', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBe('p2'); + }); + + it('defaults to first pipeline id when selectedPipelineId is missing from layout', () => { + const livePipelines = [makePipeline()]; + // Simulate a legacy saved layout that doesn't have selectedPipelineId at all + const savedLayout = { + pipelines: [makePipeline()], + } as PipelineLayoutState; + + // Delete the property so `in` check fails + delete (savedLayout as unknown as Record).selectedPipelineId; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.selectedPipelineId).toBe('p1'); + }); + + it('merges saved node positions with live pipeline data', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [ + makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 100, y: 200 }, + data: { + eventType: 'time.heartbeat', + label: 'Timer', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 500, y: 300 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + }, + }, + ], + }), + ], + selectedPipelineId: 'p1', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // Positions from saved layout should override live defaults + const triggerNode = result.pipelines[0].nodes.find((n) => n.id === 'trigger-1'); + const agentNode = result.pipelines[0].nodes.find((n) => n.id === 'agent-1'); + expect(triggerNode?.position).toEqual({ x: 100, y: 200 }); + expect(agentNode?.position).toEqual({ x: 500, y: 300 }); + }); + + it('keeps live node positions when saved layout has no matching nodes', () => { + const livePipelines = [makePipeline()]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ nodes: [] })], + selectedPipelineId: 'p1', + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // Original positions preserved + const triggerNode = result.pipelines[0].nodes.find((n) => n.id === 'trigger-1'); + expect(triggerNode?.position).toEqual({ x: 0, y: 0 }); + }); + + it('returns all live pipelines even when saved layout has fewer', () => { + const livePipelines = [ + makePipeline({ id: 'p1', name: 'first' }), + makePipeline({ id: 'p2', name: 'second' }), + ]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ id: 'p1', name: 'first' })], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + expect(result.pipelines).toHaveLength(2); + expect(result.selectedPipelineId).toBeNull(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts new file mode 100644 index 000000000..9ef8b2252 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts @@ -0,0 +1,771 @@ +/** + * Tests for pipelineToYaml conversion utilities. + * + * Verifies that visual pipeline graphs correctly convert to + * CueSubscription objects and YAML strings. + */ + +import { describe, it, expect } from 'vitest'; +import { + pipelineToYamlSubscriptions, + pipelinesToYaml, +} from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineToYaml'; +import type { CuePipeline } from '../../../../../shared/cue-pipeline-types'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + ...overrides, + }; +} + +describe('pipelineToYamlSubscriptions', () => { + it('returns empty array for pipeline with no nodes', () => { + const pipeline = makePipeline(); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('returns empty array for trigger with no outgoing edges', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + ], + }); + expect(pipelineToYamlSubscriptions(pipeline)).toEqual([]); + }); + + it('converts simple trigger -> agent chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Scheduled', + config: { interval_minutes: 10 }, + }, + }, + { + id: 'agent-1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do the work', + }, + }, + ], + edges: [{ id: 'e1', source: 'trigger-1', target: 'agent-1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('time.heartbeat'); + expect(subs[0].interval_minutes).toBe(10); + expect(subs[0].prompt).toBe('Do the work'); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'file.changed', + label: 'File Change', + config: { watch: 'src/**/*.ts' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build it', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Test it', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(2); + + expect(subs[0].name).toBe('test-pipeline'); + expect(subs[0].event).toBe('file.changed'); + expect(subs[0].watch).toBe('src/**/*.ts'); + expect(subs[0].prompt).toBe('Build it'); + + expect(subs[1].name).toBe('test-pipeline-chain-1'); + expect(subs[1].event).toBe('agent.completed'); + expect(subs[1].source_session).toBe('builder'); + expect(subs[1].prompt).toBe('Test it'); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Scheduled', + config: { interval_minutes: 30 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { + sessionId: 's1', + sessionName: 'worker-a', + toolType: 'claude-code', + inputPrompt: 'Task A', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { + sessionId: 's2', + sessionName: 'worker-b', + toolType: 'claude-code', + inputPrompt: 'Task B', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].fan_out).toEqual(['worker-a', 'worker-b']); + expect(subs[0].interval_minutes).toBe(30); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Scheduled', + config: { interval_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: -100 }, + data: { + sessionId: 's1', + sessionName: 'worker-a', + toolType: 'claude-code', + inputPrompt: 'A', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 100 }, + data: { + sessionId: 's2', + sessionName: 'worker-b', + toolType: 'claude-code', + inputPrompt: 'B', + }, + }, + { + id: 'a3', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's3', + sessionName: 'aggregator', + toolType: 'claude-code', + inputPrompt: 'Combine', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 't1', target: 'a2', mode: 'pass' }, + { id: 'e3', source: 'a1', target: 'a3', mode: 'pass' }, + { id: 'e4', source: 'a2', target: 'a3', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + + // Find the fan-in subscription (the one targeting aggregator) + const fanInSub = subs.find((s) => s.source_session && Array.isArray(s.source_session)); + expect(fanInSub).toBeDefined(); + expect(fanInSub!.event).toBe('agent.completed'); + expect(fanInSub!.source_session).toEqual(['worker-a', 'worker-b']); + expect(fanInSub!.prompt).toBe('Combine'); + }); + + it('maps github.pull_request trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'github.pull_request', + label: 'PR', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'reviewer', + toolType: 'claude-code', + inputPrompt: 'Review PR', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].repo).toBe('owner/repo'); + expect(subs[0].poll_minutes).toBe(5); + expect(subs[0].event).toBe('github.pull_request'); + }); + + it('maps task.pending trigger config', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'task.pending', + label: 'Task', + config: { watch: 'docs/**/*.md' }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'tasker', + toolType: 'claude-code', + inputPrompt: 'Complete tasks', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].watch).toBe('docs/**/*.md'); + expect(subs[0].event).toBe('task.pending'); + }); +}); + +describe('pipelinesToYaml', () => { + it('produces valid YAML with prompt_file references', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Scheduled', + config: { interval_minutes: 15 }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do stuff', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr, promptFiles } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('# Pipeline: test-pipeline (color: #06b6d4)'); + expect(yamlStr).toContain('subscriptions:'); + expect(yamlStr).toContain('name: test-pipeline'); + expect(yamlStr).toContain('event: time.heartbeat'); + expect(yamlStr).toContain('interval_minutes: 15'); + expect(yamlStr).toContain('prompt_file: .maestro/prompts/worker-test-pipeline.md'); + expect(yamlStr).not.toContain('prompt: Do stuff'); + + // Prompt content saved to external file + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline.md')).toBe('Do stuff'); + }); + + it('includes settings block when provided', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { sessionId: 's1', sessionName: 'w', toolType: 'claude-code', inputPrompt: 'go' }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline], { + timeout_minutes: 60, + max_concurrent: 3, + }); + expect(yamlStr).toContain('settings:'); + expect(yamlStr).toContain('timeout_minutes: 60'); + expect(yamlStr).toContain('max_concurrent: 3'); + }); + + it('adds debate mode edge comment', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'debater', + toolType: 'claude-code', + inputPrompt: 'argue', + }, + }, + ], + edges: [ + { + id: 'e1', + source: 't1', + target: 'a1', + mode: 'debate' as const, + debateConfig: { maxRounds: 5, timeoutPerRound: 120 }, + }, + ], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('mode: debate, max_rounds: 5, timeout_per_round: 120'); + }); + + it('handles multiple pipelines', () => { + const p1 = makePipeline({ + id: 'p1', + name: 'pipeline-a', + color: '#06b6d4', + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'w1', + toolType: 'claude-code', + inputPrompt: 'go 1', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const p2 = makePipeline({ + id: 'p2', + name: 'pipeline-b', + color: '#8b5cf6', + nodes: [ + { + id: 't2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*.md' } }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'w2', + toolType: 'claude-code', + inputPrompt: 'go 2', + }, + }, + ], + edges: [{ id: 'e2', source: 't2', target: 'a2', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([p1, p2]); + expect(yamlStr).toContain('# Pipeline: pipeline-a'); + expect(yamlStr).toContain('# Pipeline: pipeline-b'); + expect(yamlStr).toContain('name: pipeline-a'); + expect(yamlStr).toContain('name: pipeline-b'); + }); + + it('returns empty subscriptions for empty pipelines array', () => { + const { yaml: yamlStr } = pipelinesToYaml([]); + expect(yamlStr).toContain('subscriptions: []'); + }); + + it('includes agent_id from agent node sessionId', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 'uuid-abc-123', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'go', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('agent_id: uuid-abc-123'); + }); + + it('includes agent_id for each agent in a chain', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'Files', config: { watch: '**/*' } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 'id-builder', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 'id-tester', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'test', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const { yaml: yamlStr } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('agent_id: id-builder'); + expect(yamlStr).toContain('agent_id: id-tester'); + }); + + it('saves output_prompt to separate file with -output suffix', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Do work', + outputPrompt: 'Summarize output', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' }], + }); + + const { yaml: yamlStr, promptFiles } = pipelinesToYaml([pipeline]); + expect(yamlStr).toContain('prompt_file: .maestro/prompts/worker-test-pipeline.md'); + expect(yamlStr).toContain( + 'output_prompt_file: .maestro/prompts/worker-test-pipeline-output.md' + ); + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline.md')).toBe('Do work'); + expect(promptFiles.get('.maestro/prompts/worker-test-pipeline-output.md')).toBe( + 'Summarize output' + ); + }); + + it('uses edge prompt when available instead of agent node prompt', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Timer', config: { interval_minutes: 5 } }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'node-level prompt', + }, + }, + ], + edges: [ + { + id: 'e1', + source: 't1', + target: 'a1', + mode: 'pass' as const, + prompt: 'edge-level prompt', + }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(1); + expect(subs[0].prompt).toBe('edge-level prompt'); + }); + + it('serializes trigger customLabel as subscription label', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.scheduled', + label: 'Scheduled', + customLabel: 'Morning Check', + config: { schedule_times: ['08:30'] }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + inputPrompt: 'Check stuff', + }, + }, + ], + edges: [{ id: 'e1', source: 't1', target: 'a1', mode: 'pass' as const }], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[0].label).toBe('Morning Check'); + }); + + it('creates separate subscriptions for multiple triggers targeting same agent with edge prompts', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: -100 }, + data: { + eventType: 'time.scheduled', + label: 'Scheduled', + customLabel: 'Morning', + config: { schedule_times: ['08:30'] }, + }, + }, + { + id: 't2', + type: 'trigger', + position: { x: 0, y: 100 }, + data: { + eventType: 'time.scheduled', + label: 'Scheduled', + customLabel: 'Evening', + config: { schedule_times: ['17:30'] }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' as const, prompt: 'Morning routine' }, + { id: 'e2', source: 't2', target: 'a1', mode: 'pass' as const, prompt: 'Evening wrap-up' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs).toHaveLength(2); + expect(subs[0].prompt).toBe('Morning routine'); + expect(subs[0].label).toBe('Morning'); + expect(subs[0].schedule_times).toEqual(['08:30']); + expect(subs[1].prompt).toBe('Evening wrap-up'); + expect(subs[1].label).toBe('Evening'); + expect(subs[1].schedule_times).toEqual(['17:30']); + }); + + it('generates unique prompt file paths for multiple triggers targeting same agent', () => { + const pipeline = makePipeline({ + nodes: [ + { + id: 't1', + type: 'trigger', + position: { x: 0, y: -100 }, + data: { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { schedule_times: ['08:30'] }, + }, + }, + { + id: 't2', + type: 'trigger', + position: { x: 0, y: 100 }, + data: { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { schedule_times: ['17:30'] }, + }, + }, + { + id: 'a1', + type: 'agent', + position: { x: 300, y: 0 }, + data: { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' as const, prompt: 'Prompt A' }, + { id: 'e2', source: 't2', target: 'a1', mode: 'pass' as const, prompt: 'Prompt B' }, + ], + }); + + const { promptFiles } = pipelinesToYaml([pipeline]); + // Should have 2 distinct prompt files, not overwrite + const promptEntries = [...promptFiles.entries()].filter( + ([, content]) => content === 'Prompt A' || content === 'Prompt B' + ); + expect(promptEntries).toHaveLength(2); + expect(promptEntries[0][0]).not.toBe(promptEntries[1][0]); // Different file paths + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts new file mode 100644 index 000000000..8f5857cd2 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts @@ -0,0 +1,645 @@ +/** + * Tests for yamlToPipeline conversion utilities. + * + * Verifies that CueSubscription objects and CueGraphSession data + * correctly convert back into visual CuePipeline structures. + */ + +import { describe, it, expect } from 'vitest'; +import { + subscriptionsToPipelines, + graphSessionsToPipelines, +} from '../../../../../renderer/components/CuePipelineEditor/utils/yamlToPipeline'; +import type { CueSubscription, CueGraphSession } from '../../../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../../../shared/types'; + +const makeSessions = (...names: string[]): SessionInfo[] => + names.map((name, i) => ({ + id: `session-${i}`, + name, + toolType: 'claude-code' as const, + cwd: '/tmp', + projectRoot: '/tmp', + })); + +describe('subscriptionsToPipelines', () => { + it('returns empty array for no subscriptions', () => { + const result = subscriptionsToPipelines([], []); + expect(result).toEqual([]); + }); + + it('converts a simple trigger -> agent subscription', () => { + const subs: CueSubscription[] = [ + { + name: 'my-pipeline', + event: 'time.heartbeat', + enabled: true, + prompt: 'Do the work', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('my-pipeline'); + + // Should have a trigger node and an agent node + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(1); + + // Trigger should have correct event type and config + expect(triggers[0].data).toMatchObject({ + eventType: 'time.heartbeat', + config: { interval_minutes: 10 }, + }); + + // Agent should have the input prompt + expect(agents[0].data).toMatchObject({ + sessionName: 'worker', + inputPrompt: 'Do the work', + }); + + // Should have one edge connecting them + expect(pipelines[0].edges).toHaveLength(1); + expect(pipelines[0].edges[0].source).toBe(triggers[0].id); + expect(pipelines[0].edges[0].target).toBe(agents[0].id); + }); + + it('converts trigger -> agent1 -> agent2 chain', () => { + const subs: CueSubscription[] = [ + { + name: 'chain-test', + event: 'file.changed', + enabled: true, + prompt: 'Build it', + watch: 'src/**/*.ts', + }, + { + name: 'chain-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test it', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Trigger config + expect(triggers[0].data).toMatchObject({ + eventType: 'file.changed', + config: { watch: 'src/**/*.ts' }, + }); + + // Should have edges: trigger -> builder, builder -> tester + expect(pipelines[0].edges).toHaveLength(2); + }); + + it('handles fan-out (trigger -> [agent1, agent2])', () => { + const subs: CueSubscription[] = [ + { + name: 'fanout-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Task A', + interval_minutes: 30, + fan_out: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(triggers).toHaveLength(1); + expect(agents).toHaveLength(2); + + // Both agents should be connected to the trigger + expect(pipelines[0].edges).toHaveLength(2); + for (const edge of pipelines[0].edges) { + expect(edge.source).toBe(triggers[0].id); + } + + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('worker-a'); + expect(agentNames).toContain('worker-b'); + }); + + it('handles fan-in ([agent1, agent2] -> agent3)', () => { + const subs: CueSubscription[] = [ + { + name: 'fanin-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'fanin-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine results', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'aggregator'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + // worker-a, worker-b, and the aggregator target + expect(agents.length).toBeGreaterThanOrEqual(3); + + // The aggregator should have 2 incoming edges (from worker-a and worker-b) + const aggregatorNode = agents.find( + (a) => (a.data as { sessionName: string }).sessionName === 'aggregator' + ); + expect(aggregatorNode).toBeDefined(); + + const incomingEdges = pipelines[0].edges.filter((e) => e.target === aggregatorNode!.id); + expect(incomingEdges).toHaveLength(2); + }); + + it('maps github.pull_request trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'pr-review', + event: 'github.pull_request', + enabled: true, + prompt: 'Review this PR', + repo: 'owner/repo', + poll_minutes: 5, + }, + ]; + const sessions = makeSessions('reviewer'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger).toBeDefined(); + expect(trigger!.data).toMatchObject({ + eventType: 'github.pull_request', + config: { repo: 'owner/repo', poll_minutes: 5 }, + }); + }); + + it('maps task.pending trigger config', () => { + const subs: CueSubscription[] = [ + { + name: 'task-handler', + event: 'task.pending', + enabled: true, + prompt: 'Complete tasks', + watch: 'docs/**/*.md', + }, + ]; + const sessions = makeSessions('tasker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const trigger = pipelines[0].nodes.find((n) => n.type === 'trigger'); + expect(trigger!.data).toMatchObject({ + eventType: 'task.pending', + config: { watch: 'docs/**/*.md' }, + }); + }); + + it('groups subscriptions into separate pipelines by name prefix', () => { + const subs: CueSubscription[] = [ + { + name: 'pipeline-a', + event: 'time.heartbeat', + enabled: true, + prompt: 'Task A', + interval_minutes: 5, + }, + { + name: 'pipeline-b', + event: 'file.changed', + enabled: true, + prompt: 'Task B', + watch: '**/*.ts', + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(2); + expect(pipelines[0].name).toBe('pipeline-a'); + expect(pipelines[1].name).toBe('pipeline-b'); + }); + + it('assigns unique colors to each pipeline', () => { + const subs: CueSubscription[] = [ + { + name: 'p1', + event: 'time.heartbeat', + enabled: true, + prompt: 'A', + interval_minutes: 5, + }, + { + name: 'p2', + event: 'time.heartbeat', + enabled: true, + prompt: 'B', + interval_minutes: 10, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines[0].color).not.toBe(pipelines[1].color); + }); + + it('auto-layouts nodes left-to-right', () => { + const subs: CueSubscription[] = [ + { + name: 'layout-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Build', + interval_minutes: 5, + }, + { + name: 'layout-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + + // Trigger should be leftmost + expect(triggers[0].position.x).toBe(100); + // First agent should be further right + expect(agents[0].position.x).toBeGreaterThan(triggers[0].position.x); + // Second agent should be even further right (if present) + if (agents.length > 1) { + expect(agents[1].position.x).toBeGreaterThan(agents[0].position.x); + } + }); + + it('deduplicates agent nodes by session name', () => { + const subs: CueSubscription[] = [ + { + name: 'dedup-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Start', + interval_minutes: 5, + fan_out: ['worker-a', 'worker-b'], + }, + { + name: 'dedup-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Combine', + source_session: ['worker-a', 'worker-b'], + }, + ]; + const sessions = makeSessions('worker-a', 'worker-b', 'combiner'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const sessionNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + + // worker-a and worker-b should appear only once each + const workerACount = sessionNames.filter((n) => n === 'worker-a').length; + const workerBCount = sessionNames.filter((n) => n === 'worker-b').length; + expect(workerACount).toBe(1); + expect(workerBCount).toBe(1); + }); + + it('resolves target session from agent_id', () => { + const subs: CueSubscription[] = [ + { + name: 'agent-id-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Do work', + interval_minutes: 10, + agent_id: 'session-1', + }, + ]; + // session-1 maps to 'specific-worker', session-0 maps to 'other-agent' + const sessions = makeSessions('other-agent', 'specific-worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + expect((agents[0].data as { sessionName: string }).sessionName).toBe('specific-worker'); + expect((agents[0].data as { sessionId: string }).sessionId).toBe('session-1'); + }); + + it('resolves agent_id in chain subscriptions', () => { + const subs: CueSubscription[] = [ + { + name: 'chain-id', + event: 'file.changed', + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + agent_id: 'session-0', + }, + { + name: 'chain-id-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + agent_id: 'session-1', + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('builder'); + expect(agentNames).toContain('tester'); + }); + + it('overrides stale agent_id when subscription name matches a different session', () => { + // Bug scenario: agent_id was corrupted (points to Maestro) but subscription + // name "Pedsidian" matches the Pedsidian session. Name match should win. + const subs: CueSubscription[] = [ + { + name: 'Pedsidian', + event: 'time.scheduled', + enabled: true, + prompt: 'Do briefing', + schedule_times: ['08:30'], + schedule_days: ['mon', 'tue', 'wed', 'thu', 'fri'], + agent_id: 'maestro-uuid', // Wrong! Should be pedsidian-uuid + }, + ]; + const sessions: SessionInfo[] = [ + { + id: 'maestro-uuid', + name: 'Maestro', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + { + id: 'pedsidian-uuid', + name: 'Pedsidian', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + ]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + // Should resolve to Pedsidian (name match), not Maestro (stale agent_id) + expect((agents[0].data as { sessionName: string }).sessionName).toBe('Pedsidian'); + expect((agents[0].data as { sessionId: string }).sessionId).toBe('pedsidian-uuid'); + }); + + it('uses subscription name to find target when agent_id is absent', () => { + // Pre-agent_id YAML: subscription named after the target session + const subs: CueSubscription[] = [ + { + name: 'Pedsidian', + event: 'time.scheduled', + enabled: true, + prompt: 'Morning briefing', + schedule_times: ['08:30'], + }, + ]; + const sessions = [ + { + id: 'maestro-uuid', + name: 'Maestro', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + { + id: 'pedsidian-uuid', + name: 'Pedsidian', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + ] as SessionInfo[]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + // Should pick Pedsidian by name, not fall back to sessions[0] (Maestro) + expect((agents[0].data as { sessionName: string }).sessionName).toBe('Pedsidian'); + }); + + it('sets default edge mode to pass', () => { + const subs: CueSubscription[] = [ + { + name: 'mode-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Go', + interval_minutes: 5, + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + for (const edge of pipelines[0].edges) { + expect(edge.mode).toBe('pass'); + } + }); +}); + +describe('graphSessionsToPipelines', () => { + it('extracts subscriptions from graph sessions and converts', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'worker', + toolType: 'claude-code', + subscriptions: [ + { + name: 'graph-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Do work', + interval_minutes: 15, + }, + ], + }, + ]; + const sessions = makeSessions('worker'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('graph-test'); + + const triggers = pipelines[0].nodes.filter((n) => n.type === 'trigger'); + expect(triggers).toHaveLength(1); + expect(triggers[0].data).toMatchObject({ + eventType: 'time.heartbeat', + config: { interval_minutes: 15 }, + }); + }); + + it('combines subscriptions from multiple graph sessions', () => { + const graphSessions: CueGraphSession[] = [ + { + sessionId: 's1', + sessionName: 'builder', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test', + event: 'file.changed', + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + }, + ], + }, + { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + subscriptions: [ + { + name: 'multi-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ], + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + expect(pipelines[0].name).toBe('multi-test'); + expect(pipelines[0].edges.length).toBeGreaterThanOrEqual(2); + }); + + it('returns empty array for no graph sessions', () => { + const result = graphSessionsToPipelines([], []); + expect(result).toEqual([]); + }); + + it('uses owning graph session name for agent nodes (dashboard matching)', () => { + // Simulates the dashboard scenario: a session "PedTome RSSidian" has a + // cue.yaml with an issue trigger. The agent node should use that session's + // name so getPipelineColorForAgent can match it by sessionId. + const graphSessions: CueGraphSession[] = [ + { + sessionId: 'real-uuid-123', + sessionName: 'PedTome RSSidian', + toolType: 'claude-code', + subscriptions: [ + { + name: 'issue-triage', + event: 'github.issue', + enabled: true, + prompt: 'Triage this issue', + repo: 'RunMaestro/Maestro', + }, + ], + }, + ]; + const sessions: SessionInfo[] = [ + { + id: 'real-uuid-123', + name: 'PedTome RSSidian', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + { + id: 'other-uuid-456', + name: 'Maestro', + toolType: 'claude-code', + cwd: '/tmp', + projectRoot: '/tmp', + }, + ]; + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + expect(agents).toHaveLength(1); + expect((agents[0].data as { sessionName: string }).sessionName).toBe('PedTome RSSidian'); + expect((agents[0].data as { sessionId: string }).sessionId).toBe('real-uuid-123'); + }); + + it('correctly maps agents when multiple sessions share subscriptions', () => { + // Two sessions share the same project root / cue.yaml with a chain pipeline. + // Both report all subscriptions. The builder should be target of the initial + // trigger, and the tester should be target of the chain-1 sub. + const sharedSubs = [ + { + name: 'shared-pipeline', + event: 'file.changed' as const, + enabled: true, + prompt: 'Build', + watch: 'src/**/*', + }, + { + name: 'shared-pipeline-chain-1', + event: 'agent.completed' as const, + enabled: true, + prompt: 'Test', + source_session: 'builder', + }, + ]; + const graphSessions: CueGraphSession[] = [ + { + sessionId: 'builder-id', + sessionName: 'builder', + toolType: 'claude-code', + subscriptions: sharedSubs, + }, + { + sessionId: 'tester-id', + sessionName: 'tester', + toolType: 'claude-code', + subscriptions: sharedSubs, + }, + ]; + const sessions = makeSessions('builder', 'tester'); + + const pipelines = graphSessionsToPipelines(graphSessions, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const agentNames = agents.map((a) => (a.data as { sessionName: string }).sessionName); + expect(agentNames).toContain('builder'); + expect(agentNames).toContain('tester'); + }); +}); diff --git a/src/__tests__/renderer/components/CueYamlEditor.test.tsx b/src/__tests__/renderer/components/CueYamlEditor.test.tsx new file mode 100644 index 000000000..9a857b7ac --- /dev/null +++ b/src/__tests__/renderer/components/CueYamlEditor.test.tsx @@ -0,0 +1,818 @@ +/** + * Tests for CueYamlEditor component + * + * Tests the Cue YAML editor including: + * - Loading existing YAML content on mount + * - YAML template shown when no file exists + * - Real-time validation with error display + * - AI assist chat with agent spawn and conversation resume + * - Save/Exit functionality with dirty state + * - Line numbers gutter + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { CueYamlEditor } from '../../../renderer/components/CueYamlEditor'; +import type { Theme } from '../../../renderer/types'; + +// Mock the Modal component +vi.mock('../../../renderer/components/ui/Modal', () => ({ + Modal: ({ + children, + footer, + title, + testId, + onClose, + }: { + children: React.ReactNode; + footer?: React.ReactNode; + title: string; + testId?: string; + onClose: () => void; + }) => ( +
+ +
{children}
+ {footer &&
{footer}
} +
+ ), + ModalFooter: ({ + onCancel, + onConfirm, + confirmLabel, + cancelLabel = 'Cancel', + confirmDisabled, + }: { + onCancel: () => void; + onConfirm: () => void; + confirmLabel: string; + cancelLabel?: string; + confirmDisabled: boolean; + theme: Theme; + }) => ( + <> + + + + ), +})); + +// Mock modal priorities +vi.mock('../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { + CUE_YAML_EDITOR: 463, + CUE_PATTERN_PREVIEW: 464, + }, +})); + +// Mock sessionStore +const mockSession = { + id: 'sess-1', + toolType: 'claude-code', + cwd: '/test/project', + customPath: undefined, + customArgs: undefined, + customEnvVars: undefined, + customModel: undefined, + customContextWindow: undefined, + sessionSshRemoteConfig: undefined, +}; + +vi.mock('../../../renderer/stores/sessionStore', () => ({ + useSessionStore: vi.fn((selector: (s: any) => any) => selector({ sessions: [mockSession] })), + selectSessionById: (id: string) => (state: any) => state.sessions.find((s: any) => s.id === id), +})); + +// Mock buildSpawnConfigForAgent +const mockBuildSpawnConfig = vi.fn(); +vi.mock('../../../renderer/utils/sessionHelpers', () => ({ + buildSpawnConfigForAgent: (...args: any[]) => mockBuildSpawnConfig(...args), +})); + +// Mock IPC methods +const mockReadYaml = vi.fn(); +const mockWriteYaml = vi.fn(); +const mockValidateYaml = vi.fn(); +const mockRefreshSession = vi.fn(); +const mockSpawn = vi.fn(); +const mockOnData = vi.fn(); +const mockOnExit = vi.fn(); +const mockOnSessionId = vi.fn(); +const mockOnAgentError = vi.fn(); + +const existingWindowMaestro = (window as any).maestro; + +beforeEach(() => { + vi.clearAllMocks(); + + (window as any).maestro = { + ...existingWindowMaestro, + cue: { + ...existingWindowMaestro?.cue, + readYaml: mockReadYaml, + writeYaml: mockWriteYaml, + validateYaml: mockValidateYaml, + refreshSession: mockRefreshSession, + }, + process: { + ...existingWindowMaestro?.process, + spawn: mockSpawn, + onData: mockOnData, + onExit: mockOnExit, + onSessionId: mockOnSessionId, + onAgentError: mockOnAgentError, + }, + }; + + // Default: file doesn't exist, YAML is valid + mockReadYaml.mockResolvedValue(null); + mockWriteYaml.mockResolvedValue(undefined); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + mockRefreshSession.mockResolvedValue(undefined); + mockSpawn.mockResolvedValue({ pid: 123, success: true }); + mockBuildSpawnConfig.mockResolvedValue({ + sessionId: 'sess-1-cue-assist-123', + toolType: 'claude-code', + cwd: '/test/project', + command: 'claude', + args: [], + prompt: 'test prompt', + }); + + // Default: listeners return cleanup functions + mockOnData.mockReturnValue(vi.fn()); + mockOnExit.mockReturnValue(vi.fn()); + mockOnSessionId.mockReturnValue(vi.fn()); + mockOnAgentError.mockReturnValue(vi.fn()); +}); + +afterEach(() => { + vi.restoreAllMocks(); + (window as any).maestro = existingWindowMaestro; +}); + +const mockTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + scrollbar: '#44475a', + scrollbarHover: '#6272a4', + }, +}; + +const defaultProps = { + isOpen: true, + onClose: vi.fn(), + projectRoot: '/test/project', + sessionId: 'sess-1', + theme: mockTheme, +}; + +describe('CueYamlEditor', () => { + describe('rendering', () => { + it('should not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('cue-yaml-editor')).not.toBeInTheDocument(); + }); + + it('should render when isOpen is true', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('cue-yaml-editor')).toBeInTheDocument(); + }); + }); + + it('should show loading state initially', () => { + mockReadYaml.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading YAML...')).toBeInTheDocument(); + }); + + it('should render AI assist chat section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('AI Assist')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + expect(screen.getByTestId('ai-chat-history')).toBeInTheDocument(); + }); + + it('should render YAML editor section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('YAML Configuration')).toBeInTheDocument(); + }); + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + it('should render line numbers gutter', async () => { + mockReadYaml.mockResolvedValue('line1\nline2\nline3'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('line-numbers')).toBeInTheDocument(); + }); + expect(screen.getByTestId('line-numbers').textContent).toContain('1'); + expect(screen.getByTestId('line-numbers').textContent).toContain('2'); + expect(screen.getByTestId('line-numbers').textContent).toContain('3'); + }); + }); + + describe('YAML loading', () => { + it('should load existing YAML from projectRoot on mount', async () => { + const existingYaml = 'subscriptions:\n - name: "test"\n event: time.heartbeat'; + mockReadYaml.mockResolvedValue(existingYaml); + + render(); + + await waitFor(() => { + expect(mockReadYaml).toHaveBeenCalledWith('/test/project'); + }); + expect(screen.getByTestId('yaml-editor')).toHaveValue(existingYaml); + }); + + it('should show template when no YAML file exists', async () => { + mockReadYaml.mockResolvedValue(null); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# .maestro/cue.yaml'); + }); + }); + + it('should show template when readYaml throws', async () => { + mockReadYaml.mockRejectedValue(new Error('File read error')); + + render(); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toContain('# .maestro/cue.yaml'); + }); + }); + }); + + describe('validation', () => { + it('should show valid indicator when YAML is valid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + mockValidateYaml.mockResolvedValue({ valid: true, errors: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Valid YAML')).toBeInTheDocument(); + }); + }); + + it('should show validation errors when YAML is invalid', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Missing required field: name'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid: yaml: content' }, + }); + + await waitFor( + () => { + expect(screen.getByTestId('validation-errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + expect(screen.getByText('Missing required field: name')).toBeInTheDocument(); + expect(screen.getByText('1 error')).toBeInTheDocument(); + }); + + it('should show plural error count for multiple errors', async () => { + mockReadYaml.mockResolvedValue('subscriptions: []'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + mockValidateYaml.mockResolvedValue({ + valid: false, + errors: ['Error one', 'Error two'], + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'bad' }, + }); + + await waitFor( + () => { + expect(screen.getByText('2 errors')).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + }); + + it('should debounce validation calls', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('initial'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const editor = screen.getByTestId('yaml-editor'); + fireEvent.change(editor, { target: { value: 'change1' } }); + fireEvent.change(editor, { target: { value: 'change2' } }); + fireEvent.change(editor, { target: { value: 'change3' } }); + + const callsBeforeDebounce = mockValidateYaml.mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(mockValidateYaml.mock.calls.length).toBe(callsBeforeDebounce + 1); + expect(mockValidateYaml).toHaveBeenLastCalledWith('change3'); + + vi.useRealTimers(); + }); + }); + + describe('AI assist chat', () => { + it('should have disabled send button when input is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-send')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('ai-chat-send')).toBeDisabled(); + }); + + it('should enable send button when input has text', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Watch for file changes' }, + }); + + expect(screen.getByTestId('ai-chat-send')).not.toBeDisabled(); + }); + + it('should add user message to chat history on send', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-message-user')).toBeInTheDocument(); + }); + expect(screen.getByText('Set up file watching')).toBeInTheDocument(); + }); + + it('should show busy indicator while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('chat-busy-indicator')).toBeInTheDocument(); + }); + expect(screen.getByText('Agent is working...')).toBeInTheDocument(); + }); + + it('should clear input after sending', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up file watching' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect((screen.getByTestId('ai-chat-input') as HTMLTextAreaElement).value).toBe(''); + }); + }); + + it('should include system prompt on first message', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockBuildSpawnConfig).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('configuring maestro-cue.yaml'), + }) + ); + }); + + // Should include the file path + const prompt = mockBuildSpawnConfig.mock.calls[0][0].prompt; + expect(prompt).toContain('/test/project/.maestro/cue.yaml'); + expect(prompt).toContain('Run code review'); + }); + + it('should spawn agent process', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Run code review' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockSpawn).toHaveBeenCalled(); + }); + }); + + it('should freeze YAML editor while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.readOnly).toBe(true); + }); + }); + + it('should register onData, onExit, onSessionId, and onAgentError listeners', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(mockOnData).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnExit).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnSessionId).toHaveBeenCalledWith(expect.any(Function)); + expect(mockOnAgentError).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + it('should show error message when agent config is unavailable', async () => { + mockBuildSpawnConfig.mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Set up automation' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByText(/Agent not available/)).toBeInTheDocument(); + }); + }); + + it('should show placeholder text when chat is empty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/Describe what you want to automate/)).toBeInTheDocument(); + }); + }); + + it('should disable input while agent is working', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('ai-chat-input'), { + target: { value: 'Do something' }, + }); + + fireEvent.click(screen.getByTestId('ai-chat-send')); + + await waitFor(() => { + expect(screen.getByTestId('ai-chat-input')).toBeDisabled(); + }); + }); + }); + + describe('save and cancel', () => { + it('should disable Save when content has not changed', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('should enable Save when content is modified and valid', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified content' }, + }); + + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('should disable Save when validation fails', async () => { + vi.useFakeTimers(); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + mockValidateYaml.mockResolvedValue({ valid: false, errors: ['Bad YAML'] }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'invalid' }, + }); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getByText('Save')).toBeDisabled(); + + vi.useRealTimers(); + }); + + it('should call writeYaml and refreshSession on Save', async () => { + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'new content' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockWriteYaml).toHaveBeenCalledWith('/test/project', 'new content'); + }); + expect(mockRefreshSession).toHaveBeenCalledWith('sess-1', '/test/project'); + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should call onClose when Exit is clicked and content is not dirty', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Exit')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + }); + + it('should prompt for confirmation when Exit is clicked with dirty content', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(mockConfirm).toHaveBeenCalledWith('You have unsaved changes. Discard them?'); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + + mockConfirm.mockRestore(); + }); + + it('should close when user confirms discard on Exit', async () => { + const mockConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + mockReadYaml.mockResolvedValue('original'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByTestId('yaml-editor'), { + target: { value: 'modified' }, + }); + + fireEvent.click(screen.getByText('Exit')); + + expect(defaultProps.onClose).toHaveBeenCalledOnce(); + + mockConfirm.mockRestore(); + }); + }); + + describe('pattern presets', () => { + it('should render pattern preset buttons', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-presets')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('pattern-heartbeat-task')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-file-enrichment')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-reactive')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-research-swarm')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-sequential-chain')).toBeInTheDocument(); + expect(screen.getByTestId('pattern-debate')).toBeInTheDocument(); + }); + + it('should render "Start from a pattern" heading', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Start from a pattern')).toBeInTheDocument(); + }); + }); + + it('should open a preview overlay when a pattern is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-heartbeat-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-heartbeat-task')); + + // Preview overlay should show the explanation and copy button + expect(screen.getByText(/Runs a prompt on a fixed interval/)).toBeInTheDocument(); + expect(screen.getByText('Copy to Clipboard')).toBeInTheDocument(); + }); + + it('should not modify the editor when a pattern is clicked', async () => { + mockReadYaml.mockResolvedValue('original content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('yaml-editor')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-heartbeat-task')); + + // Editor should still have original content + const editor = screen.getByTestId('yaml-editor') as HTMLTextAreaElement; + expect(editor.value).toBe('original content'); + }); + + it('should copy YAML to clipboard when Copy button is clicked', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-heartbeat-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-heartbeat-task')); + fireEvent.click(screen.getByText('Copy to Clipboard')); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(expect.stringContaining('time.heartbeat')); + }); + + expect(screen.getByText('Copied')).toBeInTheDocument(); + }); + + it('should close preview modal when close is triggered', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('pattern-heartbeat-task')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('pattern-heartbeat-task')); + expect(screen.getByText('Copy to Clipboard')).toBeInTheDocument(); + expect(screen.getByTestId('cue-pattern-preview')).toBeInTheDocument(); + + // Close via the mock Modal's close button + fireEvent.click(screen.getByTestId('cue-pattern-preview-close')); + + await waitFor(() => { + expect(screen.queryByTestId('cue-pattern-preview')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx index 6d53b6eab..83733dc53 100644 --- a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx +++ b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx @@ -29,6 +29,23 @@ const mockThemeColors: ThemeColors = { success: '#10b981', warning: '#f59e0b', error: '#ef4444', + info: '#3b82f6', + successForeground: '#1a1a2e', + warningForeground: '#1a1a2e', + errorForeground: '#1a1a2e', + successDim: 'rgba(16, 185, 129, 0.15)', + warningDim: 'rgba(245, 158, 11, 0.15)', + errorDim: 'rgba(239, 68, 68, 0.15)', + infoDim: 'rgba(59, 130, 246, 0.15)', + diffAddition: '#10b981', + diffAdditionBg: 'rgba(16, 185, 129, 0.15)', + diffDeletion: '#ef4444', + diffDeletionBg: 'rgba(239, 68, 68, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }; const mockTheme: Theme = { diff --git a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx index c4c1cf765..5752e3133 100644 --- a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx +++ b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { UnifiedHistoryTab } from '../../../../renderer/components/DirectorNotes/UnifiedHistoryTab'; import type { Theme } from '../../../../renderer/types'; +import { useSettingsStore } from '../../../../renderer/stores/settingsStore'; // Mock useSettings hook (mutable so individual tests can override) const mockDirNotesSettings = vi.hoisted(() => ({ @@ -108,7 +109,7 @@ vi.mock('../../../../renderer/components/History', () => ({ )}
), - HistoryFilterToggle: ({ activeFilters, onToggleFilter }: any) => ( + HistoryFilterToggle: ({ activeFilters, onToggleFilter, visibleTypes }: any) => (
+ {visibleTypes?.includes('CUE') && ( + + )}
), HistoryStatsBar: ({ stats }: any) => ( @@ -226,6 +236,7 @@ beforeEach(() => { (window as any).maestro = { directorNotes: { getUnifiedHistory: mockGetUnifiedHistory, + onHistoryEntryAdded: vi.fn().mockReturnValue(() => {}), }, history: { update: mockHistoryUpdate, @@ -233,6 +244,11 @@ beforeEach(() => { }; mockHistoryUpdate.mockResolvedValue(true); mockGetUnifiedHistory.mockResolvedValue(createPaginatedResponse(createMockEntries())); + + // Default: maestroCue disabled + useSettingsStore.setState({ + encoreFeatures: { directorNotes: false, usageStats: false, symphony: false, maestroCue: false }, + }); }); afterEach(() => { @@ -395,6 +411,43 @@ describe('UnifiedHistoryTab', () => { // USER entries should remain expect(screen.getByText('User performed action A')).toBeInTheDocument(); }); + + it('hides CUE filter when maestroCue is disabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-auto')).toBeInTheDocument(); + expect(screen.getByTestId('filter-user')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('filter-cue')).not.toBeInTheDocument(); + }); + + it('shows CUE filter when maestroCue is enabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('filter-cue')).toBeInTheDocument(); + }); + }); }); describe('Activity Graph', () => { diff --git a/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx b/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx index b78d4ce8c..de9b8f82b 100644 --- a/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx +++ b/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx @@ -31,6 +31,7 @@ const mockTheme: Theme = { success: '#50fa7b', warning: '#ffb86c', error: '#ff5555', + info: '#8be9fd', }, }; @@ -835,7 +836,7 @@ describe('DocumentNode', () => { const indicator = screen.getByTestId('large-file-indicator'); expect(indicator).toHaveStyle({ - color: '#3b82f6', + color: mockTheme.colors.info, }); }); diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index dc33f5313..3fb0696c8 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -235,6 +235,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ messageQueue: [], changedFiles: [], fileTreeAutoRefreshInterval: 0, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index cb4e24677..e4db17741 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -165,6 +165,7 @@ vi.mock('../../../shared/gitUtils', () => ({ })); const mockTheme = { + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/GitDiffViewer.test.tsx b/src/__tests__/renderer/components/GitDiffViewer.test.tsx index e2899952d..e384d7e9b 100644 --- a/src/__tests__/renderer/components/GitDiffViewer.test.tsx +++ b/src/__tests__/renderer/components/GitDiffViewer.test.tsx @@ -138,6 +138,10 @@ const mockTheme = { vibe: '#8855ff', statusBar: '#0d0d1a', scrollbarThumb: '#444466', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', }, }; @@ -830,7 +834,7 @@ describe('GitDiffViewer', () => { const onClose = vi.fn(); mockParseGitDiff.mockReturnValue([createMockParsedFile()]); - render( + const { container } = render( { /> ); - // The Plus icon from lucide-react should be present with green color - const greenSpans = document.querySelectorAll('.text-green-500'); - expect(greenSpans.length).toBeGreaterThan(0); + // The additions span uses inline style with diffAddition theme color + const allSpans = Array.from(container.querySelectorAll('span')); + const additionSpan = allSpans.find( + (span) => + span.style.color && + (span.style.color === mockTheme.colors.diffAddition || + span.style.color.includes('80, 250, 123')) + ); + expect(additionSpan).toBeTruthy(); }); it('shows deletions in tab for text files with deletions', () => { @@ -882,7 +892,7 @@ describe('GitDiffViewer', () => { }), ]); - render( + const { container } = render( { /> ); - // There should be red minus sign for deletions - const redSpans = document.querySelectorAll('.text-red-500'); - expect(redSpans.length).toBeGreaterThan(0); + // The deletions span uses inline style with diffDeletion theme color + const allSpans = Array.from(container.querySelectorAll('span')); + const deletionSpan = allSpans.find( + (span) => + span.style.color && + (span.style.color === mockTheme.colors.diffDeletion || + span.style.color.includes('255, 85, 85')) + ); + expect(deletionSpan).toBeTruthy(); }); it('shows additions and deletions in footer', () => { diff --git a/src/__tests__/renderer/components/GroupChatInput.test.tsx b/src/__tests__/renderer/components/GroupChatInput.test.tsx index eff4a1db9..3c5d559d9 100644 --- a/src/__tests__/renderer/components/GroupChatInput.test.tsx +++ b/src/__tests__/renderer/components/GroupChatInput.test.tsx @@ -74,6 +74,8 @@ function createMockSession(id: string, name: string, toolType: string = 'claude- aiTabs: [], activeTabId: '', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, }; } diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index e10c9715f..9a4a7476f 100644 --- a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx @@ -97,6 +97,85 @@ describe('HistoryEntryItem', () => { expect(screen.getByText('USER')).toBeInTheDocument(); }); + it('shows CUE type pill for CUE entries', () => { + render( + + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('shows CUE pill with teal color', () => { + render( + + ); + const cuePill = screen.getByText('CUE').closest('span')!; + expect(cuePill).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows success indicator for successful CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task completed successfully')).toBeInTheDocument(); + }); + + it('shows failure indicator for failed CUE entries', () => { + render( + + ); + expect(screen.getByTitle('Task failed')).toBeInTheDocument(); + }); + + it('shows CUE event type metadata when present', () => { + render( + + ); + expect(screen.getByText('Triggered by: file_change')).toBeInTheDocument(); + }); + + it('does not show CUE metadata for non-CUE entries', () => { + render( + + ); + expect(screen.queryByText(/Triggered by:/)).not.toBeInTheDocument(); + }); + it('shows success indicator for successful AUTO entries', () => { render( { expect(userButton).toHaveStyle({ color: mockTheme.colors.textDim }); }); - it('renders both buttons even when no filters are active', () => { + it('renders all three buttons even when no filters are active', () => { render( ([])} @@ -145,5 +145,82 @@ describe('HistoryFilterToggle', () => { ); expect(screen.getByText('AUTO')).toBeInTheDocument(); expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('hides CUE button when visibleTypes excludes it', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.queryByText('CUE')).not.toBeInTheDocument(); + }); + + it('shows CUE button when visibleTypes includes it', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + visibleTypes={['AUTO', 'USER', 'CUE']} + /> + ); + expect(screen.getByText('AUTO')).toBeInTheDocument(); + expect(screen.getByText('USER')).toBeInTheDocument(); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('renders CUE filter button', () => { + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + expect(screen.getByText('CUE')).toBeInTheDocument(); + }); + + it('calls onToggleFilter with CUE when CUE button is clicked', () => { + const onToggleFilter = vi.fn(); + render( + (['AUTO', 'USER', 'CUE'])} + onToggleFilter={onToggleFilter} + theme={mockTheme} + /> + ); + fireEvent.click(screen.getByText('CUE')); + expect(onToggleFilter).toHaveBeenCalledWith('CUE'); + }); + + it('styles active CUE button with teal colors', () => { + render( + (['CUE'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton).toHaveStyle({ color: '#06b6d4' }); + }); + + it('shows CUE button as inactive when not in active filters', () => { + render( + (['AUTO', 'USER'])} + onToggleFilter={vi.fn()} + theme={mockTheme} + /> + ); + const cueButton = screen.getByText('CUE').closest('button')!; + expect(cueButton.className).toContain('opacity-40'); }); }); diff --git a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx index 090436495..ebc06b205 100644 --- a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx @@ -207,6 +207,74 @@ describe('HistoryDetailModal', () => { ); expect(validatedIndicator).toBeInTheDocument(); }); + + it('should render CUE type with correct pill and teal color', () => { + render( + + ); + + const cuePill = screen.getByText('CUE'); + expect(cuePill).toBeInTheDocument(); + expect(cuePill.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + + it('should show success indicator for CUE entries with success=true', () => { + render( + + ); + + const successIndicator = screen.getByTitle('Task completed successfully'); + expect(successIndicator).toBeInTheDocument(); + }); + + it('should show failure indicator for CUE entries with success=false', () => { + render( + + ); + + const failureIndicator = screen.getByTitle('Task failed'); + expect(failureIndicator).toBeInTheDocument(); + }); + + it('should display CUE trigger metadata when available', () => { + render( + + ); + + expect(screen.getByTitle('Trigger: lint-on-save')).toBeInTheDocument(); + }); + + it('should not display CUE trigger metadata for non-CUE entries', () => { + render( + + ); + + expect(screen.queryByTitle(/Trigger:/)).not.toBeInTheDocument(); + }); }); describe('Content Display', () => { @@ -810,6 +878,21 @@ describe('HistoryDetailModal', () => { expect(screen.getByText(/auto history entry/)).toBeInTheDocument(); }); + it('should show correct type in delete confirmation for CUE entry', () => { + render( + + ); + + fireEvent.click(screen.getByTitle('Delete this history entry')); + + expect(screen.getByText(/cue history entry/)).toBeInTheDocument(); + }); + it('should cancel delete when Cancel button is clicked', () => { render( ({ FileJson: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( ), + Zap: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ), })); // Create a mock theme @@ -86,6 +90,8 @@ describe('HistoryHelpModal', () => { beforeEach(() => { vi.clearAllMocks(); mockRegisterLayer.mockReturnValue('test-layer-id'); + // Default: maestroCue disabled + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); }); afterEach(() => { @@ -294,6 +300,42 @@ describe('HistoryHelpModal', () => { screen.getByText(/Entries automatically generated by the Auto Runner/) ).toBeInTheDocument(); }); + + it('does not render CUE entry type when maestroCue is disabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: false } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeFalsy(); + }); + + it('renders CUE entry type when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + const { container } = render(); + + const cueBadges = container.querySelectorAll('.rounded-full.text-\\[10px\\]'); + const cueBadge = Array.from(cueBadges).find((el) => el.textContent?.includes('CUE')); + expect(cueBadge).toBeTruthy(); + }); + + it('describes CUE entry triggers when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByText(/Entries created by Maestro Cue automations/)).toBeInTheDocument(); + }); + + it('renders Zap icon in CUE badge when maestroCue is enabled', () => { + useSettingsStore.setState({ encoreFeatures: { directorNotes: false, maestroCue: true } }); + + render(); + + expect(screen.getByTestId('zap-icon')).toBeInTheDocument(); + }); }); describe('Status Indicators Section', () => { @@ -474,10 +516,10 @@ describe('HistoryHelpModal', () => { 'font-medium', 'transition-colors' ); - // Check for accent background - the exact RGB value + // Check for accent background and theme-based foreground color const style = gotItButton.getAttribute('style'); expect(style).toContain('background-color'); - expect(style).toContain('color: white'); + expect(style).toContain('color'); }); it('calls onClose when "Got it" button is clicked', () => { diff --git a/src/__tests__/renderer/components/HistoryPanel.test.tsx b/src/__tests__/renderer/components/HistoryPanel.test.tsx index e7d72bb0c..1447b4678 100644 --- a/src/__tests__/renderer/components/HistoryPanel.test.tsx +++ b/src/__tests__/renderer/components/HistoryPanel.test.tsx @@ -23,6 +23,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { HistoryPanel, HistoryPanelHandle } from '../../../renderer/components/HistoryPanel'; import type { Theme, Session, HistoryEntry, HistoryEntryType } from '../../../renderer/types'; import { useUIStore } from '../../../renderer/stores/uiStore'; +import { useSettingsStore } from '../../../renderer/stores/settingsStore'; // Mock child components vi.mock('../../../renderer/components/HistoryDetailModal', () => ({ @@ -142,6 +143,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ fileTree: [], fileExplorerExpanded: [], messageQueue: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -167,6 +170,16 @@ describe('HistoryPanel', () => { // Reset uiStore state used by HistoryPanel useUIStore.setState({ historySearchFilterOpen: false }); + // Default: maestroCue disabled + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + // Mock scrollIntoView for jsdom Element.prototype.scrollIntoView = vi.fn(); @@ -507,6 +520,80 @@ describe('HistoryPanel', () => { }); }); + it('should toggle CUE filter', async () => { + // Enable maestroCue so CUE filter button is visible + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); + + const autoEntry = createMockEntry({ type: 'AUTO', summary: 'Auto task' }); + const cueEntry = createMockEntry({ + id: 'cue-1', + type: 'CUE', + summary: 'Cue triggered task', + cueTriggerName: 'lint-on-save', + cueEventType: 'file_change', + }); + mockHistoryGetAll.mockResolvedValue([autoEntry, cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + + // Toggle off CUE + const cueFilter = screen.getByRole('button', { name: /CUE/i }); + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Auto task')).toBeInTheDocument(); + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + + // Toggle CUE back on + fireEvent.click(cueFilter); + + await waitFor(() => { + expect(screen.getByText('Cue triggered task')).toBeInTheDocument(); + }); + }); + + it('should hide CUE filter button when maestroCue is disabled', async () => { + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: false, + }, + }); + + const cueEntry = createMockEntry({ + type: 'CUE', + summary: 'Cue triggered task', + }); + mockHistoryGetAll.mockResolvedValue([cueEntry]); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /AUTO/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /USER/i })).toBeInTheDocument(); + }); + + // CUE button should not be rendered + expect(screen.queryByRole('button', { name: /CUE/i })).not.toBeInTheDocument(); + // CUE entries should be filtered out (not in activeFilters) + expect(screen.queryByText('Cue triggered task')).not.toBeInTheDocument(); + }); + it('should filter by search text in summary', async () => { const entry1 = createMockEntry({ summary: 'Alpha task' }); const entry2 = createMockEntry({ summary: 'Beta task' }); @@ -1660,16 +1747,26 @@ describe('HistoryPanel', () => { describe('filter button styling', () => { it('should apply active styling to selected filters', async () => { mockHistoryGetAll.mockResolvedValue([]); + useSettingsStore.setState({ + encoreFeatures: { + directorNotes: false, + usageStats: false, + symphony: false, + maestroCue: true, + }, + }); render(); await waitFor(() => { const autoFilter = screen.getByRole('button', { name: /AUTO/i }); const userFilter = screen.getByRole('button', { name: /USER/i }); + const cueFilter = screen.getByRole('button', { name: /CUE/i }); - // Both should be active by default + // All should be active by default expect(autoFilter).toHaveClass('opacity-100'); expect(userFilter).toHaveClass('opacity-100'); + expect(cueFilter).toHaveClass('opacity-100'); }); }); diff --git a/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx b/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx index 35925c11b..5a4c39c8d 100644 --- a/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx +++ b/src/__tests__/renderer/components/InlineWizard/WizardInputPanel.test.tsx @@ -98,6 +98,8 @@ const createMockSession = (overrides?: Partial): Session => showThinking: 'off', }, }, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }) as Session; diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index a77a65881..9e302be64 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -167,6 +167,8 @@ const createMockSession = (overrides: Partial & { wizardState?: any } = closedTabHistory: [], shellCwd: '/Users/test/project', busySource: null, + terminalTabs: [], + activeTerminalTabId: null, ...sessionOverrides, }; }; diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 85f2ef46a..cc16aee2c 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -2,7 +2,7 @@ * LogViewer.tsx Test Suite * * Tests for the LogViewer component which displays Maestro system logs with: - * - Log level filtering (debug, info, warn, error, toast) + * - Log level filtering (debug, info, warn, error, toast, autorun, cue) * - Search functionality * - Expand/collapse log details * - Export and clear logs @@ -34,6 +34,7 @@ const mockTheme: Theme = { error: '#ff5555', warning: '#ffb86c', success: '#50fa7b', + info: '#8be9fd', syntaxComment: '#6272a4', syntaxKeyword: '#ff79c6', }, @@ -43,7 +44,7 @@ const mockTheme: Theme = { const createMockLog = ( overrides: Partial<{ timestamp: number; - level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun'; + level: 'debug' | 'info' | 'warn' | 'error' | 'toast' | 'autorun' | 'cue'; message: string; context?: string; data?: unknown; @@ -68,6 +69,12 @@ vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ }), })); +// Mock clipboard utility +const mockSafeClipboardWrite = vi.fn().mockResolvedValue(true); +vi.mock('../../../renderer/utils/clipboard', () => ({ + safeClipboardWrite: (...args: unknown[]) => mockSafeClipboardWrite(...args), +})); + // Mock ConfirmModal vi.mock('../../../renderer/components/ConfirmModal', () => ({ ConfirmModal: ({ @@ -228,6 +235,8 @@ describe('LogViewer', () => { expect(screen.getByRole('button', { name: 'WARN' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ERROR' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'TOAST' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'AUTORUN' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'CUE' })).toBeInTheDocument(); }); }); @@ -316,6 +325,45 @@ describe('LogViewer', () => { }); }); + it('should always enable cue level regardless of logLevel', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'CUE' })).not.toBeDisabled(); + }); + }); + + it('should filter cue logs by level when CUE toggle clicked', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ level: 'cue', message: 'Cue event fired' }), + createMockLog({ level: 'info', message: 'Info message' }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to disable it + const cueButton = screen.getByRole('button', { name: 'CUE' }); + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.queryByText('Cue event fired')).not.toBeInTheDocument(); + // Info should still be visible + expect(screen.getByText('Info message')).toBeInTheDocument(); + }); + + // Click CUE to re-enable it + fireEvent.click(cueButton); + + await waitFor(() => { + expect(screen.getByText('Cue event fired')).toBeInTheDocument(); + }); + }); + it('should persist level selections via callback', async () => { const onSelectedLevelsChange = vi.fn(); @@ -1064,6 +1112,83 @@ describe('LogViewer', () => { }); }); + it('should display agent pill for cue entries with context', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "On PR Opened" triggered (pull_request.opened)', + context: 'My Cue Agent', + }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('My Cue Agent')).toBeInTheDocument(); + }); + }); + + it('should render cue agent pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: '[CUE] "Deploy Check" triggered (push)', + context: 'Cue Session', + }), + ]); + + render(); + + await waitFor(() => { + const agentPill = screen.getByText('Cue Session'); + expect(agentPill).toBeInTheDocument(); + expect(agentPill.closest('span')).toHaveStyle({ + backgroundColor: 'rgba(6, 182, 212, 0.2)', + color: '#06b6d4', + }); + }); + }); + + it('should not show context badge for cue entries (uses agent pill instead)', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue triggered', + context: 'CueContext', + }), + ]); + + render(); + + await waitFor(() => { + // The context should appear as an agent pill, not as a context badge + const contextElement = screen.getByText('CueContext'); + expect(contextElement).toBeInTheDocument(); + // Verify it's styled as an agent pill (teal), not a context badge (accent color) + expect(contextElement.closest('span')).toHaveStyle({ color: '#06b6d4' }); + }); + }); + + it('should render cue level pill with teal color', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ + level: 'cue', + message: 'Cue level test', + }), + ]); + + render(); + + await waitFor(() => { + const levelPill = screen.getByText('cue'); + expect(levelPill).toBeInTheDocument(); + expect(levelPill).toHaveStyle({ + color: '#06b6d4', + backgroundColor: 'rgba(6, 182, 212, 0.15)', + }); + }); + }); + it('should not show context badge for toast entries', async () => { getMockGetLogs().mockResolvedValue([ createMockLog({ @@ -1344,4 +1469,84 @@ describe('LogViewer', () => { }); }); }); + + describe('Copy log entry', () => { + it('should render copy button for each log entry', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ message: 'Log 1' }), + createMockLog({ message: 'Log 2' }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Log 1')).toBeInTheDocument(); + }); + + const copyButtons = screen.getAllByTitle('Copy log entry'); + expect(copyButtons).toHaveLength(2); + }); + + it('should copy log entry text to clipboard on click', async () => { + const timestamp = Date.now(); + getMockGetLogs().mockResolvedValue([ + createMockLog({ message: 'Important log', level: 'error', context: 'TestCtx', timestamp }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Important log')).toBeInTheDocument(); + }); + + const copyButton = screen.getByTitle('Copy log entry'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockSafeClipboardWrite).toHaveBeenCalledWith(expect.stringContaining('[ERROR]')); + expect(mockSafeClipboardWrite).toHaveBeenCalledWith( + expect.stringContaining('Important log') + ); + expect(mockSafeClipboardWrite).toHaveBeenCalledWith(expect.stringContaining('[TestCtx]')); + }); + }); + + it('should show check icon after successful copy', async () => { + getMockGetLogs().mockResolvedValue([createMockLog({ message: 'Copy me' })]); + + render(); + + await waitFor(() => { + expect(screen.getByText('Copy me')).toBeInTheDocument(); + }); + + const copyButton = screen.getByTitle('Copy log entry'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(screen.getByTitle('Copied!')).toBeInTheDocument(); + }); + }); + + it('should include data in copied text when log has data', async () => { + getMockGetLogs().mockResolvedValue([ + createMockLog({ message: 'With data', data: { key: 'value' } }), + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText('With data')).toBeInTheDocument(); + }); + + const copyButton = screen.getByTitle('Copy log entry'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(mockSafeClipboardWrite).toHaveBeenCalledWith( + expect.stringContaining('"key": "value"') + ); + }); + }); + }); }); diff --git a/src/__tests__/renderer/components/MainPanel.test.tsx b/src/__tests__/renderer/components/MainPanel.test.tsx index 06834dfae..a09935d00 100644 --- a/src/__tests__/renderer/components/MainPanel.test.tsx +++ b/src/__tests__/renderer/components/MainPanel.test.tsx @@ -12,12 +12,47 @@ import type { import { gitService } from '../../../renderer/services/git'; import { useUIStore } from '../../../renderer/stores/uiStore'; import { useSettingsStore } from '../../../renderer/stores/settingsStore'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; import { clearCapabilitiesCache, setCapabilitiesCache, } from '../../../renderer/hooks/agent/useAgentCapabilities'; // Mock child components to simplify testing - must be before MainPanel import + +// TerminalView: forwardRef stub that records render calls per session so we can +// assert persistence (kept mounted) vs destruction (unmounted) across sessions. +const terminalViewSessions: string[] = []; +vi.mock('../../../renderer/components/TerminalView', () => { + const React = require('react'); + const TerminalView = React.forwardRef( + (props: { session: { id: string }; isVisible: boolean }, ref: React.Ref) => { + React.useImperativeHandle(ref, () => ({ + clearActiveTerminal: vi.fn(), + focusActiveTerminal: vi.fn(), + })); + // Track which session IDs have been mounted + React.useEffect(() => { + terminalViewSessions.push(props.session.id); + return () => { + const idx = terminalViewSessions.lastIndexOf(props.session.id); + if (idx !== -1) terminalViewSessions.splice(idx, 1); + }; + }, [props.session.id]); + return React.createElement('div', { + 'data-testid': `terminal-view-${props.session.id}`, + 'data-visible': String(props.isVisible), + }); + } + ); + TerminalView.displayName = 'TerminalView'; + return { + TerminalView, + createTabStateChangeHandler: vi.fn(() => vi.fn()), + createTabPidChangeHandler: vi.fn(() => vi.fn()), + }; +}); + vi.mock('../../../renderer/components/LogViewer', () => ({ LogViewer: (props: { onClose: () => void }) => { return React.createElement( @@ -329,6 +364,13 @@ describe('MainPanel', () => { }, ], activeTabId: 'tab-1', + filePreviewTabs: [], + activeFileTabId: null, + terminalTabs: [], + activeTerminalTabId: null, + unifiedTabOrder: [{ type: 'ai' as const, id: 'tab-1' }], + unifiedClosedTabHistory: [], + closedTabHistory: [], ...overrides, }); @@ -738,12 +780,13 @@ describe('MainPanel', () => { expect(screen.getByTestId('tab-tab-2')).toBeInTheDocument(); }); - it('should not render TabBar in terminal mode', () => { + it('should render TabBar in terminal mode (unified tab system shows tabs in all modes)', () => { const session = createSession({ inputMode: 'terminal' }); render(); - expect(screen.queryByTestId('tab-bar')).not.toBeInTheDocument(); + // TabBar renders in both AI and terminal modes when aiTabs exist + expect(screen.queryByTestId('tab-bar')).toBeInTheDocument(); }); it('should call onTabSelect when tab is clicked', () => { @@ -3297,4 +3340,123 @@ describe('MainPanel', () => { expect(screen.getByTestId('wizard-conversation-view')).toBeInTheDocument(); }); }); + + // --------------------------------------------------------------------------- + // Terminal session persistence + // --------------------------------------------------------------------------- + describe('terminal session persistence', () => { + const makeTerminalTab = (id = 'ttab-1') => ({ + id, + name: null, + shellType: 'zsh' as const, + pid: 9000, + cwd: '/tmp', + createdAt: Date.now(), + state: 'idle' as const, + exitCode: undefined, + }); + + beforeEach(() => { + terminalViewSessions.length = 0; + }); + + it('renders TerminalView when active session has terminal tabs in terminal mode', () => { + const tab = makeTerminalTab(); + const session = createSession({ + id: 'session-term', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + // Seed session store so the eviction effect keeps the session alive + useSessionStore.setState({ sessions: [session] }); + + render(); + + const view = screen.getByTestId('terminal-view-session-term'); + expect(view).toBeInTheDocument(); + expect(view.getAttribute('data-visible')).toBe('true'); + }); + + it('hides TerminalView (display:none) when switching to AI mode, but keeps it mounted', async () => { + const tab = makeTerminalTab(); + const sessionTerminal = createSession({ + id: 'session-persist', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + const sessionAI = createSession({ + id: 'session-persist', + inputMode: 'ai', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + useSessionStore.setState({ sessions: [sessionTerminal] }); + + const { rerender } = render( + + ); + + // Confirm it is visible + expect(screen.getByTestId('terminal-view-session-persist').getAttribute('data-visible')).toBe('true'); + + // Simulate switching to AI mode (inputMode changes, terminalTabs unchanged) + await act(async () => { + rerender(); + }); + + // TerminalView must still be in the DOM (not unmounted) + const view = screen.getByTestId('terminal-view-session-persist'); + expect(view).toBeInTheDocument(); + // But hidden + expect(view.getAttribute('data-visible')).toBe('false'); + }); + + it('shows TerminalView again when switching back from AI mode to terminal mode', async () => { + const tab = makeTerminalTab(); + const sessionTerminal = createSession({ + id: 'session-roundtrip', + inputMode: 'terminal', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + const sessionAI = createSession({ + id: 'session-roundtrip', + inputMode: 'ai', + terminalTabs: [tab], + activeTerminalTabId: tab.id, + unifiedTabOrder: [{ type: 'terminal' as const, id: tab.id }], + }); + useSessionStore.setState({ sessions: [sessionTerminal] }); + + const { rerender } = render( + + ); + + // Switch to AI mode + await act(async () => { + rerender(); + }); + + // Switch back to terminal mode + await act(async () => { + rerender(); + }); + + const view = screen.getByTestId('terminal-view-session-roundtrip'); + expect(view.getAttribute('data-visible')).toBe('true'); + }); + + it('does not render TerminalView when session has no terminal tabs', () => { + const session = createSession({ inputMode: 'ai', terminalTabs: [] }); + useSessionStore.setState({ sessions: [session] }); + render(); + expect(screen.queryByTestId('terminal-view-session-1')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx index ec2347e24..15a60f5db 100644 --- a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx +++ b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx @@ -23,6 +23,7 @@ vi.mock('lucide-react', () => ({ const mockTheme = { id: 'test-theme', + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/MergeSessionModal.test.tsx b/src/__tests__/renderer/components/MergeSessionModal.test.tsx index 0765d5c29..137b3af71 100644 --- a/src/__tests__/renderer/components/MergeSessionModal.test.tsx +++ b/src/__tests__/renderer/components/MergeSessionModal.test.tsx @@ -96,6 +96,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ ], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/ProcessMonitor.test.tsx b/src/__tests__/renderer/components/ProcessMonitor.test.tsx index 529073e8a..208b32b65 100644 --- a/src/__tests__/renderer/components/ProcessMonitor.test.tsx +++ b/src/__tests__/renderer/components/ProcessMonitor.test.tsx @@ -46,6 +46,11 @@ vi.mock('lucide-react', () => ({ ⊗ ), + ExternalLink: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ↗ + + ), })); // Mock layer stack context @@ -699,6 +704,69 @@ describe('ProcessMonitor', () => { // Should be a span, not a button expect(screen.queryByTitle('Click to navigate to this session')).not.toBeInTheDocument(); }); + + it('should show jump-to button on process rows that navigates to agent tab', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession(); + render( + + ); + + await waitFor(() => { + expect(screen.getByTitle('Jump to tab')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Jump to tab')); + expect(onNavigateToSession).toHaveBeenCalledWith('session-1', 'tab-1'); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show jump-to button on session rows that navigates to agent', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession(); + render( + + ); + + await waitFor(() => { + expect(screen.getByTitle('Jump to agent')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Jump to agent')); + expect(onNavigateToSession).toHaveBeenCalledWith('session-1'); + expect(onClose).toHaveBeenCalled(); + }); + + it('should not show jump-to buttons when onNavigateToSession is not provided', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession(); + render(); + + await waitFor(() => { + expect(screen.getByText('abc12345...')).toBeInTheDocument(); + }); + + expect(screen.queryByTitle('Jump to agent')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Jump to tab')).not.toBeInTheDocument(); + }); }); describe('Expand/collapse', () => { diff --git a/src/__tests__/renderer/components/PromptComposerModal.test.tsx b/src/__tests__/renderer/components/PromptComposerModal.test.tsx index e0c01a2ea..9e19d92f5 100644 --- a/src/__tests__/renderer/components/PromptComposerModal.test.tsx +++ b/src/__tests__/renderer/components/PromptComposerModal.test.tsx @@ -50,6 +50,7 @@ const mockTheme: Theme = { headerBg: '#202020', scrollbarTrack: '#1a1a1a', scrollbarThumb: '#444444', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', }, }; diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 3401f0252..5ea30f100 100644 --- a/src/__tests__/renderer/components/QuickActionsModal.test.tsx +++ b/src/__tests__/renderer/components/QuickActionsModal.test.tsx @@ -133,6 +133,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ aiTabs: [{ id: 'tab-1', name: 'Tab 1', logs: [] }], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -1615,4 +1617,121 @@ describe('QuickActionsModal', () => { expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument(); }); }); + + describe('Create Worktree action', () => { + it('shows Create Worktree action for git repo sessions with callback', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.getByText('Create Worktree')).toBeInTheDocument(); + }); + + it('calls onQuickCreateWorktree with active session and closes modal', () => { + const onQuickCreateWorktree = vi.fn(); + const session = createMockSession({ isGitRepo: true }); + const props = createDefaultProps({ + sessions: [session], + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + expect(onQuickCreateWorktree).toHaveBeenCalledWith(session); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('resolves to parent session when active session is a worktree child', () => { + const onQuickCreateWorktree = vi.fn(); + const parentSession = createMockSession({ + id: 'parent-1', + name: 'Parent', + isGitRepo: true, + }); + const childSession = createMockSession({ + id: 'child-1', + name: 'Child', + isGitRepo: true, + parentSessionId: 'parent-1', + worktreeBranch: 'feature-1', + }); + const props = createDefaultProps({ + sessions: [parentSession, childSession], + activeSessionId: 'child-1', + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + // Should resolve to parent, not the child + expect(onQuickCreateWorktree).toHaveBeenCalledWith(parentSession); + }); + + it('does not show Create Worktree when session is not a git repo', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: false })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + + it('does not show Create Worktree when callback is not provided', () => { + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + }); + + describe('Configure Maestro Cue action', () => { + it('shows Configure Maestro Cue command with agent name when onConfigureCue is provided', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + expect(screen.getByText('Open YAML editor for event-driven automation')).toBeInTheDocument(); + }); + + it('handles Configure Maestro Cue action - calls onConfigureCue with active session and closes modal', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + fireEvent.click(screen.getByText('Configure Maestro Cue: Test Session')); + + expect(onConfigureCue).toHaveBeenCalledWith( + expect.objectContaining({ id: 'session-1', name: 'Test Session' }) + ); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('does not show Configure Maestro Cue when onConfigureCue is not provided', () => { + const props = createDefaultProps(); + render(); + + expect(screen.queryByText(/Configure Maestro Cue/)).not.toBeInTheDocument(); + }); + + it('Configure Maestro Cue appears when searching for "cue"', () => { + const onConfigureCue = vi.fn(); + const props = createDefaultProps({ onConfigureCue }); + render(); + + const input = screen.getByPlaceholderText('Type a command or jump to agent...'); + fireEvent.change(input, { target: { value: 'cue' } }); + + expect(screen.getByText('Configure Maestro Cue: Test Session')).toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/RenameSessionModal.test.tsx b/src/__tests__/renderer/components/RenameSessionModal.test.tsx index b0963b860..7c4de0a3b 100644 --- a/src/__tests__/renderer/components/RenameSessionModal.test.tsx +++ b/src/__tests__/renderer/components/RenameSessionModal.test.tsx @@ -54,6 +54,8 @@ const createMockSessions = (): Session[] => [ fileTree: [], fileExplorerExpanded: [], agentSessionId: 'claude-123', + terminalTabs: [], + activeTerminalTabId: null, }, { id: 'session-2', @@ -71,6 +73,8 @@ const createMockSessions = (): Session[] => [ isGitRepo: false, fileTree: [], fileExplorerExpanded: [], + terminalTabs: [], + activeTerminalTabId: null, }, ]; diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index 048117af7..f8c174f30 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -995,6 +995,32 @@ describe('RightPanel', () => { expect(setActiveRightTab).toHaveBeenCalledWith('history'); }); + it('should show "View history" link when on files tab during batch run', () => { + useUIStore.setState({ activeRightTab: 'files' }); + const setActiveRightTab = vi.fn(); + const currentSessionBatchState: BatchRunState = { + isRunning: true, + isStopping: false, + documents: ['doc1'], + currentDocumentIndex: 0, + totalTasks: 10, + completedTasks: 5, + currentDocTasksTotal: 10, + currentDocTasksCompleted: 5, + totalTasksAcrossAllDocs: 10, + completedTasksAcrossAllDocs: 5, + loopEnabled: false, + loopIteration: 0, + }; + const props = createDefaultProps({ currentSessionBatchState, setActiveRightTab }); + render(); + + const link = screen.getByText('View history'); + expect(link).toBeInTheDocument(); + fireEvent.click(link); + expect(setActiveRightTab).toHaveBeenCalledWith('history'); + }); + it('should not show "View history" link when on history tab during batch run', () => { useUIStore.setState({ activeRightTab: 'history' }); const currentSessionBatchState: BatchRunState = { diff --git a/src/__tests__/renderer/components/SendToAgentModal.test.tsx b/src/__tests__/renderer/components/SendToAgentModal.test.tsx index 1c6d7f3d6..d1782a037 100644 --- a/src/__tests__/renderer/components/SendToAgentModal.test.tsx +++ b/src/__tests__/renderer/components/SendToAgentModal.test.tsx @@ -183,6 +183,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ ], activeTabId: 'tab-1', closedTabHistory: [], + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); diff --git a/src/__tests__/renderer/components/SessionItemCue.test.tsx b/src/__tests__/renderer/components/SessionItemCue.test.tsx new file mode 100644 index 000000000..6b2f5a6a2 --- /dev/null +++ b/src/__tests__/renderer/components/SessionItemCue.test.tsx @@ -0,0 +1,153 @@ +/** + * @fileoverview Tests for SessionItem Cue status indicator + * + * Validates that the Zap icon appears next to session names when + * the session has active Cue subscriptions, with correct tooltip text. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SessionItem } from '../../../renderer/components/SessionItem'; +import type { Session, Theme } from '../../../renderer/types'; + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Activity: () => , + GitBranch: () => , + Bot: () => , + Bookmark: ({ fill }: { fill?: string }) => , + AlertCircle: () => , + Server: () => , + Zap: ({ + title, + style, + fill, + }: { + title?: string; + style?: Record; + fill?: string; + }) => , +})); + +const defaultTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#282a36', + bgSidebar: '#21222c', + bgActivity: '#343746', + textMain: '#f8f8f2', + textDim: '#6272a4', + accent: '#bd93f9', + accentForeground: '#f8f8f2', + border: '#44475a', + success: '#50fa7b', + warning: '#ffb86c', + error: '#ff5555', + info: '#8be9fd', + }, +}; + +const createMockSession = (overrides: Partial = {}): Session => ({ + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/home/user/project', + projectRoot: '/home/user/project', + aiPid: 12345, + terminalPid: 12346, + aiLogs: [], + shellLogs: [], + isGitRepo: true, + fileTree: [], + fileExplorerExpanded: [], + messageQueue: [], + contextUsage: 30, + activeTimeMs: 60000, + ...overrides, +}); + +const defaultProps = { + variant: 'flat' as const, + theme: defaultTheme, + isActive: false, + isKeyboardSelected: false, + isDragging: false, + isEditing: false, + leftSidebarOpen: true, + onSelect: vi.fn(), + onDragStart: vi.fn(), + onContextMenu: vi.fn(), + onFinishRename: vi.fn(), + onStartRename: vi.fn(), + onToggleBookmark: vi.fn(), +}; + +describe('SessionItem Cue Indicator', () => { + it('shows Zap icon when cueSubscriptionCount > 0', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon).toBeInTheDocument(); + // Title is on the wrapper span, not the icon itself + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when cueSubscriptionCount is undefined', () => { + render(); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon when cueSubscriptionCount is 0', () => { + render( + + ); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('shows singular "subscription" for count of 1', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (1 subscription)' + ); + }); + + it('uses teal color for the Zap icon', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + // jsdom converts hex to rgb + expect(zapIcon.style.color).toBe('rgb(45, 212, 191)'); + }); + + it('does not show Zap icon when session is in editing mode', () => { + render( + + ); + + // In editing mode, the name row is replaced by an input field + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index c16f09692..be415acb8 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -69,6 +69,9 @@ vi.mock('lucide-react', () => ({ Music: () => , Command: () => , MessageSquare: () => , + Zap: ({ title, style }: { title?: string; style?: Record }) => ( + + ), })); // Mock gitService @@ -153,6 +156,7 @@ const defaultShortcuts: Record = { processMonitor: { keys: ['meta', 'shift', 'p'], description: 'Process monitor' }, usageDashboard: { keys: ['alt', 'meta', 'u'], description: 'Usage dashboard' }, toggleSidebar: { keys: ['meta', 'b'], description: 'Toggle sidebar' }, + filterUnreadAgents: { keys: ['meta', 'shift', 'u'], description: 'Filter unread agents' }, }; // Create mock session @@ -174,6 +178,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ messageQueue: [], contextUsage: 30, activeTimeMs: 60000, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -3136,4 +3142,99 @@ describe('SessionList', () => { expect(screen.queryByText('Rename')).not.toBeInTheDocument(); }); }); + + // ============================================================================ + // Cue Status Indicator Tests + // ============================================================================ + + describe('Cue Status Indicator', () => { + it('shows Zap icon for sessions with active Cue subscriptions when Encore Feature enabled', async () => { + const session = createMockSession({ id: 's1', name: 'Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status to return session with subscriptions + (window.maestro as Record).cue = { + getStatus: vi + .fn() + .mockResolvedValue([ + { + sessionId: 's1', + sessionName: 'Cue Session', + subscriptionCount: 3, + enabled: true, + activeRuns: 0, + }, + ]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Wait for async status fetch to complete + await waitFor(() => { + expect(screen.getByTestId('icon-zap')).toBeInTheDocument(); + }); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (3 subscriptions)' + ); + }); + + it('does not show Zap icon when Encore Feature is disabled', async () => { + const session = createMockSession({ id: 's1', name: 'No Cue Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: false }, + }); + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + // Give async effects time to settle + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + + it('does not show Zap icon for sessions without Cue subscriptions', async () => { + const session = createMockSession({ id: 's1', name: 'No Sub Session' }); + useSessionStore.setState({ sessions: [session] }); + useUIStore.setState({ leftSidebarOpen: true }); + useSettingsStore.setState({ + shortcuts: defaultShortcuts, + encoreFeatures: { directorNotes: false, maestroCue: true }, + }); + + // Mock Cue status with no sessions having subscriptions + (window.maestro as Record).cue = { + getStatus: vi.fn().mockResolvedValue([]), + getActiveRuns: vi.fn().mockResolvedValue([]), + getActivityLog: vi.fn().mockResolvedValue([]), + onActivityUpdate: vi.fn().mockReturnValue(() => {}), + }; + + const props = createDefaultProps({ sortedSessions: [session] }); + render(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(screen.queryByTestId('icon-zap')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx index 4c7d74095..d03ab1e0d 100644 --- a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx +++ b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx @@ -22,6 +22,7 @@ const mockTheme: Theme = { const defaultShortcuts = { toggleSidebar: { keys: ['Cmd', 'B'], label: 'Toggle Sidebar' }, + filterUnreadAgents: { keys: ['Meta', 'Shift', 'u'], label: 'Filter Unread Agents' }, } as any; function createProps(overrides: Partial[0]> = {}) { @@ -30,9 +31,11 @@ function createProps(overrides: Partial[0]> = leftSidebarOpen: true, hasNoSessions: false, shortcuts: defaultShortcuts, + showUnreadAgentsOnly: false, addNewSession: vi.fn(), openWizard: vi.fn(), setLeftSidebarOpen: vi.fn(), + toggleShowUnreadAgentsOnly: vi.fn(), ...overrides, }; } @@ -45,11 +48,13 @@ describe('SidebarActions', () => { expect(screen.getByText('Wizard')).toBeTruthy(); }); - it('hides New Agent and Wizard when sidebar is collapsed', () => { + it('hides New Agent, Wizard, and unread filter when sidebar is collapsed', () => { render(); expect(screen.queryByText('New Agent')).toBeNull(); expect(screen.queryByText('Wizard')).toBeNull(); + expect(screen.queryByTitle(/Filter unread agents/)).toBeNull(); + expect(screen.queryByTitle(/Showing unread agents only/)).toBeNull(); }); it('hides Wizard button when openWizard is undefined', () => { @@ -110,4 +115,22 @@ describe('SidebarActions', () => { fireEvent.click(expandBtn); expect(setLeftSidebarOpen).toHaveBeenCalledWith(true); }); + + it('renders unread agents filter button', () => { + render(); + expect(screen.getByTitle(/Filter unread agents/)).toBeTruthy(); + }); + + it('calls toggleShowUnreadAgentsOnly when unread filter button is clicked', () => { + const toggleShowUnreadAgentsOnly = vi.fn(); + render(); + + fireEvent.click(screen.getByTitle(/Filter unread agents/)); + expect(toggleShowUnreadAgentsOnly).toHaveBeenCalledOnce(); + }); + + it('shows active state when showUnreadAgentsOnly is true', () => { + render(); + expect(screen.getByTitle(/Showing unread agents only/)).toBeTruthy(); + }); }); diff --git a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx index 968d1c5c2..54c9c0e34 100644 --- a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx @@ -5,7 +5,6 @@ * - Font family selection and loading * - Custom font management (add/remove) * - Font size toggle buttons - * - Terminal width toggle buttons * - Max log buffer toggle buttons * - Max output lines toggle buttons * - User message alignment toggle @@ -27,7 +26,6 @@ import type { Theme } from '../../../../../renderer/types'; // --- Mock setters (module-level for assertion access) --- const mockSetFontFamily = vi.fn(); const mockSetFontSize = vi.fn(); -const mockSetTerminalWidth = vi.fn(); const mockSetMaxLogBuffer = vi.fn(); const mockSetMaxOutputLines = vi.fn(); const mockSetUserMessageAlignment = vi.fn(); @@ -48,8 +46,6 @@ vi.mock('../../../../../renderer/hooks/settings/useSettings', () => ({ setFontFamily: mockSetFontFamily, fontSize: 14, setFontSize: mockSetFontSize, - terminalWidth: 100, - setTerminalWidth: mockSetTerminalWidth, maxLogBuffer: 5000, setMaxLogBuffer: mockSetMaxLogBuffer, maxOutputLines: 25, @@ -561,82 +557,6 @@ describe('DisplayTab', () => { }); }); - // ========================================================================= - // Terminal Width - // ========================================================================= - - describe('Terminal Width', () => { - it('should render Terminal Width label', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - expect(screen.getByText('Terminal Width (Columns)')).toBeInTheDocument(); - }); - - it('should call setTerminalWidth with 80', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '80' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(80); - }); - - it('should call setTerminalWidth with 100', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - // There may be multiple "100" on screen (e.g., from max nodes slider) - // so get the one in the terminal width section - const buttons = screen.getAllByRole('button', { name: '100' }); - fireEvent.click(buttons[0]); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(100); - }); - - it('should call setTerminalWidth with 120', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '120' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(120); - }); - - it('should call setTerminalWidth with 160', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - fireEvent.click(screen.getByRole('button', { name: '160' })); - expect(mockSetTerminalWidth).toHaveBeenCalledWith(160); - }); - - it('should highlight selected terminal width (100)', async () => { - render(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(50); - }); - - // Find the 100 button that has ring-2 class (the active one) - const buttons = screen.getAllByRole('button', { name: '100' }); - const activeButton = buttons.find((btn) => btn.classList.contains('ring-2')); - expect(activeButton).toBeTruthy(); - }); - }); - // ========================================================================= // Max Log Buffer // ========================================================================= @@ -704,7 +624,7 @@ describe('DisplayTab', () => { }); expect( - screen.getByText(/Maximum number of log messages to keep in memory/) + screen.getByText(/Maximum number of system log messages retained in memory/) ).toBeInTheDocument(); }); }); @@ -1615,8 +1535,6 @@ describe('DisplayTab', () => { expect(screen.getByText('Interface Font')).toBeInTheDocument(); // Font Size expect(screen.getByText('Font Size')).toBeInTheDocument(); - // Terminal Width - expect(screen.getByText('Terminal Width (Columns)')).toBeInTheDocument(); // Max Log Buffer expect(screen.getByText('Maximum Log Buffer')).toBeInTheDocument(); // Max Output Lines diff --git a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx index 681edd5a9..432625cc4 100644 --- a/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx +++ b/src/__tests__/renderer/components/Settings/tabs/EncoreTab.test.tsx @@ -68,7 +68,7 @@ vi.mock('../../../../../renderer/components/shared/AgentConfigPanel', () => ({ /> + )} + + + {/* Color picker palette */} + {colorPickerId === pipeline.id && onChangePipelineColor && ( +
e.stopPropagation()} + style={{ + display: 'grid', + gridTemplateColumns: 'repeat(6, 1fr)', + gap: 4, + padding: '8px 10px', + backgroundColor: '#16162a', + borderTop: '1px solid rgba(255,255,255,0.08)', + zIndex: 10, + }} + > + {PIPELINE_COLORS.map((c) => ( +
+ )} + + ))} + + {/* Divider */} +
+ + {/* New Pipeline button */} + +
+ )} + + ); +} + +/** Small multi-color icon representing "All Pipelines" */ +function MultiColorIcon() { + const colors = PIPELINE_COLORS.slice(0, 4); + return ( + + ); +} diff --git a/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx new file mode 100644 index 000000000..8809d7c10 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/AgentDrawer.tsx @@ -0,0 +1,273 @@ +import { memo, useState, useMemo, useRef, useEffect } from 'react'; +import { Bot, Search, X } from 'lucide-react'; +import type { Theme } from '../../../types'; + +export interface AgentSessionInfo { + id: string; + groupId?: string; + name: string; + toolType: string; +} + +export interface AgentDrawerProps { + isOpen: boolean; + onClose: () => void; + sessions: AgentSessionInfo[]; + groups?: { id: string; name: string; emoji: string }[]; + onCanvasSessionIds?: Set; + theme: Theme; +} + +function handleDragStart(e: React.DragEvent, session: AgentSessionInfo) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ + type: 'agent', + sessionId: session.id, + sessionName: session.name, + toolType: session.toolType, + }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const AgentDrawer = memo(function AgentDrawer({ + isOpen, + onClose, + sessions, + groups, + onCanvasSessionIds, + theme, +}: AgentDrawerProps) { + const [search, setSearch] = useState(''); + const searchInputRef = useRef(null); + + // Auto-focus search input when drawer opens + useEffect(() => { + if (isOpen) { + // Small delay to allow the CSS transform transition to start + const timer = setTimeout(() => searchInputRef.current?.focus(), 50); + return () => clearTimeout(timer); + } + }, [isOpen]); + + const filtered = useMemo(() => { + if (!search.trim()) return sessions; + const q = search.toLowerCase(); + return sessions.filter( + (s) => s.name.toLowerCase().includes(q) || s.toolType.toLowerCase().includes(q) + ); + }, [sessions, search]); + + // Build group lookup + const groupMap = useMemo(() => { + const map = new Map(); + for (const g of groups ?? []) { + map.set(g.id, { name: g.name, emoji: g.emoji }); + } + return map; + }, [groups]); + + // Group by user-defined groups, alphabetize groups (ungrouped last), alphabetize agents within each group + const grouped = useMemo(() => { + const result = new Map< + string, + { label: string; sortName: string; sessions: AgentSessionInfo[] } + >(); + for (const s of filtered) { + const key = s.groupId ?? '__ungrouped__'; + if (!result.has(key)) { + const g = s.groupId ? groupMap.get(s.groupId) : undefined; + const label = g ? `${g.emoji} ${g.name}` : 'Ungrouped'; + const sortName = g ? g.name : 'Ungrouped'; + result.set(key, { label, sortName, sessions: [] }); + } + result.get(key)!.sessions.push(s); + } + // Sort agents within each group alphabetically by name + for (const entry of result.values()) { + entry.sessions.sort((a, b) => a.name.localeCompare(b.name)); + } + // Sort groups alphabetically by name (ignoring emoji), with ungrouped last + const sorted = new Map( + Array.from(result.entries()).sort(([keyA, a], [keyB, b]) => { + if (keyA === '__ungrouped__') return 1; + if (keyB === '__ungrouped__') return -1; + return a.sortName.localeCompare(b.sortName); + }) + ); + return sorted; + }, [filtered, groupMap]); + + return ( +
+ {/* Header */} +
+ Agents + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Search agents..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Agent list */} +
+ {Array.from(grouped.entries()).map(([key, { label, sessions: agents }]) => ( +
+ {grouped.size > 1 && ( +
+ {label} +
+ )} + {agents.map((session) => { + const isOnCanvas = onCanvasSessionIds?.has(session.id) ?? false; + return ( +
handleDragStart(e, session)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {session.name} +
+
+ {session.toolType} +
+
+ {isOnCanvas && ( +
+ )} +
+ ); + })} +
+ ))} + {filtered.length === 0 && ( +
+ {search ? 'No agents match' : 'No agents available'} +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx new file mode 100644 index 000000000..a1cd129ec --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -0,0 +1,239 @@ +import { memo, useState, useMemo } from 'react'; +import { + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + Search, + X, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; +import type { Theme } from '../../../types'; + +export interface TriggerDrawerProps { + isOpen: boolean; + onClose: () => void; + theme: Theme; +} + +interface TriggerItem { + eventType: CueEventType; + label: string; + description: string; + icon: typeof Clock; + color: string; +} + +const TRIGGER_ITEMS: TriggerItem[] = [ + { + eventType: 'time.heartbeat', + label: 'Heartbeat', + description: 'Run every N minutes', + icon: Clock, + color: '#f59e0b', + }, + { + eventType: 'time.scheduled', + label: 'Scheduled', + description: 'Run at specific times & days', + icon: Clock, + color: '#8b5cf6', + }, + { + eventType: 'file.changed', + label: 'File Change', + description: 'Watch for file modifications', + icon: FileText, + color: '#3b82f6', + }, + { + eventType: 'agent.completed', + label: 'Agent Done', + description: 'After an agent finishes', + icon: Zap, + color: '#22c55e', + }, + { + eventType: 'github.pull_request', + label: 'Pull Request', + description: 'GitHub PR events', + icon: GitPullRequest, + color: '#a855f7', + }, + { + eventType: 'github.issue', + label: 'Issue', + description: 'GitHub issue events', + icon: GitBranch, + color: '#f97316', + }, + { + eventType: 'task.pending', + label: 'Pending Task', + description: 'Markdown task checkboxes', + icon: CheckSquare, + color: '#06b6d4', + }, +]; + +function handleDragStart(e: React.DragEvent, item: TriggerItem) { + e.dataTransfer.setData( + 'application/cue-pipeline', + JSON.stringify({ type: 'trigger', eventType: item.eventType, label: item.label }) + ); + e.dataTransfer.effectAllowed = 'move'; +} + +export const TriggerDrawer = memo(function TriggerDrawer({ + isOpen, + onClose, + theme, +}: TriggerDrawerProps) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return TRIGGER_ITEMS; + const q = search.toLowerCase(); + return TRIGGER_ITEMS.filter( + (item) => + item.label.toLowerCase().includes(q) || + item.eventType.toLowerCase().includes(q) || + item.description.toLowerCase().includes(q) + ); + }, [search]); + + return ( +
+ {/* Header */} +
+ + Triggers + + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter triggers..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: theme.colors.textMain, + fontSize: 12, + }} + /> +
+
+ + {/* Trigger list */} +
+ {filtered.map((item) => { + const Icon = item.icon; + return ( +
handleDragStart(e, item)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '8px 10px', + marginBottom: 4, + borderRadius: 6, + borderLeft: `3px solid ${item.color}`, + backgroundColor: theme.colors.bgActivity, + cursor: 'grab', + transition: 'filter 0.15s', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1.2)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.filter = 'brightness(1)'; + }} + > + +
+
+ {item.label} +
+
{item.description}
+
+
+ ); + })} + {filtered.length === 0 && ( +
+ No triggers match +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx new file mode 100644 index 000000000..c7eb5683d --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/edges/PipelineEdge.tsx @@ -0,0 +1,122 @@ +import { memo } from 'react'; +import { getBezierPath, BaseEdge, EdgeLabelRenderer, type EdgeProps } from 'reactflow'; +import { MessageCircle, FileText } from 'lucide-react'; +import type { EdgeMode } from '../../../../shared/cue-pipeline-types'; + +// Inject the pipeline dash animation once into the document head +let pipelineDashInjected = false; +function ensurePipelineDashStyle() { + if (pipelineDashInjected) return; + pipelineDashInjected = true; + const style = document.createElement('style'); + style.textContent = [ + `@keyframes pipeline-dash { to { stroke-dashoffset: -9; } }`, + `@keyframes pipeline-edge-pulse { 0%, 100% { opacity: 1; filter: drop-shadow(0 0 3px var(--edge-color)); } 50% { opacity: 0.7; filter: drop-shadow(0 0 8px var(--edge-color)); } }`, + `@keyframes pipeline-node-pulse { 0%, 100% { box-shadow: 0 0 12px var(--node-color-40); } 50% { box-shadow: 0 0 20px var(--node-color-60), 0 0 6px var(--node-color-30); } }`, + ].join('\n'); + document.head.appendChild(style); +} + +export interface PipelineEdgeData { + pipelineColor: string; + mode: EdgeMode; + isActivePipeline: boolean; + isRunning?: boolean; +} + +export const PipelineEdge = memo(function PipelineEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, + markerEnd, +}: EdgeProps) { + ensurePipelineDashStyle(); + const color = data?.pipelineColor ?? '#06b6d4'; + const mode = data?.mode ?? 'pass'; + const isActive = data?.isActivePipeline !== false; + const isRunning = data?.isRunning ?? false; + const opacity = isActive ? 1 : 0.25; + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + return ( + <> + {/* Glow underlay for selected edge */} + {selected && ( + + )} + + + {/* Mode label for non-pass modes */} + {mode !== 'pass' && ( + +
+ {mode === 'debate' && } + {mode === 'autorun' && } + {mode} +
+
+ )} + + ); +}); + +export const edgeTypes = { + pipeline: PipelineEdge, +}; diff --git a/src/renderer/components/CuePipelineEditor/index.ts b/src/renderer/components/CuePipelineEditor/index.ts new file mode 100644 index 000000000..d63cca584 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/index.ts @@ -0,0 +1,2 @@ +export { CuePipelineEditor } from './CuePipelineEditor'; +export type { CuePipelineEditorProps } from './CuePipelineEditor'; diff --git a/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx new file mode 100644 index 000000000..26bc7110b --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/AgentNode.tsx @@ -0,0 +1,212 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { MessageSquare, GripVertical, Settings } from 'lucide-react'; + +export interface AgentNodeDataProps { + compositeId: string; + sessionId: string; + sessionName: string; + toolType: string; + hasPrompt: boolean; + hasOutgoingEdge: boolean; + pipelineColor: string; + pipelineCount: number; + pipelineColors: string[]; + onConfigure?: (compositeId: string) => void; +} + +export const AgentNode = memo(function AgentNode({ + data, + selected, +}: NodeProps) { + const accentColor = data.pipelineColor; + + return ( +
+ {/* Drag handle */} +
{ + e.currentTarget.style.color = '#fff'; + e.currentTarget.style.filter = 'brightness(1.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#555'; + e.currentTarget.style.filter = 'brightness(1)'; + }} + title="Drag to move" + > + +
+ + {/* Content */} +
+
+ + {data.sessionName} + + {data.hasPrompt && ( + + )} +
+ + {data.toolType} + + + {/* Multi-pipeline color strip */} + {data.pipelineColors.length > 1 && ( +
+ {data.pipelineColors.map((c, i) => ( +
+ ))} +
+ )} +
+ + {/* Gear icon */} +
{ + e.stopPropagation(); + data.onConfigure?.(data.compositeId); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? accentColor : '#555', + flexShrink: 0, + padding: '0 6px', + marginRight: 14, + borderRadius: 4, + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = accentColor)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? accentColor : '#555')} + title="Configure" + > + +
+ + {/* Pipeline count badge */} + {data.pipelineCount > 1 && ( +
+ {data.pipelineCount} +
+ )} + + + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx new file mode 100644 index 000000000..7513f3b47 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/nodes/TriggerNode.tsx @@ -0,0 +1,190 @@ +import { memo } from 'react'; +import { Handle, Position, type NodeProps } from 'reactflow'; +import { + Clock, + FileText, + GitPullRequest, + GitBranch, + CheckSquare, + Zap, + GripVertical, + Settings, +} from 'lucide-react'; +import type { CueEventType } from '../../../../shared/cue-pipeline-types'; + +export interface TriggerNodeDataProps { + compositeId: string; + eventType: CueEventType; + label: string; + configSummary: string; + onConfigure?: (compositeId: string) => void; +} + +const EVENT_COLORS: Record = { + 'time.heartbeat': '#f59e0b', + 'time.scheduled': '#8b5cf6', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', +}; + +const EVENT_ICONS: Record = { + 'time.heartbeat': Clock, + 'time.scheduled': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +export const TriggerNode = memo(function TriggerNode({ + data, + selected, +}: NodeProps) { + const color = EVENT_COLORS[data.eventType] ?? '#06b6d4'; + const Icon = EVENT_ICONS[data.eventType] ?? Zap; + + return ( +
+ {/* Drag handle */} +
{ + e.currentTarget.style.color = '#fff'; + e.currentTarget.style.filter = 'brightness(1.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = '#555'; + e.currentTarget.style.filter = 'brightness(1)'; + }} + title="Drag to move" + > + +
+ + {/* Content */} +
+
+ + + {data.label} + +
+ {data.configSummary && ( + + {data.configSummary} + + )} +
+ + {/* Gear icon - placed before connector to avoid overlap */} +
{ + e.stopPropagation(); + data.onConfigure?.(data.compositeId); + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: selected ? color : `${color}60`, + flexShrink: 0, + padding: '4px 4px', + marginRight: 14, + borderRadius: 4, + transition: 'color 0.15s', + }} + onMouseEnter={(e) => (e.currentTarget.style.color = color)} + onMouseLeave={(e) => (e.currentTarget.style.color = selected ? color : `${color}60`)} + title="Configure" + > + +
+ + +
+ ); +}); diff --git a/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx new file mode 100644 index 000000000..f54497063 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.tsx @@ -0,0 +1,167 @@ +/** + * CueSettingsPanel — Popover panel for global Cue settings. + * + * Configures: timeout, failure behavior, concurrency, queue size. + */ + +import type { CueSettings } from '../../../../main/cue/cue-types'; + +const inputStyle: React.CSSProperties = { + backgroundColor: '#2a2a3e', + border: '1px solid #444', + borderRadius: 4, + color: '#e4e4e7', + padding: '4px 8px', + fontSize: 12, + width: '100%', + outline: 'none', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + color: '#9ca3af', + fontSize: 11, + fontWeight: 500, + marginBottom: 2, +}; + +interface CueSettingsPanelProps { + settings: CueSettings; + onChange: (settings: CueSettings) => void; + onClose: () => void; +} + +export function CueSettingsPanel({ settings, onChange, onClose }: CueSettingsPanelProps) { + return ( +
+
+ Cue Settings + +
+ +
+ {/* Timeout */} +
+
Timeout (minutes)
+ + onChange({ + ...settings, + timeout_minutes: Math.max(1, parseInt(e.target.value) || 30), + }) + } + style={inputStyle} + /> +
+ + {/* Timeout on fail */} +
+
On Source Failure
+ +
+ + {/* Max concurrent */} +
+
Max Concurrent Runs
+ + onChange({ + ...settings, + max_concurrent: Math.min(10, Math.max(1, parseInt(e.target.value) || 1)), + }) + } + style={inputStyle} + /> +
+ + {/* Queue size */} +
+
Event Queue Size
+ + onChange({ + ...settings, + queue_size: Math.min(50, Math.max(0, parseInt(e.target.value) || 10)), + }) + } + style={inputStyle} + /> +
+
+ +
+ Settings are saved to .maestro/cue.yaml when you save the pipeline. +
+
+ ); +} diff --git a/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx new file mode 100644 index 000000000..25d7c823f --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.tsx @@ -0,0 +1,256 @@ +/** + * EdgeConfigPanel — Bottom panel for configuring selected pipeline edges. + * + * Provides mode selection (pass/debate/autorun) and mode-specific settings. + * All changes update immediately. + */ + +import { ArrowRight, MessageCircle, FileText, Trash2 } from 'lucide-react'; +import type { PipelineEdge, EdgeMode, PipelineNode } from '../../../../shared/cue-pipeline-types'; + +interface EdgeConfigPanelProps { + selectedEdge: PipelineEdge | null; + sourceNode: PipelineNode | null; + targetNode: PipelineNode | null; + pipelineColor: string; + onUpdateEdge: (edgeId: string, updates: Partial) => void; + onDeleteEdge: (edgeId: string) => void; +} + +function getNodeLabel(node: PipelineNode | null): string { + if (!node) return '?'; + if (node.type === 'trigger') { + return (node.data as { label: string }).label; + } + return (node.data as { sessionName: string }).sessionName; +} + +const MODES: Array<{ + mode: EdgeMode; + label: string; + icon: typeof ArrowRight; + description: string; +}> = [ + { + mode: 'pass', + label: 'Pass', + icon: ArrowRight, + description: 'Data passes through to next agent', + }, + { + mode: 'debate', + label: 'Debate', + icon: MessageCircle, + description: 'Multiple agents debate before passing result', + }, + { + mode: 'autorun', + label: 'Auto Run', + icon: FileText, + description: 'Agent creates auto-run documents for next agent', + }, +]; + +export function EdgeConfigPanel({ + selectedEdge, + sourceNode, + targetNode, + pipelineColor, + onUpdateEdge, + onDeleteEdge, +}: EdgeConfigPanelProps) { + if (!selectedEdge) return null; + + const currentMode = selectedEdge.mode; + + return ( +
+ + + {/* Header */} +
+
+ + Connection Settings + + + {getNodeLabel(sourceNode)} + + {getNodeLabel(targetNode)} + +
+ +
+ + {/* Content */} +
+ {/* Mode selector */} +
+ {MODES.map(({ mode, label, icon: Icon }) => { + const isActive = currentMode === mode; + return ( + + ); + })} +
+ + {/* Mode description */} +
+ {MODES.find((m) => m.mode === currentMode)?.description} +
+ + {/* Debate settings */} + {currentMode === 'debate' && ( +
+ + +
+ )} + + {/* Auto Run explanation */} + {currentMode === 'autorun' && ( +
+ The source agent will produce auto-run documents that the target agent will execute + sequentially. +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx new file mode 100644 index 000000000..5a553bcf7 --- /dev/null +++ b/src/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.tsx @@ -0,0 +1,799 @@ +/** + * NodeConfigPanel — Bottom panel for configuring selected trigger or agent nodes. + * + * Shows event-specific fields for triggers, prompt textarea for agents. + * All changes update immediately (debounced for text inputs). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Trash2, + Clock, + FileText, + Zap, + GitPullRequest, + GitBranch, + CheckSquare, + ExternalLink, + ChevronsUp, + ChevronsDown, +} from 'lucide-react'; +import type { + PipelineNode, + TriggerNodeData, + AgentNodeData, + CueEventType, + CuePipeline, +} from '../../../../shared/cue-pipeline-types'; +import { useDebouncedCallback } from '../../../hooks/utils'; + +/** Info about an incoming trigger edge for per-edge prompt editing */ +export interface IncomingTriggerEdgeInfo { + edgeId: string; + triggerLabel: string; + configSummary: string; + prompt: string; +} + +interface NodeConfigPanelProps { + selectedNode: PipelineNode | null; + pipelines: CuePipeline[]; + hasOutgoingEdge?: boolean; + /** Incoming trigger edges for the selected agent node (for per-edge prompts) */ + incomingTriggerEdges?: IncomingTriggerEdgeInfo[]; + onUpdateNode: (nodeId: string, data: Partial) => void; + onUpdateEdgePrompt?: (edgeId: string, prompt: string) => void; + onDeleteNode: (nodeId: string) => void; + onSwitchToAgent?: (sessionId: string) => void; +} + +const EVENT_ICONS: Record = { + 'time.heartbeat': Clock, + 'time.scheduled': Clock, + 'file.changed': FileText, + 'agent.completed': Zap, + 'github.pull_request': GitPullRequest, + 'github.issue': GitBranch, + 'task.pending': CheckSquare, +}; + +const EVENT_LABELS: Record = { + 'time.heartbeat': 'Heartbeat Timer', + 'time.scheduled': 'Scheduled', + 'file.changed': 'File Change', + 'agent.completed': 'Agent Completed', + 'github.pull_request': 'Pull Request', + 'github.issue': 'GitHub Issue', + 'task.pending': 'Pending Task', +}; + +const inputStyle: React.CSSProperties = { + backgroundColor: '#2a2a3e', + border: '1px solid #444', + borderRadius: 4, + color: '#e4e4e7', + padding: '4px 8px', + fontSize: 12, + outline: 'none', + width: '100%', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const labelStyle: React.CSSProperties = { + color: '#9ca3af', + fontSize: 11, + fontWeight: 500, + marginBottom: 4, + display: 'block', +}; + +function TriggerConfig({ + node, + onUpdateNode, +}: { + node: PipelineNode; + onUpdateNode: NodeConfigPanelProps['onUpdateNode']; +}) { + const data = node.data as TriggerNodeData; + const [localConfig, setLocalConfig] = useState(data.config); + const [localCustomLabel, setLocalCustomLabel] = useState(data.customLabel ?? ''); + + useEffect(() => { + setLocalConfig(data.config); + }, [data.config]); + + useEffect(() => { + setLocalCustomLabel(data.customLabel ?? ''); + }, [data.customLabel]); + + const { debouncedCallback: debouncedUpdate } = useDebouncedCallback((...args: unknown[]) => { + const config = args[0] as TriggerNodeData['config']; + onUpdateNode(node.id, { config } as Partial); + }, 300); + + const { debouncedCallback: debouncedUpdateLabel } = useDebouncedCallback((...args: unknown[]) => { + const customLabel = (args[0] as string) || undefined; + onUpdateNode(node.id, { customLabel } as Partial); + }, 300); + + const handleCustomLabelChange = useCallback( + (e: React.ChangeEvent) => { + setLocalCustomLabel(e.target.value); + debouncedUpdateLabel(e.target.value); + }, + [debouncedUpdateLabel] + ); + + const updateConfig = useCallback( + (key: string, value: string | number) => { + const updated = { ...localConfig, [key]: value }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + const updateFilter = useCallback( + (key: string, value: string) => { + const updated = { + ...localConfig, + filter: { ...(localConfig.filter ?? {}), [key]: value }, + }; + setLocalConfig(updated); + debouncedUpdate(updated); + }, + [localConfig, debouncedUpdate] + ); + + const nameField = ( + + ); + + switch (data.eventType) { + case 'time.heartbeat': + return ( +
+ {nameField} + +
+ ); + case 'time.scheduled': + return ( +
+ {nameField} + + +
+ ); + case 'file.changed': + return ( +
+ {nameField} + + +
+ ); + case 'agent.completed': + return ( +
+ {nameField} +
+ Source agent is determined by incoming edges. Connect a trigger or agent node to + configure the source. +
+
+ ); + case 'github.pull_request': + case 'github.issue': + return ( +
+ {nameField} + + +
+ ); + case 'task.pending': + return ( +
+ {nameField} + +
+ ); + default: + return null; + } +} + +/** Single prompt row for a specific incoming trigger edge */ +function EdgePromptRow({ + edgeInfo, + onUpdateEdgePrompt, + expanded, +}: { + edgeInfo: IncomingTriggerEdgeInfo; + onUpdateEdgePrompt: (edgeId: string, prompt: string) => void; + expanded?: boolean; +}) { + const [localPrompt, setLocalPrompt] = useState(edgeInfo.prompt); + + useEffect(() => { + setLocalPrompt(edgeInfo.prompt); + }, [edgeInfo.prompt]); + + const { debouncedCallback: debouncedUpdate } = useDebouncedCallback((...args: unknown[]) => { + onUpdateEdgePrompt(edgeInfo.edgeId, args[0] as string); + }, 300); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setLocalPrompt(e.target.value); + debouncedUpdate(e.target.value); + }, + [debouncedUpdate] + ); + + return ( +
+