diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 291f8087f5..af67ef5849 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,8 @@ # CodeRabbit configuration # Docs: https://docs.coderabbit.ai/configuration reviews: - # Review PRs targeting both main and rc branches - base_branches: - - main - - rc + auto_review: + # Review PRs targeting both main and rc branches + base_branches: + - main + - rc diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..dee65bd1e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,159 @@ +name: Bug report +description: Report a reproducible Maestro bug with the environment details needed for triage. +title: 'Bug: ' +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting this. Please fill out the required fields so we can reproduce the issue without extra back-and-forth. + + If you opened this from the Maestro app, prefer the in-app feedback flow when possible so screenshots and environment details stay aligned. + + - type: input + id: summary + attributes: + label: Summary + description: One-line description of the bug. + placeholder: Feedback modal fails to submit after attaching a screenshot + validations: + required: true + + - type: input + id: maestro_version + attributes: + label: Maestro version + description: The Maestro version shown in the app or release build. + placeholder: 0.15.3 + validations: + required: true + + - type: dropdown + id: operating_system + attributes: + label: Operating system + options: + - macOS + - Windows + - Linux + - Other + validations: + required: true + + - type: input + id: os_version + attributes: + label: OS version + description: Include the exact OS version if you know it. + placeholder: macOS 15.4 / Windows 11 24H2 / Ubuntu 24.04 + validations: + required: true + + - type: dropdown + id: install_source + attributes: + label: Install source + options: + - Release build + - Dev build + - Packaged locally + validations: + required: true + + - type: dropdown + id: product_area + attributes: + label: Product area + description: Where did the issue happen? + options: + - Local agent session + - SSH remote session + - Group chat + - Auto Run + - Symphony + - Desktop UI / shell + - Other / not sure + validations: + required: true + + - type: dropdown + id: agent_provider + attributes: + label: Agent/provider involved + description: Strongly encouraged when the bug involves an agent workflow. + options: + - Claude Code + - Codex + - OpenCode + - Factory Droid + - Terminal + - Multiple + - Not sure / not applicable + + - type: dropdown + id: ssh_remote_enabled + attributes: + label: Was SSH remote execution enabled? + options: + - 'Yes' + - 'No' + - Not sure / not applicable + + - type: dropdown + id: reproducibility + attributes: + label: Reproducibility + options: + - Always + - Sometimes / intermittent + - Happened once + - Not sure + + - type: textarea + id: steps_to_reproduce + attributes: + label: Steps to reproduce + description: Use a numbered list when possible. + placeholder: | + 1. Open Maestro + 2. Click Feedback + 3. Attach a screenshot + 4. Click Send Feedback + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: Expected behavior + placeholder: The issue should be created successfully and the modal should close. + validations: + required: true + + - type: textarea + id: actual_behavior + attributes: + label: Actual behavior + placeholder: The modal stays open and shows an authentication error even though gh is logged in. + validations: + required: true + + - type: textarea + id: logs_and_context + attributes: + label: Logs, screenshots, or additional context + description: Paste logs, drag in screenshots, or include links to recordings if available. + placeholder: Include stack traces, screenshots, session context, or repo/worktree details that help us triage. + + - type: textarea + id: advanced_environment_details + attributes: + label: Advanced environment details + description: Optional details that are sometimes useful for harder-to-reproduce issues. + placeholder: | + Include any of these when relevant: + - Node / Electron version + - Shell used + - Git repo or worktree context + - Remote OS details if SSH is involved diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..fe3212c644 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Maestro troubleshooting guide + url: https://docs.runmaestro.ai/troubleshooting + about: Check the troubleshooting guide first for setup, auth, and environment issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..700f153532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,114 @@ +name: Feature request +description: Propose a new capability or workflow improvement for Maestro. +title: 'Feature: ' +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Use this form for feature requests and improvements. Be specific about the workflow gap and the outcome you want. + + - type: input + id: summary + attributes: + label: Summary + description: One-line description of the request. + placeholder: Add a copy-diagnostics action for issue reporting + validations: + required: true + + - type: textarea + id: problem_or_opportunity + attributes: + label: Problem or opportunity + description: What is missing or painful today? + placeholder: Filing actionable bug reports still requires manually collecting environment details and logs. + validations: + required: true + + - type: textarea + id: desired_outcome + attributes: + label: Desired outcome + description: What would success look like? + placeholder: A single action should copy a safe diagnostics block that matches the GitHub bug-report form. + validations: + required: true + + - type: textarea + id: proposed_solution + attributes: + label: Proposed solution + description: Optional. Share implementation ideas if you have them. + placeholder: Add a diagnostics exporter to the feedback modal and link it from the Help menu. + + - type: textarea + id: additional_context + attributes: + label: Additional context + description: Optional screenshots, examples, or workflows this would unblock. + placeholder: Include mockups, related issues, or workaround details. + + - type: input + id: maestro_version + attributes: + label: Maestro version + description: Optional but helpful if this request is tied to a recent release. + placeholder: 0.15.3 + + - type: dropdown + id: operating_system + attributes: + label: Operating system + options: + - Not relevant + - macOS + - Windows + - Linux + - Other + + - type: dropdown + id: install_source + attributes: + label: Install source + options: + - Not relevant + - Release build + - Dev build + - Packaged locally + + - type: dropdown + id: product_area + attributes: + label: Product area + options: + - Local agent session + - SSH remote session + - Group chat + - Auto Run + - Symphony + - Desktop UI / shell + - Other / not sure + + - type: dropdown + id: agent_provider + attributes: + label: Agent/provider involved + options: + - Not applicable + - Claude Code + - Codex + - OpenCode + - Factory Droid + - Terminal + - Multiple + + - type: dropdown + id: ssh_remote_enabled + attributes: + label: Is SSH remote execution part of this request? + options: + - Not relevant + - 'Yes' + - 'No' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfb8941df1..0bad284773 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [main, rc] + branches: [main, rc, '*-RC'] push: branches: [main, rc] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51a6df9bf9..80de527677 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 2b03bf147b..7c0a0fbd17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,15 @@ # Maestro TEMP-REFACTORING-PLAN.md TEMP-REFACTORING-PLAN-2.md +CUE-NOTES.md +CUE-REFACTORING.md Auto\ Run\ Docs/ Work\ Trees/ community-data/ .mcp.json specs/ .maestro/ +maestro-cue.yaml # Tests coverage/ diff --git a/.husky/_/.gitignore b/.husky/_/.gitignore new file mode 100644 index 0000000000..f59ec20aab --- /dev/null +++ b/.husky/_/.gitignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/.husky/_/applypatch-msg b/.husky/_/applypatch-msg new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/applypatch-msg @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/commit-msg b/.husky/_/commit-msg new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/commit-msg @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/h b/.husky/_/h new file mode 100644 index 0000000000..bf7c896408 --- /dev/null +++ b/.husky/_/h @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +[ "$HUSKY" = "2" ] && set -x +n=$(basename "$0") +s=$(dirname "$(dirname "$0")")/$n + +[ ! -f "$s" ] && exit 0 + +if [ -f "$HOME/.huskyrc" ]; then + echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh" +fi +i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh" +[ -f "$i" ] && . "$i" + +[ "${HUSKY-}" = "0" ] && exit 0 + +export PATH="node_modules/.bin:$PATH" +sh -e "$s" "$@" +c=$? + +[ $c != 0 ] && echo "husky - $n script failed (code $c)" +[ $c = 127 ] && echo "husky - command not found in PATH=$PATH" +exit $c diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh new file mode 100644 index 0000000000..f9d0637909 --- /dev/null +++ b/.husky/_/husky.sh @@ -0,0 +1,9 @@ +echo "husky - DEPRECATED + +Please remove the following two lines from $0: + +#!/usr/bin/env sh +. \"\$(dirname -- \"\$0\")/_/husky.sh\" + +They WILL FAIL in v10.0.0 +" \ No newline at end of file diff --git a/.husky/_/post-applypatch b/.husky/_/post-applypatch new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/post-applypatch @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/post-checkout @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/post-commit b/.husky/_/post-commit new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/post-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/post-merge b/.husky/_/post-merge new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/post-merge @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/post-rewrite b/.husky/_/post-rewrite new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/post-rewrite @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-applypatch b/.husky/_/pre-applypatch new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-applypatch @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-auto-gc b/.husky/_/pre-auto-gc new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-auto-gc @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-commit b/.husky/_/pre-commit new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-merge-commit b/.husky/_/pre-merge-commit new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-merge-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-push b/.husky/_/pre-push new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-push @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/pre-rebase b/.husky/_/pre-rebase new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/pre-rebase @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/_/prepare-commit-msg b/.husky/_/prepare-commit-msg new file mode 100755 index 0000000000..16aae78f5b --- /dev/null +++ b/.husky/_/prepare-commit-msg @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/h" \ No newline at end of file diff --git a/.husky/pre-merge-commit b/.husky/pre-merge-commit new file mode 100755 index 0000000000..b042464b4e --- /dev/null +++ b/.husky/pre-merge-commit @@ -0,0 +1,4 @@ +#!/bin/sh + +# Run staged-file formatting/lint checks before creating a merge commit. +npx lint-staged diff --git a/.prettierignore b/.prettierignore index a877d7bf84..5c8f23aaf1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,6 @@ node_modules/ coverage/ *.min.js .gitignore +.husky/_/ src/renderer/assets/file-explorer-rich-icons/*.svg +AGENTS.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7650f41dd2..dd323f9af5 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 1e9c9334b6..aa12a746e3 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 2b4d39977e..ec3e2494c8 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 ec1b1c9c2c..aedb5b44b3 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 12eb0d3b48..2524467eef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,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 @@ -135,9 +140,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) @@ -207,6 +213,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/Plans/rippling-inventing-lamport.md b/Plans/rippling-inventing-lamport.md new file mode 100644 index 0000000000..84569db7dc --- /dev/null +++ b/Plans/rippling-inventing-lamport.md @@ -0,0 +1,17 @@ +# Fix PR #596 Review Feedback + +## Context + +PR #596 review identified 3 issues. All verified as real. + +## Fixes + +1. **agentStore.ts**: Add `getStdinFlags` import and pass `sendPromptViaStdin`/`sendPromptViaStdinRaw` to both spawn calls in `processQueuedItem` +2. **preload/process.ts**: Add `sendPromptViaStdin` and `sendPromptViaStdinRaw` to ProcessConfig interface +3. **process.ts**: Replace bare `catch {}` with ENOENT-only ignore + captureException for other errors + +## Verification + +- Type check passes +- Existing tests pass +- Push and confirm CI green diff --git a/docs/assets/theme-hint.js b/docs/assets/theme-hint.js new file mode 100644 index 0000000000..c2bbff3c4e --- /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 287b5939f6..623fe7a6de 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/bmad-commands.md b/docs/bmad-commands.md new file mode 100644 index 0000000000..ad05dcbcf4 --- /dev/null +++ b/docs/bmad-commands.md @@ -0,0 +1,43 @@ +--- +title: BMAD Commands +description: Use BMAD Method workflows inside Maestro's AI Commands panel. +--- + +# BMAD Commands + +Maestro bundles a curated set of prompts from [bmad-code-org/BMAD-METHOD](https://github.com/bmad-code-org/BMAD-METHOD) and exposes them in **Settings -> AI Commands**. + +You can review, edit, reset, and refresh these prompts the same way you can with Spec-Kit and OpenSpec. + +## What Is Included + +The BMAD bundle covers the main workflow families published by BMAD: + +- **Core utilities** like `/bmad-help`, `/bmad-brainstorming`, `/bmad-party-mode`, `/bmad-index-docs`, and review-oriented prompts +- **Analysis workflows** like `/bmad-bmm-market-research`, `/bmad-bmm-domain-research`, `/bmad-bmm-technical-research`, and `/bmad-bmm-create-product-brief` +- **Planning workflows** like `/bmad-bmm-create-prd`, `/bmad-bmm-validate-prd`, `/bmad-bmm-edit-prd`, and `/bmad-bmm-create-ux-design` +- **Solutioning workflows** like `/bmad-bmm-create-architecture`, `/bmad-bmm-create-epics-and-stories`, and `/bmad-bmm-check-implementation-readiness` +- **Implementation workflows** like `/bmad-bmm-sprint-planning`, `/bmad-bmm-create-story`, `/bmad-bmm-dev-story`, `/bmad-bmm-code-review`, and `/bmad-bmm-qa-automate` +- **Quick flow workflows** like `/bmad-bmm-quick-spec`, `/bmad-bmm-quick-dev`, and `/bmad-bmm-quick-dev-new-preview` + +## Important Prerequisite + +Many BMAD prompts assume the target repository already contains BMAD's project artifacts such as the `_bmad/` directory, workflow configs, sprint files, and generated planning documents. + +If those files are missing, the prompt may still provide guidance, but BMAD works best when the repository has already been prepared with the BMAD installer or equivalent project structure. + +## Updating The Bundle + +From the AI Commands settings panel, use **Check for Updates** in the BMAD section to pull the latest upstream workflow text from BMAD. + +This updates Maestro's cached copy of the upstream prompts while preserving any local edits you have made in the app. + +## Editing Prompts + +Each bundled BMAD command can be: + +- expanded to inspect the current prompt +- edited and saved locally +- reset back to the bundled default + +Local edits are stored in Maestro's application data and do not modify the upstream BMAD project. diff --git a/docs/configuration.md b/docs/configuration.md index 7c31804368..d96d34e465 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,7 +17,7 @@ Settings are organized into tabs: | **Shortcuts** | Customize keyboard shortcuts (see [Keyboard Shortcuts](./keyboard-shortcuts)) | | **Themes** | Dark, light, and vibe mode themes, custom theme builder with import/export | | **Notifications** | OS notifications, custom command notifications, toast notification duration | -| **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), and [OpenSpec](./openspec-commands) prompts | +| **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), [OpenSpec](./openspec-commands), and [BMAD](./bmad-commands) prompts | | **SSH Hosts** | Configure remote hosts for [SSH agent execution](./ssh-remote-execution) | | **WakaTime** _(in General tab)_ | WakaTime integration toggle, API key, detailed file tracking | diff --git a/docs/deep-links.md b/docs/deep-links.md new file mode 100644 index 0000000000..a0e618dd71 --- /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 e786b6ea5f..eebc2b2ffa 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", @@ -68,13 +67,24 @@ "autorun-playbooks", "playbook-exchange", "speckit-commands", - "openspec-commands" + "openspec-commands", + "bmad-commands" ] }, { "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 +92,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 9b4928de7f..207225900c 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 13dc13e80e..c668b598c0 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/feedback-flow/feedback-attachment-pr491-success.png b/docs/feedback-flow/feedback-attachment-pr491-success.png new file mode 100644 index 0000000000..82e4272449 Binary files /dev/null and b/docs/feedback-flow/feedback-attachment-pr491-success.png differ diff --git a/docs/feedback-flow/feedback-button-pr491-success.png b/docs/feedback-flow/feedback-button-pr491-success.png new file mode 100644 index 0000000000..dc4822eb7e Binary files /dev/null and b/docs/feedback-flow/feedback-button-pr491-success.png differ diff --git a/docs/feedback-flow/feedback-button-pr491.png b/docs/feedback-flow/feedback-button-pr491.png new file mode 100644 index 0000000000..4b8f42cfe3 Binary files /dev/null and b/docs/feedback-flow/feedback-button-pr491.png differ diff --git a/docs/feedback-flow/feedback-issue-created-pr491-success.png b/docs/feedback-flow/feedback-issue-created-pr491-success.png new file mode 100644 index 0000000000..396b4a65e7 Binary files /dev/null and b/docs/feedback-flow/feedback-issue-created-pr491-success.png differ diff --git a/docs/feedback-flow/feedback-modal-pr491-success.png b/docs/feedback-flow/feedback-modal-pr491-success.png new file mode 100644 index 0000000000..a90fe687ce Binary files /dev/null and b/docs/feedback-flow/feedback-modal-pr491-success.png differ diff --git a/docs/feedback-flow/feedback-session-pr491.png b/docs/feedback-flow/feedback-session-pr491.png new file mode 100644 index 0000000000..7cafea1c8f Binary files /dev/null and b/docs/feedback-flow/feedback-session-pr491.png differ diff --git a/docs/feedback-flow/feedback-submit-clicked-pr491-success.png b/docs/feedback-flow/feedback-submit-clicked-pr491-success.png new file mode 100644 index 0000000000..238e214053 Binary files /dev/null and b/docs/feedback-flow/feedback-submit-clicked-pr491-success.png differ diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e4ac14a01..cb3f4f5c0f 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/maestro-cue-advanced.md b/docs/maestro-cue-advanced.md new file mode 100644 index 0000000000..05cd6945d1 --- /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 0000000000..e3d504d6b7 --- /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 eight [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 eight 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 0000000000..93feacd106 --- /dev/null +++ b/docs/maestro-cue-events.md @@ -0,0 +1,414 @@ +--- +title: Cue Event Types +description: Detailed reference for all eight Maestro Cue event types with configuration, payloads, and examples. +icon: calendar-check +--- + +Cue supports eight 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). + +## app.startup + +Fires exactly once when the Maestro application launches. Ideal for workspace setup, dependency installation, health checks, or any initialization that should happen once per session. + +**Required fields:** None beyond the universal `name`, `event`, and either `prompt` or `prompt_file`. + +**Behavior:** + +- Fires once per application launch +- Does NOT re-fire when toggling Cue on/off in Settings +- Does not re-fire on YAML hot-reload (deduplication by subscription name) +- Resets on session removal (so re-adding the session fires again on next app launch) +- Not affected by sleep/wake reconciliation +- Works with `fan_out`, `filter`, `output_prompt`, and `prompt_file` + +**Example:** + +```yaml +subscriptions: + - name: init-workspace + event: app.startup + prompt: | + Set up the development environment: + 1. Run `npm install` if node_modules is missing + 2. Check that required env vars are set + 3. Report any issues found +``` + +**Payload fields:** + +| Field | Type | Description | +| -------- | ------ | ------------------------- | +| `reason` | string | Always `"system_startup"` | + +--- + +## 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 0000000000..0ec2601b2a --- /dev/null +++ b/docs/maestro-cue-examples.md @@ -0,0 +1,484 @@ +--- +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. + +## Workspace Initialization + +Run setup tasks once when the Maestro application launches — install dependencies, verify environment, run health checks. + +**Agents needed:** `setup-agent` + +```yaml +subscriptions: + - name: init-workspace + event: app.startup + prompt: | + Initialize the workspace: + 1. Run `npm install` if node_modules is missing or outdated + 2. Verify required environment variables are set + 3. Run `npm run build` to ensure the project compiles + Report any issues found. +``` + +This fires exactly once per application launch. Toggling Cue off and back on does NOT re-fire it. Only an application restart triggers it again. Editing the YAML does not re-trigger it. + +--- + +## 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 0000000000..9e5347c4a3 --- /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 c9d0ab57bb..34b1a217b4 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/speckit-commands.md b/docs/speckit-commands.md index d91fb1e08f..88a35eb2e7 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:** @@ -114,11 +114,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 334f38efd9..0c32cd1c82 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 92d73149d9..ba9ba908a3 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 7183842bcd..15feb6fe6f 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 92c219f517..233abd8a08 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 3aa153ebe7..feb07f77bc 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 5770237968..3f79a06d67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.3", + "version": "0.16.1-RC", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.3", + "version": "0.16.1-RC", "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", @@ -36,9 +42,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", @@ -67,7 +75,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", @@ -4248,6 +4258,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4318,6 +4335,13 @@ "@types/pg": "*" } }, + "node_modules/@types/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -4889,6 +4913,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", @@ -5144,6 +5207,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/app-builder-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", @@ -13730,6 +13805,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -14843,12 +14931,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -15875,6 +15964,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -17684,20 +17785,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -19466,20 +19553,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest/node_modules/vite": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", diff --git a/package.json b/package.json index c15d4d7f13..f5cd0df6cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maestro", - "version": "0.15.3", + "version": "0.16.5-RC", "description": "Maestro hones fractured attention into focused intent.", "main": "dist/main/index.js", "author": { @@ -46,9 +46,9 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check:all": "prettier --check .", "validate:push": "npm run build:prompts && npm run format:check:all && npm run lint && npm run lint:eslint && npm run test", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test": "NODE_OPTIONS=--max-old-space-size=8192 vitest run", + "test:watch": "NODE_OPTIONS=--max-old-space-size=8192 vitest", + "test:coverage": "NODE_OPTIONS=--max-old-space-size=8192 vitest run --coverage", "test:e2e": "npm run build:main && npm run build:renderer && playwright test", "test:e2e:ui": "npm run build:main && npm run build:renderer && playwright test --ui", "test:e2e:headed": "npm run build:main && npm run build:renderer && playwright test --headed", @@ -56,12 +56,21 @@ "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:performance": "vitest run --config vitest.performance.config.mts", "refresh-speckit": "node scripts/refresh-speckit.mjs", - "refresh-openspec": "node scripts/refresh-openspec.mjs" + "refresh-openspec": "node scripts/refresh-openspec.mjs", + "refresh-bmad": "node scripts/refresh-bmad.mjs" }, "build": { "npmRebuild": false, "appId": "com.maestro.app", "productName": "Maestro", + "protocols": [ + { + "name": "Maestro", + "schemes": [ + "maestro" + ] + } + ], "publish": { "provider": "github", "owner": "RunMaestro", @@ -116,6 +125,10 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts/bmad", + "to": "prompts/bmad" } ] }, @@ -148,6 +161,10 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts/bmad", + "to": "prompts/bmad" } ] }, @@ -172,6 +189,10 @@ { "from": "src/prompts/openspec", "to": "prompts/openspec" + }, + { + "from": "src/prompts/bmad", + "to": "prompts/bmad" } ] }, @@ -226,6 +247,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", @@ -241,9 +268,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", @@ -269,7 +298,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", @@ -314,8 +345,8 @@ "node": ">=22.0.0" }, "lint-staged": { - "*": [ - "prettier --write" + "*.{js,cjs,mjs,jsx,ts,tsx,mts,cts,md,json,yml,yaml,css,scss,less,html,svg}": [ + "prettier --write --ignore-unknown" ], "*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}": [ "eslint --fix" diff --git a/scripts/refresh-bmad.mjs b/scripts/refresh-bmad.mjs new file mode 100644 index 0000000000..e6e686c433 --- /dev/null +++ b/scripts/refresh-bmad.mjs @@ -0,0 +1,501 @@ +#!/usr/bin/env node +/** + * Refresh BMAD prompts + * + * Fetches the current BMAD workflow catalog and prompt sources from GitHub, + * then regenerates the bundled Maestro prompt files and command catalog. + * + * Usage: npm run refresh-bmad + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import https from 'https'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BMAD_DIR = path.join(__dirname, '..', 'src', 'prompts', 'bmad'); +const CATALOG_PATH = path.join(BMAD_DIR, 'catalog.ts'); +const METADATA_PATH = path.join(BMAD_DIR, 'metadata.json'); + +const GITHUB_API = 'https://api.github.com'; +const RAW_GITHUB = 'https://raw.githubusercontent.com'; +const REPO_OWNER = 'bmad-code-org'; +const REPO_NAME = 'BMAD-METHOD'; +const REPO_REF = 'main'; +const RAW_BASE = `${RAW_GITHUB}/${REPO_OWNER}/${REPO_NAME}/${REPO_REF}`; + +const MODULE_HELP_FILES = ['src/core/module-help.csv', 'src/bmm/module-help.csv']; +const REFERENCE_TOKEN_REGEX = + /`((?:\.\.?\/)?[A-Za-z0-9_./-]+\.md|\{project-root\}\/_bmad\/[^`]+\.md|\{installed_path\}\/[^`]+\.md)`/g; + +function applyMaestroPromptFixes(id, prompt) { + let fixed = prompt; + + if (id === 'code-review') { + fixed = fixed.replace( + /Run `git status --porcelain` to find uncommitted changes<\/action>\nRun `git diff --name-only` to see modified files<\/action>\nRun `git diff --cached --name-only` to see staged files<\/action>\nCompile list of actually changed files from git output<\/action>/, + `Run \`git status --porcelain\` to find uncommitted changes +Run \`git diff --name-only\` to see modified files +Run \`git diff --cached --name-only\` to see staged files +If working-tree and staged diffs are both empty, inspect committed branch changes with \`git diff --name-only HEAD~1..HEAD\` or the current branch diff against its merge-base when available +Compile one combined list of actually changed files from git output` + ); + } + + if (id === 'create-story') { + fixed = fixed.replace( + / {2}<\/check>\n {2}Load the FULL file: \{\{sprint_status\}\}<\/action>[\s\S]*?GOTO step 2a<\/action>\n<\/step>/, + ` \n` + ); + } + + if (id === 'retrospective') { + fixed = fixed.replace( + `- No time estimates — NEVER mention hours, days, weeks, months, or ANY time-based predictions. AI has fundamentally changed development speed.`, + `- Do not invent time estimates or predictions. Only mention hours, days, sprints, or timelines when they are already present in project artifacts or completed work.` + ); + fixed = fixed.replace('{planning*artifacts}/\\_epic*.md', '{planning_artifacts}/*epic*.md'); + fixed = fixed.replace( + 'different than originally understood', + 'different from originally understood' + ); + } + + if (id === 'technical-research') { + fixed = fixed.replace( + `1. Set \`research_type = "technical"\` +2. Set \`research_topic = [discovered topic from discussion]\` +3. Set \`research_goals = [discovered goals from discussion]\` +4. Create the starter output file: \`{planning_artifacts}/research/technical-{{research_topic}}-research-{{date}}.md\` with exact copy of the \`./research.template.md\` contents +5. Load: \`./technical-steps/step-01-init.md\` with topic context`, + `1. Set \`research_type = "technical"\` +2. Set \`research_topic = [discovered topic from discussion]\` +3. Set \`research_goals = [discovered goals from discussion]\` +4. Set \`research_topic_slug = sanitized lowercase kebab-case version of research_topic\` (replace whitespace with \`-\`, remove slashes and filesystem-reserved characters, collapse duplicate dashes) +5. Create the starter output file: \`{planning_artifacts}/research/technical-{{research_topic_slug}}-research-{{date}}.md\` with exact copy of the \`./research.template.md\` contents +6. Load: \`./technical-steps/step-01-init.md\` with topic context` + ); + } + + if (id === 'dev-story') { + fixed = fixed.replace( + '- `story_file` = `` (explicit story path; auto-discovered if empty)', + '- `story_path` = `` (explicit story path; auto-discovered if empty)' + ); + fixed = fixed.replace( + 'Store user-provided story path as {{story_path}}\n ', + 'Store user-provided story path as {{story_path}}\n Read COMPLETE story file\n Extract story_key from filename or metadata\n ' + ); + fixed = fixed.replace( + 'Store user-provided story path as {{story_path}}\n Continue with provided story file', + 'Store user-provided story path as {{story_path}}\n Read COMPLETE story file\n Extract story_key from filename or metadata\n ' + ); + fixed = fixed.replace( + 'Dev Agent Record → Implementation Plan', + 'Dev Agent Record → Completion Notes' + ); + } + + return fixed; +} + +function httpsGet(url, options = {}) { + return new Promise((resolve, reject) => { + const timeoutMs = options.timeoutMs ?? 15000; + const headers = { + 'User-Agent': 'Maestro-BMAD-Refresher', + Accept: 'application/vnd.github+json', + ...options.headers, + }; + + const req = https.get(url, { headers }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return resolve(httpsGet(res.headers.location, options)); + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}: ${url}`)); + return; + } + + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve({ data, headers: res.headers })); + res.on('error', reject); + }); + + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request timed out after ${timeoutMs}ms: ${url}`)); + }); + req.on('error', reject); + }); +} + +async function getJson(url) { + const { data } = await httpsGet(url); + return JSON.parse(data); +} + +async function getText(url) { + const { data } = await httpsGet(url, { + headers: { + Accept: 'text/plain', + }, + }); + return data; +} + +function resolveReferenceToRepoPath(reference, sourcePath) { + if (reference.startsWith('{project-root}/_bmad/')) { + return `src/${reference.slice('{project-root}/_bmad/'.length)}`; + } + + if (reference.startsWith('{installed_path}/')) { + return path.posix.join( + path.posix.dirname(sourcePath), + reference.slice('{installed_path}/'.length) + ); + } + + if (reference.startsWith('./') || reference.startsWith('../')) { + return path.posix.normalize(path.posix.join(path.posix.dirname(sourcePath), reference)); + } + + if (reference.endsWith('.md')) { + return path.posix.normalize(path.posix.join(path.posix.dirname(sourcePath), reference)); + } + + return null; +} + +async function collectReferencedAssets( + sourcePath, + content, + seen = new Set([sourcePath]), + depth = 0 +) { + if (depth > 1) { + return []; + } + + const references = new Set(); + for (const match of content.matchAll(REFERENCE_TOKEN_REGEX)) { + if (match[1]) { + references.add(match[1]); + } + } + + const assets = []; + for (const reference of references) { + const repoPath = resolveReferenceToRepoPath(reference, sourcePath); + if (!repoPath || seen.has(repoPath)) { + continue; + } + + seen.add(repoPath); + try { + const assetContent = await getText(`${RAW_BASE}/${repoPath}`); + assets.push({ path: repoPath, content: assetContent.trim() }); + const nestedAssets = await collectReferencedAssets(repoPath, assetContent, seen, depth + 1); + assets.push(...nestedAssets); + } catch (error) { + console.warn(` Warning: Could not fetch referenced asset ${repoPath}: ${error.message}`); + } + } + + return assets; +} + +function appendReferencedAssets(prompt, assets) { + if (assets.length === 0) { + return prompt; + } + + return `${prompt.trimEnd()} + +--- + +# Bundled Reference Assets + +The following upstream BMAD files are embedded so this Maestro prompt remains self-contained. + +${assets + .map( + (asset) => `## ${asset.path} + +\`\`\`md +${asset.content} +\`\`\`` + ) + .join('\n\n')}`; +} + +function parseCsv(text) { + const rows = []; + let row = []; + let field = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + if (inQuotes) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + continue; + } + + if (char === ',') { + row.push(field); + field = ''; + continue; + } + + if (char === '\n') { + row.push(field); + rows.push(row); + row = []; + field = ''; + continue; + } + + if (char !== '\r') { + field += char; + } + } + + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + const [header = [], ...body] = rows; + return body + .filter((record) => record.some((value) => value !== '')) + .map((record) => Object.fromEntries(header.map((name, index) => [name, record[index] ?? '']))); +} + +function getPromptId(rawCommand) { + return rawCommand.replace(/^bmad-(bmm-)?/, ''); +} + +function mergeUniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} + +function mergeDescriptions(descriptions) { + const unique = mergeUniqueStrings(descriptions); + if (unique.length === 0) return ''; + if (unique.length === 1) return unique[0]; + return unique.join(' '); +} + +function normalizeWorkflowPath(workflowFile) { + if (!workflowFile) return null; + if (workflowFile.startsWith('_bmad/')) { + return `src/${workflowFile.slice('_bmad/'.length)}`; + } + return workflowFile; +} + +function resolveSkillWorkflowPath(skillName, treePaths) { + const matches = treePaths.filter((candidate) => candidate.endsWith(`/${skillName}/workflow.md`)); + if (matches.length === 0) { + throw new Error(`Unable to resolve workflow.md for skill ${skillName}`); + } + if (matches.length === 1) { + return matches[0]; + } + + const preferred = matches.find((candidate) => candidate.includes('/src/')) ?? matches[0]; + return preferred; +} + +async function loadTreePaths() { + const tree = await getJson( + `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/${REPO_REF}?recursive=1` + ); + return tree.tree.map((entry) => entry.path); +} + +async function loadModuleHelpRows() { + const allRows = []; + for (const moduleHelpPath of MODULE_HELP_FILES) { + const text = await getText( + `${RAW_GITHUB}/${REPO_OWNER}/${REPO_NAME}/${REPO_REF}/${moduleHelpPath}` + ); + allRows.push(...parseCsv(text)); + } + return allRows.filter((row) => row.command); +} + +function buildCatalog(rows, treePaths) { + const byCommand = new Map(); + + for (const row of rows) { + const rawCommand = row.command; + if (!rawCommand) continue; + + const id = getPromptId(rawCommand); + const sourcePath = row['workflow-file']?.startsWith('skill:') + ? resolveSkillWorkflowPath(row['workflow-file'].slice('skill:'.length), treePaths) + : normalizeWorkflowPath(row['workflow-file']); + + if (!sourcePath) { + continue; + } + + const existing = byCommand.get(rawCommand); + if (existing) { + existing.names.push(row.name); + existing.descriptions.push(row.description); + continue; + } + + byCommand.set(rawCommand, { + id, + command: `/${rawCommand}`, + rawCommand, + sourcePath, + names: row.name ? [row.name] : [], + descriptions: row.description ? [row.description] : [], + }); + } + + const catalog = Array.from(byCommand.values()).map((entry) => ({ + id: entry.id, + command: entry.command, + description: mergeDescriptions(entry.descriptions), + name: mergeUniqueStrings(entry.names).join(' / ') || entry.rawCommand, + sourcePath: entry.sourcePath, + isCustom: false, + })); + + catalog.sort((left, right) => { + if (left.id === 'help') return -1; + if (right.id === 'help') return 1; + return left.command.localeCompare(right.command); + }); + + return catalog; +} + +function writeCatalogFile(catalog) { + const lines = [ + '/**', + ' * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY', + ' *', + ' * Generated by scripts/refresh-bmad.mjs', + ' */', + '', + 'export interface BmadCatalogEntry {', + '\tid: string;', + '\tcommand: string;', + '\tdescription: string;', + '\tname: string;', + '\tsourcePath: string;', + '\tisCustom: boolean;', + '}', + '', + 'export const bmadCatalog: BmadCatalogEntry[] = ' + + JSON.stringify(catalog, null, '\t').replace(/^/gm, '').replace(/\n/g, '\n') + + ';', + '', + ]; + + fs.writeFileSync(CATALOG_PATH, lines.join('\n')); +} + +async function getLatestCommitSha() { + try { + const commit = await getJson( + `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/commits/${REPO_REF}` + ); + return commit.sha.substring(0, 7); + } catch (error) { + console.warn(' Warning: Could not fetch commit SHA, using "main"'); + return REPO_REF; + } +} + +async function getSourceVersion() { + try { + const packageJson = await getText( + `${RAW_GITHUB}/${REPO_OWNER}/${REPO_NAME}/${REPO_REF}/package.json` + ); + const parsed = JSON.parse(packageJson); + return parsed.version ?? REPO_REF; + } catch (error) { + console.warn(' Warning: Could not fetch BMAD package version, using "main"'); + return REPO_REF; + } +} + +async function refreshBmad() { + console.log('🔄 Refreshing BMAD prompts from GitHub...\n'); + + fs.mkdirSync(BMAD_DIR, { recursive: true }); + + console.log('📡 Fetching BMAD workflow catalog...'); + const [treePaths, rows] = await Promise.all([loadTreePaths(), loadModuleHelpRows()]); + const catalog = buildCatalog(rows, treePaths); + console.log(` Found ${catalog.length} unique prompt commands`); + + console.log('\n✏️ Writing prompt files...'); + let updatedCount = 0; + for (const entry of catalog) { + const prompt = applyMaestroPromptFixes( + entry.id, + await getText(`${RAW_BASE}/${entry.sourcePath}`) + ); + const assets = await collectReferencedAssets(entry.sourcePath, prompt); + const fullPrompt = appendReferencedAssets(prompt, assets); + const promptPath = path.join(BMAD_DIR, `bmad.${entry.id}.md`); + const existing = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, 'utf8') : ''; + + if (existing !== fullPrompt) { + fs.writeFileSync(promptPath, fullPrompt); + updatedCount++; + console.log(` ✓ Updated: bmad.${entry.id}.md`); + } else { + console.log(` - Unchanged: bmad.${entry.id}.md`); + } + } + + console.log('\n📄 Writing catalog and metadata...'); + writeCatalogFile(catalog); + + const [commitSha, sourceVersion] = await Promise.all([getLatestCommitSha(), getSourceVersion()]); + const metadata = { + lastRefreshed: new Date().toISOString(), + commitSha, + sourceVersion, + sourceUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}`, + }; + fs.writeFileSync(METADATA_PATH, JSON.stringify(metadata, null, 2)); + + console.log('\n✅ Refresh complete!'); + console.log(` Commit: ${commitSha}`); + console.log(` Version: ${sourceVersion}`); + console.log(` Commands: ${catalog.length}`); + console.log(` Updated prompt files: ${updatedCount}`); +} + +refreshBmad().catch((error) => { + console.error('\n❌ Refresh failed:', error.message); + process.exit(1); +}); diff --git a/src/__tests__/cli/commands/auto-run.test.ts b/src/__tests__/cli/commands/auto-run.test.ts new file mode 100644 index 0000000000..2230d334db --- /dev/null +++ b/src/__tests__/cli/commands/auto-run.test.ts @@ -0,0 +1,351 @@ +/** + * @file auto-run.test.ts + * @description Tests for the auto-run CLI command + * + * Tests the auto-run command functionality including: + * - Configuring auto-run with valid document paths + * - Error handling for non-existent documents + * - Error handling for non-.md files + * - --save-as flag sends saveAsPlaybook in message + * - --launch flag sends launch: true + * - --loop and --max-loops send loop config + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock maestro-client +vi.mock('../../../cli/services/maestro-client', () => ({ + withMaestroClient: vi.fn(), + resolveSessionId: vi.fn(), +})); + +// Mock storage (for resolveAgentId) +vi.mock('../../../cli/services/storage', () => ({ + resolveAgentId: vi.fn(), +})); + +import { autoRun } from '../../../cli/commands/auto-run'; +import { withMaestroClient, resolveSessionId } from '../../../cli/services/maestro-client'; +import { resolveAgentId } from '../../../cli/services/storage'; +import { existsSync } from 'fs'; + +describe('auto-run command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let consoleWarnSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + it('should configure auto-run with valid document paths', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc1.md', '/path/to/doc2.md'], { agent: 'agent-123' }); + + expect(resolveAgentId).toHaveBeenCalledWith('agent-123'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Auto-run configured with 2 documents') + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should error with no documents', async () => { + await autoRun([], {}); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('At least one document path is required') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when document does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + await autoRun(['/nonexistent/doc.md'], {}); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('File not found')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when document is not a .md file', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await autoRun(['/path/to/file.txt'], {}); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('File must be a .md file') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should send saveAsPlaybook when --save-as is provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + playbookId: 'pb-456', + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { saveAs: 'My Playbook', agent: 'agent-123' }); + + expect(sentMessage).toBeDefined(); + expect(sentMessage!.saveAsPlaybook).toBe('My Playbook'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Playbook 'My Playbook' saved") + ); + }); + + it('should send launch: true when --launch is provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { launch: true, agent: 'agent-123' }); + + expect(sentMessage).toBeDefined(); + expect(sentMessage!.launch).toBe(true); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Auto-run launched with 1 document') + ); + }); + + it('should send loop config when --loop is provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { loop: true, agent: 'agent-123' }); + + expect(sentMessage).toBeDefined(); + expect(sentMessage!.loopEnabled).toBe(true); + }); + + it('should send loop config with --max-loops', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { maxLoops: '5', agent: 'agent-123' }); + + expect(sentMessage).toBeDefined(); + expect(sentMessage!.loopEnabled).toBe(true); + expect(sentMessage!.maxLoops).toBe(5); + }); + + it('should error with invalid --max-loops value', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await autoRun(['/path/to/doc.md'], { maxLoops: 'abc' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('--max-loops must be a positive integer') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should set resetOnCompletion on documents when flag is provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + + let sentMessage: Record | undefined; + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + sentMessage = msg; + return Promise.resolve({ + type: 'configure_auto_run_result', + success: true, + }); + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { + resetOnCompletion: true, + agent: 'agent-123', + }); + + expect(sentMessage).toBeDefined(); + const docs = sentMessage!.documents as Array<{ filename: string; resetOnCompletion: boolean }>; + expect(docs[0].resetOnCompletion).toBe(true); + }); + + it('should error gracefully when Maestro app is not running', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await autoRun(['/path/to/doc.md'], { agent: 'agent-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Maestro desktop app is not running') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should use resolveAgentId when --agent is provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('full-agent-uuid-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { agent: 'full-ag' }); + + expect(resolveAgentId).toHaveBeenCalledWith('full-ag'); + expect(resolveSessionId).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Auto-run configured with 1 document') + ); + }); + + it('should prefer --agent over --session when both provided', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-uuid-456'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { agent: 'agent-uuid', session: 'session-789' }); + + expect(resolveAgentId).toHaveBeenCalledWith('agent-uuid'); + expect(resolveSessionId).not.toHaveBeenCalled(); + // --session is still present, so deprecation warning should fire + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('--session is deprecated')); + }); + + it('should show deprecation warning when --session is used', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('session-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: true, + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { session: 'session-123' }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('--session is deprecated')); + expect(resolveAgentId).toHaveBeenCalledWith('session-123'); + }); + + it('should handle resolveAgentId throwing with clean error message', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockImplementationOnce(() => { + throw new Error('Agent not found'); + }); + + await autoRun(['/path/to/doc.md'], { agent: 'bad-id' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Agent not found')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when server returns failure', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveAgentId).mockReturnValue('agent-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'configure_auto_run_result', + success: false, + error: 'Agent not found', + }), + }; + return action(mockClient as never); + }); + + await autoRun(['/path/to/doc.md'], { agent: 'agent-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Agent not found')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/__tests__/cli/commands/list-sessions.test.ts b/src/__tests__/cli/commands/list-sessions.test.ts index 9bec0d4f40..e5449fee37 100644 --- a/src/__tests__/cli/commands/list-sessions.test.ts +++ b/src/__tests__/cli/commands/list-sessions.test.ts @@ -17,6 +17,7 @@ import type { SessionInfo } from '../../../shared/types'; vi.mock('../../../cli/services/storage', () => ({ resolveAgentId: vi.fn(), getSessionById: vi.fn(), + readSessions: vi.fn().mockReturnValue([]), })); // Mock agent-sessions @@ -25,7 +26,7 @@ vi.mock('../../../cli/services/agent-sessions', () => ({ })); import { listSessions } from '../../../cli/commands/list-sessions'; -import { resolveAgentId, getSessionById } from '../../../cli/services/storage'; +import { resolveAgentId, getSessionById, readSessions } from '../../../cli/services/storage'; import { listClaudeSessions } from '../../../cli/services/agent-sessions'; describe('list sessions command', () => { @@ -205,30 +206,49 @@ describe('list sessions command', () => { expect(processExitSpy).toHaveBeenCalledWith(1); }); - it('should exit with error for unsupported agent type', () => { + it('should list empty sessions for non-Claude agent type', () => { vi.mocked(resolveAgentId).mockReturnValue('agent-term-1'); vi.mocked(getSessionById).mockReturnValue( mockAgent({ id: 'agent-term-1', toolType: 'terminal' }) ); + vi.mocked(readSessions).mockReturnValue([ + { + id: 'agent-term-1', + name: 'Terminal', + toolType: 'terminal' as any, + cwd: '/test', + projectRoot: '/test', + }, + ]); listSessions('agent-term', {}); - expect(consoleErrorSpy).toHaveBeenCalled(); - expect(processExitSpy).toHaveBeenCalledWith(1); + // Non-Claude agents use tab-based listing; no tabs = empty output + expect(consoleSpy).toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should exit with error for unsupported agent type in JSON mode', () => { - vi.mocked(resolveAgentId).mockReturnValue('agent-term-1'); + it('should list empty sessions for non-Claude agent types in JSON mode', () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-codex-1'); vi.mocked(getSessionById).mockReturnValue( - mockAgent({ id: 'agent-term-1', toolType: 'terminal' }) + mockAgent({ id: 'agent-codex-1', toolType: 'codex' }) ); + vi.mocked(readSessions).mockReturnValue([ + { + id: 'agent-codex-1', + name: 'Test Codex', + toolType: 'codex' as any, + cwd: '/test', + projectRoot: '/test', + }, + ]); - listSessions('agent-term', { json: true }); + listSessions('agent-codex', { json: true }); const output = JSON.parse(consoleSpy.mock.calls[0][0]); - expect(output.success).toBe(false); - expect(output.code).toBe('AGENT_UNSUPPORTED'); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(output.success).toBe(true); + expect(output.sessions).toEqual([]); + expect(output.totalCount).toBe(0); }); it('should exit with error for invalid limit', () => { diff --git a/src/__tests__/cli/commands/open-file.test.ts b/src/__tests__/cli/commands/open-file.test.ts new file mode 100644 index 0000000000..efd18fddb2 --- /dev/null +++ b/src/__tests__/cli/commands/open-file.test.ts @@ -0,0 +1,130 @@ +/** + * @file open-file.test.ts + * @description Tests for the open-file CLI command + * + * Tests the open-file command functionality including: + * - Opening a valid file with explicit session + * - Opening a valid file with default session resolution + * - Error handling for non-existent files + * - Error handling when Maestro app is not running + */ + +import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest'; +import * as path from 'path'; + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock maestro-client +vi.mock('../../../cli/services/maestro-client', () => ({ + withMaestroClient: vi.fn(), + resolveSessionId: vi.fn(), +})); + +// Mock storage (used for resolving relative paths against agent's cwd) +vi.mock('../../../cli/services/storage', () => ({ + getSessionById: vi.fn().mockReturnValue({ + id: 'session-123', + name: 'Test Agent', + toolType: 'claude-code', + cwd: '/home/user/project', + projectRoot: '/home/user/project', + }), +})); + +import { openFile } from '../../../cli/commands/open-file'; +import { withMaestroClient, resolveSessionId } from '../../../cli/services/maestro-client'; +import { existsSync } from 'fs'; + +describe('open-file command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + it('should open a valid file with explicit session', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveSessionId).mockReturnValue('session-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ type: 'open_file_tab_result', success: true }), + }; + return action(mockClient as never); + }); + + await openFile('/path/to/file.ts', { session: 'session-123' }); + + expect(resolveSessionId).toHaveBeenCalledWith({ session: 'session-123' }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Opened file.ts in Maestro')); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should resolve relative file paths to absolute', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveSessionId).mockReturnValue('session-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockImplementation((msg) => { + // Verify absolute path was sent + expect(path.isAbsolute(msg.filePath)).toBe(true); + return Promise.resolve({ type: 'open_file_tab_result', success: true }); + }), + }; + return action(mockClient as never); + }); + + await openFile('relative/file.ts', { session: 'session-123' }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Opened file.ts in Maestro')); + }); + + it('should error when file does not exist', async () => { + vi.mocked(existsSync).mockReturnValue(false); + + await openFile('/nonexistent/file.ts', {}); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('File not found')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should error gracefully when Maestro app is not running', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveSessionId).mockReturnValue('session-123'); + vi.mocked(withMaestroClient).mockRejectedValue(new Error('Maestro desktop app is not running')); + + await openFile('/path/to/file.ts', { session: 'session-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Maestro desktop app is not running') + ); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when server returns failure', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(resolveSessionId).mockReturnValue('session-123'); + vi.mocked(withMaestroClient).mockImplementation(async (action) => { + const mockClient = { + sendCommand: vi.fn().mockResolvedValue({ + type: 'open_file_tab_result', + success: false, + error: 'Session not found', + }), + }; + return action(mockClient as never); + }); + + await openFile('/path/to/file.ts', { session: 'session-123' }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Session not found')); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 3bd3a852ad..55f655e2ea 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 aecdc1ff59..a7f749831e 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -1064,6 +1064,135 @@ Some text with [x] in it that's not a checkbox expect(result.response).toBe('Complete'); }); + it('should flush buffer on close when last line lacks trailing newline', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Result line without trailing newline (stays in buffer until close) + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Flushed"}')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('Flushed'); + }); + + it('should flush buffer with session_id and usage on close', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Earlier lines with newlines are processed normally + mockStdout.emit('data', Buffer.from('{"session_id":"sess-1"}\n')); + // Final result without trailing newline + mockStdout.emit( + 'data', + Buffer.from('{"type":"result","result":"Done","total_cost_usd":0.05}') + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('Done'); + expect(result.agentSessionId).toBe('sess-1'); + expect(result.usageStats?.totalCostUsd).toBe(0.05); + }); + + it('should use assistant message text when result field is empty', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assistant message with response text (as Claude Code actually emits) + mockStdout.emit( + 'data', + Buffer.from( + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"CLI capture test OK"}]}}\n' + ) + ); + // Result message with empty result field (matches real Claude Code behavior) + mockStdout.emit('data', Buffer.from('{"type":"result","result":"","total_cost_usd":0.02}\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('CLI capture test OK'); + }); + + it('should prefer result field over assistant text when both present', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + mockStdout.emit( + 'data', + Buffer.from( + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"partial"}]}}\n' + ) + ); + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Final answer"}\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('Final answer'); + }); + + it('should handle assistant message with string content', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + mockStdout.emit( + 'data', + Buffer.from('{"type":"assistant","message":{"role":"assistant","content":"Hello world"}}\n') + ); + mockStdout.emit('data', Buffer.from('{"type":"result","result":""}\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('Hello world'); + }); + + it('should separate multiple assistant messages with newlines', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + mockStdout.emit( + 'data', + Buffer.from( + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First part."}]}}\n' + ) + ); + mockStdout.emit( + 'data', + Buffer.from( + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Second part."}]}}\n' + ) + ); + mockStdout.emit('data', Buffer.from('{"type":"result","result":""}\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.success).toBe(true); + expect(result.response).toBe('First part.\nSecond part.'); + }); + it('should ignore non-JSON lines', async () => { const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); @@ -1187,6 +1316,45 @@ 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'); + // Should NOT have permission bypass in read-only mode + expect(args).not.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'); + // Should have permission bypass in normal mode + expect(args).toContain('--dangerously-skip-permissions'); + + 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__/cli/services/maestro-client.test.ts b/src/__tests__/cli/services/maestro-client.test.ts new file mode 100644 index 0000000000..4c27196188 --- /dev/null +++ b/src/__tests__/cli/services/maestro-client.test.ts @@ -0,0 +1,430 @@ +/** + * @file maestro-client.test.ts + * @description Tests for the CLI WebSocket client service + * + * Tests the MaestroClient class including: + * - Connection lifecycle (connect, disconnect) + * - Command sending with response matching + * - Timeout handling + * - withMaestroClient helper lifecycle + * - resolveSessionId helper + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// Track WebSocket instances created +let mockWsInstance: EventEmitter & { + close: ReturnType; + send: ReturnType; + readyState: number; +}; + +vi.mock('ws', async () => { + const { EventEmitter: EE } = await import('events'); + const WS_OPEN = 1; + class MockWebSocket extends EE { + close = vi.fn(); + send = vi.fn(); + readyState = WS_OPEN; + static OPEN = WS_OPEN; + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + mockWsInstance = this as unknown as typeof mockWsInstance; + } + } + return { default: MockWebSocket }; +}); + +vi.mock('../../../shared/cli-server-discovery', () => ({ + readCliServerInfo: vi.fn(), + isCliServerRunning: vi.fn(), +})); + +vi.mock('../../../cli/services/storage', () => ({ + readSessions: vi.fn(), +})); + +import { + MaestroClient, + withMaestroClient, + resolveSessionId, +} from '../../../cli/services/maestro-client'; +import { readCliServerInfo, isCliServerRunning } from '../../../shared/cli-server-discovery'; +import { readSessions } from '../../../cli/services/storage'; +import WebSocket from 'ws'; + +describe('MaestroClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('connect()', () => { + it('should throw when no discovery file exists', async () => { + vi.mocked(readCliServerInfo).mockReturnValue(null); + + const client = new MaestroClient(); + await expect(client.connect()).rejects.toThrow('Maestro desktop app is not running'); + }); + + it('should throw when PID is stale', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(false); + + const client = new MaestroClient(); + await expect(client.connect()).rejects.toThrow('Maestro discovery file is stale'); + }); + + it('should connect successfully when server is running', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + + // Simulate WebSocket open event + mockWsInstance.emit('open'); + + await connectPromise; + + // Verify connection was established (mockWsInstance is set) + expect(mockWsInstance).toBeDefined(); + }); + + it('should reject on WebSocket error', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + + // Simulate WebSocket error + mockWsInstance.emit('error', new Error('Connection refused')); + + await expect(connectPromise).rejects.toThrow( + 'Failed to connect to Maestro: Connection refused' + ); + }); + + it('should timeout after 5 seconds', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + + // Advance past timeout + vi.advanceTimersByTime(5001); + + await expect(connectPromise).rejects.toThrow('Connection to Maestro timed out'); + }); + }); + + describe('sendCommand()', () => { + async function createConnectedClient(): Promise { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + mockWsInstance.emit('open'); + await connectPromise; + return client; + } + + it('should throw when not connected', async () => { + const client = new MaestroClient(); + await expect(client.sendCommand({ type: 'ping' }, 'pong')).rejects.toThrow( + 'Not connected to Maestro' + ); + }); + + it('should resolve via requestId when response includes matching requestId', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand<{ type: string; data: string }>( + { type: 'ping' }, + 'pong' + ); + + // Extract the requestId that was sent + const sentPayload = JSON.parse(mockWsInstance.send.mock.calls[0][0] as string) as Record< + string, + unknown + >; + expect(sentPayload.requestId).toBeDefined(); + + // Respond with the same requestId (triggers requestId-based resolution) + mockWsInstance.emit( + 'message', + JSON.stringify({ type: 'pong', data: 'ok', requestId: sentPayload.requestId }) + ); + + const result = await commandPromise; + expect(result.type).toBe('pong'); + expect(result.data).toBe('ok'); + }); + + it('should resolve on matching response type', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand<{ type: string; data: string }>( + { type: 'ping' }, + 'pong' + ); + + // Simulate matching response + mockWsInstance.emit('message', JSON.stringify({ type: 'pong', data: 'ok' })); + + const result = await commandPromise; + expect(result.type).toBe('pong'); + expect(result.data).toBe('ok'); + expect(mockWsInstance.send).toHaveBeenCalledWith(expect.stringContaining('"type":"ping"')); + }); + + it('should reject on timeout', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand({ type: 'ping' }, 'pong', 2000); + + // Advance past timeout + vi.advanceTimersByTime(2001); + + await expect(commandPromise).rejects.toThrow('Command timed out waiting for pong'); + }); + + it('should use default 10s timeout', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand({ type: 'ping' }, 'pong'); + + // At 9.9s it should still be pending + vi.advanceTimersByTime(9900); + + // At 10.1s it should timeout + vi.advanceTimersByTime(200); + + await expect(commandPromise).rejects.toThrow('Command timed out'); + }); + + it('should ignore non-matching response types', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); + + // Send non-matching response first + mockWsInstance.emit('message', JSON.stringify({ type: 'other_event', data: 'ignored' })); + + // Then matching one + mockWsInstance.emit('message', JSON.stringify({ type: 'pong' })); + + const result = await commandPromise; + expect(result.type).toBe('pong'); + }); + + it('should ignore non-JSON messages', async () => { + const client = await createConnectedClient(); + + const commandPromise = client.sendCommand<{ type: string }>({ type: 'ping' }, 'pong'); + + // Send invalid JSON + mockWsInstance.emit('message', 'not json'); + + // Then send valid matching message + mockWsInstance.emit('message', JSON.stringify({ type: 'pong' })); + + const result = await commandPromise; + expect(result.type).toBe('pong'); + }); + }); + + describe('disconnect()', () => { + it('should close the WebSocket connection', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + mockWsInstance.emit('open'); + await connectPromise; + + client.disconnect(); + + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it('should reject pending requests on disconnect', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const client = new MaestroClient(); + const connectPromise = client.connect(); + mockWsInstance.emit('open'); + await connectPromise; + + const commandPromise = client.sendCommand({ type: 'ping' }, 'pong'); + + client.disconnect(); + + await expect(commandPromise).rejects.toThrow('Client disconnected'); + }); + + it('should be safe to call when not connected', () => { + const client = new MaestroClient(); + expect(() => client.disconnect()).not.toThrow(); + }); + }); +}); + +describe('withMaestroClient()', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should connect, run action, and disconnect', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const actionResult = 'action-result'; + const actionFn = vi.fn().mockResolvedValue(actionResult); + + const resultPromise = withMaestroClient(actionFn); + + // Wait for connect + mockWsInstance.emit('open'); + + const result = await resultPromise; + + expect(result).toBe(actionResult); + expect(actionFn).toHaveBeenCalledTimes(1); + // Should disconnect after action + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it('should disconnect even when action throws', async () => { + vi.mocked(readCliServerInfo).mockReturnValue({ + port: 3000, + token: 'test-token', + pid: 12345, + startedAt: Date.now(), + }); + vi.mocked(isCliServerRunning).mockReturnValue(true); + + const actionFn = vi.fn().mockRejectedValue(new Error('Action failed')); + + const resultPromise = withMaestroClient(actionFn); + mockWsInstance.emit('open'); + + await expect(resultPromise).rejects.toThrow('Action failed'); + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it('should propagate connection errors', async () => { + vi.mocked(readCliServerInfo).mockReturnValue(null); + + await expect(withMaestroClient(async () => 'should not reach')).rejects.toThrow( + 'Maestro desktop app is not running' + ); + }); +}); + +describe('resolveSessionId()', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return provided session option directly', () => { + const result = resolveSessionId({ session: 'my-session-id' }); + expect(result).toBe('my-session-id'); + expect(readSessions).not.toHaveBeenCalled(); + }); + + it('should return first session ID when no option provided', () => { + vi.mocked(readSessions).mockReturnValue([ + { + id: 'first-session', + name: 'First', + toolType: 'claude-code', + cwd: '/path', + projectRoot: '/path', + }, + { + id: 'second-session', + name: 'Second', + toolType: 'claude-code', + cwd: '/path', + projectRoot: '/path', + }, + ]); + + const result = resolveSessionId({}); + expect(result).toBe('first-session'); + }); + + it('should exit when no sessions exist and no option provided', () => { + vi.mocked(readSessions).mockReturnValue([]); + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => resolveSessionId({})).toThrow('process.exit called'); + + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No agents found')); + + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/integration/symphony.integration.test.ts b/src/__tests__/integration/symphony.integration.test.ts index 0963fd47b7..1ba1564545 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/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index 33f0f46b51..b8b888c446 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -282,6 +282,7 @@ describe('agent-capabilities', () => { 'supportsGroupChatModeration', 'usesJsonLineOutput', 'usesCombinedContextWindow', + 'supportsAppendSystemPrompt', ]; const defaultKeys = Object.keys(DEFAULT_CAPABILITIES); diff --git a/src/__tests__/main/autorun-folder-validation.test.ts b/src/__tests__/main/autorun-folder-validation.test.ts index aefad5018b..ed7b198353 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 5a4f0d2069..213da88d15 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/bmad-manager.test.ts b/src/__tests__/main/bmad-manager.test.ts new file mode 100644 index 0000000000..cf526fca10 --- /dev/null +++ b/src/__tests__/main/bmad-manager.test.ts @@ -0,0 +1,204 @@ +/** + * Tests for the BMAD Manager + * + * Tests the core functionality for managing bundled BMAD prompts including: + * - Loading bundled prompts from disk + * - User customization persistence + * - Resetting to defaults + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/mock/userData'), + isPackaged: false, + }, +})); + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +vi.mock('../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { + getBmadMetadata, + getBmadPrompts, + saveBmadPrompt, + resetBmadPrompt, + getBmadCommand, + getBmadCommandBySlash, + type BmadMetadata, +} from '../../main/bmad-manager'; + +describe('bmad-manager', () => { + const mockBundledPrompt = '# Test Prompt\n\nThis is a test prompt.'; + const mockMetadata: BmadMetadata = { + lastRefreshed: '2026-03-14T00:00:00Z', + commitSha: 'ac769b2', + sourceVersion: '6.1.0', + sourceUrl: 'https://github.com/bmad-code-org/BMAD-METHOD', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getBmadMetadata', () => { + it('should return bundled metadata when no customizations exist', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('bmad-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + const metadata = await getBmadMetadata(); + expect(metadata).toEqual(mockMetadata); + }); + }); + + describe('getBmadPrompts', () => { + it('should return bundled commands', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('bmad-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('bmad-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + if (pathStr.endsWith('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + const commands = await getBmadPrompts(); + + expect(commands.length).toBeGreaterThan(20); + expect(commands.some((cmd) => cmd.command === '/bmad-help')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/bmad-bmm-create-prd')).toBe(true); + expect(commands.some((cmd) => cmd.command === '/bmad-bmm-quick-spec')).toBe(true); + expect(commands.every((cmd) => cmd.command.startsWith('/bmad'))).toBe(true); + }); + }); + + describe('saveBmadPrompt', () => { + it('should persist a customized prompt', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('bmad-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + await saveBmadPrompt('help', '# Custom Help'); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('bmad-customizations.json'), + expect.stringContaining('# Custom Help'), + 'utf-8' + ); + }); + }); + + describe('resetBmadPrompt', () => { + it('should reset a customized prompt to the bundled default', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('bmad-customizations.json')) { + return JSON.stringify({ + metadata: mockMetadata, + prompts: { + help: { + content: '# Customized Help', + isModified: true, + }, + }, + }); + } + if (pathStr.includes('bmad-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + if (pathStr.endsWith('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + const prompt = await resetBmadPrompt('help'); + + expect(prompt).toBe(mockBundledPrompt); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('bmad-customizations.json'), + expect.not.stringContaining('# Customized Help'), + 'utf-8' + ); + }); + + it('should throw for an unknown command', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + await expect(resetBmadPrompt('missing')).rejects.toThrow('Unknown BMAD command'); + }); + }); + + describe('command lookup helpers', () => { + it('should find a command by slash command', async () => { + vi.mocked(fs.readFile).mockImplementation(async (filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('bmad-customizations.json')) { + throw new Error('ENOENT'); + } + if (pathStr.includes('bmad-prompts')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.md')) { + return mockBundledPrompt; + } + if (pathStr.endsWith('metadata.json')) { + return JSON.stringify(mockMetadata); + } + throw new Error('ENOENT'); + }); + + const byId = await getBmadCommand('help'); + const bySlash = await getBmadCommandBySlash('/bmad-help'); + + expect(byId?.command).toBe('/bmad-help'); + expect(bySlash?.id).toBe('help'); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-activity-log.test.ts b/src/__tests__/main/cue/cue-activity-log.test.ts new file mode 100644 index 0000000000..cb870a6700 --- /dev/null +++ b/src/__tests__/main/cue/cue-activity-log.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for the Cue activity log ring buffer. + */ + +import { describe, it, expect } from 'vitest'; +import { createCueActivityLog } from '../../../main/cue/cue-activity-log'; +import type { CueRunResult } from '../../../main/cue/cue-types'; + +function makeResult(id: string): CueRunResult { + return { + runId: id, + sessionId: 'session-1', + sessionName: 'Test', + subscriptionName: 'sub', + event: { id: 'e1', type: 'time.heartbeat', timestamp: '', triggerName: 'sub', payload: {} }, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: '', + endedAt: '', + }; +} + +describe('createCueActivityLog', () => { + it('stores and retrieves results', () => { + const log = createCueActivityLog(); + log.push(makeResult('r1')); + log.push(makeResult('r2')); + expect(log.getAll()).toHaveLength(2); + expect(log.getAll()[0].runId).toBe('r1'); + }); + + it('respects limit parameter on getAll', () => { + const log = createCueActivityLog(); + log.push(makeResult('r1')); + log.push(makeResult('r2')); + log.push(makeResult('r3')); + const last2 = log.getAll(2); + expect(last2).toHaveLength(2); + expect(last2[0].runId).toBe('r2'); + expect(last2[1].runId).toBe('r3'); + }); + + it('evicts oldest entries when exceeding maxSize', () => { + const log = createCueActivityLog(3); + log.push(makeResult('r1')); + log.push(makeResult('r2')); + log.push(makeResult('r3')); + log.push(makeResult('r4')); + const all = log.getAll(); + expect(all).toHaveLength(3); + expect(all[0].runId).toBe('r2'); + expect(all[2].runId).toBe('r4'); + }); + + it('clear empties the log', () => { + const log = createCueActivityLog(); + log.push(makeResult('r1')); + log.clear(); + expect(log.getAll()).toHaveLength(0); + }); + + it('returns a copy from getAll, not a reference', () => { + const log = createCueActivityLog(); + log.push(makeResult('r1')); + const snapshot = log.getAll(); + log.push(makeResult('r2')); + expect(snapshot).toHaveLength(1); + }); +}); 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 0000000000..a35c752555 --- /dev/null +++ b/src/__tests__/main/cue/cue-completion-chains.test.ts @@ -0,0 +1,700 @@ +/** + * 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 } from '../../../main/cue/cue-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 cue-db to prevent real SQLite (better-sqlite3 native addon) operations +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + updateHeartbeat: vi.fn(), + getLastHeartbeat: vi.fn(() => null), // null = first start, skip reconcile + pruneCueEvents: vi.fn(), + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + +// Mock reconciler (not exercised in these tests, but avoids heavy imports) +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'follow up', + event: 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 and sets outputTruncated to true', () => { + 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 request = (deps.onCueRun as ReturnType).mock.calls[0][0]; + const event = request.event as CueEvent; + expect((event.payload.sourceOutput as string).length).toBe(5000); + expect(event.payload.outputTruncated).toBe(true); + + engine.stop(); + }); + + it('sets outputTruncated to false when output is under limit', () => { + 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', { stdout: 'short output' }); + + const request = (deps.onCueRun as ReturnType).mock.calls[0][0]; + const event = request.event as CueEvent; + expect(event.payload.outputTruncated).toBe(false); + + 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'follow up', + event: 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'do work', + event: expect.objectContaining({ type: 'time.heartbeat' }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-2', + prompt: 'follow up', + event: 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( + expect.objectContaining({ + sessionId: 'session-2', + prompt: 'deploy', + event: expect.objectContaining({ + payload: expect.objectContaining({ fanOutSource: 'trigger-session', fanOutIndex: 0 }), + }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-3', + prompt: 'deploy', + event: 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'aggregate', + event: expect.objectContaining({ + payload: expect.objectContaining({ + sourceOutput: 'output-a\n---\noutput-b', + sourceSession: 'Agent A, Agent B', + }), + }), + }) + ); + + engine.stop(); + }); + + it('sets outputTruncated in fan-in aggregate event when any source is truncated', () => { + 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(); + + const longOutput = 'x'.repeat(10000); + engine.notifyAgentCompleted('agent-a', { sessionName: 'Agent A', stdout: longOutput }); + engine.notifyAgentCompleted('agent-b', { sessionName: 'Agent B', stdout: 'short' }); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + payload: expect.objectContaining({ + outputTruncated: true, + }), + }), + }) + ); + + 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', + max_concurrent: 1, + queue_size: 10, + }, + }); + 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', + max_concurrent: 1, + queue_size: 10, + }, + }); + 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'aggregate', + event: expect.objectContaining({ + payload: expect.objectContaining({ + partial: true, + timedOutSessions: expect.arrayContaining(['agent-b']), + }), + }), + }) + ); + + engine.stop(); + }); + + it('includes outputTruncated in continue-mode timeout payload', () => { + 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', + max_concurrent: 1, + queue_size: 10, + }, + }); + 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 }); + + vi.advanceTimersByTime(1 * 60 * 1000 + 100); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + payload: expect.objectContaining({ + outputTruncated: true, + partial: true, + }), + }), + }) + ); + + 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 0000000000..ea45e74dd0 --- /dev/null +++ b/src/__tests__/main/cue/cue-concurrency.test.ts @@ -0,0 +1,846 @@ +/** + * 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'; + +// 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 database +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + pruneCueEvents: vi.fn(), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +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('stopRun concurrency slot release', () => { + it('stopRun frees the concurrency slot so queued events dispatch immediately', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), // Never resolves + }); + 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(); + + // First run starts immediately + await vi.advanceTimersByTimeAsync(10); + expect(engine.getActiveRuns()).toHaveLength(1); + + // Second event gets queued (max_concurrent = 1) + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Stop the active run — should free the slot and drain the queue + const activeRun = engine.getActiveRuns()[0]; + engine.stopRun(activeRun.runId); + + // The queued event should have been dispatched (onCueRun called again) + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(engine.getQueueStatus().size).toBe(0); + + engine.stopAll(); + engine.stop(); + }); + }); + + 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(); + }); + }); + + describe('stress scenarios', () => { + it('processes multiple queued events in FIFO order', async () => { + const resolvers: ((val: CueRunResult) => void)[] = []; + const callOrder: number[] = []; + let callCount = 0; + + const deps = createMockDeps({ + onCueRun: vi.fn(() => { + const idx = callCount++; + callOrder.push(idx); + return new Promise((resolve) => { + resolvers.push(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(); + + // Initial heartbeat occupies the slot + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Queue several events via timer advances + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 1 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 2 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 3 + expect(engine.getQueueStatus().get('session-1')).toBe(3); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); // still only 1 dispatched + + // Resolve the first run -> drain dispatches next queued event + const makeResult = (runId: string): CueRunResult => ({ + runId, + 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(), + }); + + resolvers[0](makeResult('r0')); + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + // Resolve second -> third dispatched + resolvers[1](makeResult('r1')); + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(3); + + // Resolve third -> fourth dispatched + resolvers[2](makeResult('r2')); + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(4); + + // Verify events dispatched in sequential order + expect(callOrder).toEqual([0, 1, 2, 3]); + + engine.stopAll(); + engine.stop(); + }); + + it('all queued events become stale and are dropped', 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(); + + // First run occupies the slot + await vi.advanceTimersByTimeAsync(10); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Queue several events + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 1 + vi.advanceTimersByTime(1 * 60 * 1000); // queued: 2 + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Advance time past timeout so all queued events become stale (>1 minute) + vi.advanceTimersByTime(3 * 60 * 1000); + + // Resolve the in-flight run -> drain should evict all queued events as stale + 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); + + // All stale events should have been dropped + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Dropping stale queued event') + ); + + engine.stopAll(); + engine.stop(); + }); + + it('enable/disable toggle during queue drain does not crash', 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); + + // Queue some events + vi.advanceTimersByTime(1 * 60 * 1000); + vi.advanceTimersByTime(1 * 60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Stop engine mid-drain — should not crash + expect(() => { + engine.stop(); + }).not.toThrow(); + + // Queue should be cleared + expect(engine.getQueueStatus().size).toBe(0); + }); + }); +}); 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 0000000000..798e66dce6 --- /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 0000000000..873a6ff1c5 --- /dev/null +++ b/src/__tests__/main/cue/cue-engine.test.ts @@ -0,0 +1,2850 @@ +/** + * 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'; + +// 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 the database +const mockInitCueDb = vi.fn(); +const mockCloseCueDb = vi.fn(); +const mockPruneCueEvents = vi.fn(); +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: (...args: unknown[]) => mockInitCueDb(...args), + closeCueDb: () => mockCloseCueDb(), + pruneCueEvents: (...args: unknown[]) => mockPruneCueEvents(...args), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + +// Mock crypto +vi.mock('crypto', () => ({ + randomUUID: vi.fn(() => `uuid-${Math.random().toString(36).slice(2, 8)}`), +})); + +import { + CueEngine, + calculateNextScheduledTime, + type CueEngineDeps, +} from '../../../main/cue/cue-engine'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +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')); + }); + + it('does not enable when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(engine.isEnabled()).toBe(false); + }); + + it('logs error when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(deps.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Failed to initialize Cue database') + ); + }); + + it('does not initialize sessions when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + expect(mockLoadCueConfig).not.toHaveBeenCalled(); + }); + + it('does not start heartbeat when initCueDb throws', () => { + mockInitCueDb.mockImplementation(() => { + throw new Error('DB corrupted'); + }); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + // Engine is not enabled, so getStatus should return empty + expect(engine.getStatus()).toEqual([]); + }); + + it('can retry start after DB failure', () => { + mockInitCueDb + .mockImplementationOnce(() => { + throw new Error('DB corrupted'); + }) + .mockImplementation(() => {}); + mockLoadCueConfig.mockReturnValue(null); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + engine.start(); + expect(engine.isEnabled()).toBe(false); + + engine.start(); + expect(engine.isEnabled()).toBe(true); + }); + }); + + 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'Run check', + timeoutMs: 30 * 60 * 1000, + event: 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'follow up', + event: 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( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'aggregate', + event: 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('stopRun signals the executor callback for active runs', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + 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(); + + await vi.advanceTimersByTimeAsync(10); + + const activeRun = engine.getActiveRuns()[0]; + expect(activeRun).toBeDefined(); + expect(engine.stopRun(activeRun.runId)).toBe(true); + expect(deps.onStopCueRun).toHaveBeenCalledWith(activeRun.runId); + + engine.stop(); + }); + + it('stopRun adds the stopped run to the activity log', async () => { + const deps = createMockDeps({ + onCueRun: vi.fn(() => new Promise(() => {})), + }); + 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(); + + await vi.advanceTimersByTimeAsync(10); + + const activeRun = engine.getActiveRuns()[0]; + expect(activeRun).toBeDefined(); + engine.stopRun(activeRun.runId); + + const log = engine.getActivityLog(); + expect(log).toHaveLength(1); + expect(log[0].runId).toBe(activeRun.runId); + expect(log[0].status).toBe('stopped'); + + engine.stop(); + }); + + 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); + expect(deps.onStopCueRun).toHaveBeenCalled(); + + 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('passes gh_state to GitHub poller config', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'merged-prs', + event: 'github.pull_request', + enabled: true, + prompt: 'review merged PR', + gh_state: 'merged', + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const engine = new CueEngine(createMockDeps()); + engine.start(); + + expect(mockCreateCueGitHubPoller).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'github.pull_request', + ghState: 'merged', + triggerName: 'merged-prs', + }) + ); + + engine.stop(); + }); + + 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][0].prompt).toBe('do work'); + + // Second call is the output prompt with context appended + expect(onCueRun.mock.calls[1][0].prompt).toContain('format results'); + expect(onCueRun.mock.calls[1][0].prompt).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'); + }); + }); + + describe('calculateNextScheduledTime', () => { + it('returns null for empty times array', () => { + expect(calculateNextScheduledTime([])).toBeNull(); + }); + + it('returns next occurrence today if time is ahead', () => { + // Monday 2026-03-09 at 08:00 + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + const result = calculateNextScheduledTime(['09:00']); + expect(result).not.toBeNull(); + const date = new Date(result!); + expect(date.getHours()).toBe(9); + expect(date.getMinutes()).toBe(0); + expect(date.getDate()).toBe(9); // same day + }); + + it('returns next occurrence tomorrow if time has passed', () => { + // Monday 2026-03-09 at 10:00 + vi.setSystemTime(new Date('2026-03-09T10:00:00')); + const result = calculateNextScheduledTime(['09:00']); + expect(result).not.toBeNull(); + const date = new Date(result!); + expect(date.getHours()).toBe(9); + expect(date.getMinutes()).toBe(0); + expect(date.getDate()).toBe(10); // next day + }); + + it('picks earliest matching time', () => { + // Monday 2026-03-09 at 08:00 + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + const result = calculateNextScheduledTime(['14:00', '09:00']); + expect(result).not.toBeNull(); + const date = new Date(result!); + expect(date.getHours()).toBe(9); + expect(date.getMinutes()).toBe(0); + }); + + it('respects days filter — skips non-matching days', () => { + // Monday 2026-03-09 at 10:00 + vi.setSystemTime(new Date('2026-03-09T10:00:00')); + const result = calculateNextScheduledTime(['09:00'], ['wed']); + expect(result).not.toBeNull(); + const date = new Date(result!); + // Wednesday is 2026-03-11 + expect(date.getDay()).toBe(3); // Wednesday + expect(date.getHours()).toBe(9); + }); + + it('returns null for invalid time strings', () => { + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + const result = calculateNextScheduledTime(['25:99']); + // Invalid hours/minutes — parseInt yields 25 and 99, but the resulting + // Date will roll over. The function still produces a candidate because + // Date constructor handles overflow. Check it doesn't crash. + // With hour=25, the date rolls to next day 01:XX — still a valid timestamp. + // This is acceptable behavior (no crash), but let's verify it returns something. + expect(typeof result === 'number' || result === null).toBe(true); + }); + + it('handles midnight crossing', () => { + // Monday 2026-03-09 at 23:30 + vi.setSystemTime(new Date('2026-03-09T23:30:00')); + const result = calculateNextScheduledTime(['00:15']); + expect(result).not.toBeNull(); + const date = new Date(result!); + expect(date.getDate()).toBe(10); // next day + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(15); + }); + + it('handles all days when no days filter provided', () => { + // Monday 2026-03-09 at 08:00 + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + const result = calculateNextScheduledTime(['09:00']); + expect(result).not.toBeNull(); + // Should be today since no day restriction + const date = new Date(result!); + expect(date.getDate()).toBe(9); + }); + + it('wraps around week boundary', () => { + // Saturday 2026-03-14 at 10:00 + vi.setSystemTime(new Date('2026-03-14T10:00:00')); + const result = calculateNextScheduledTime(['09:00'], ['mon']); + expect(result).not.toBeNull(); + const date = new Date(result!); + // Next Monday is 2026-03-16 + expect(date.getDay()).toBe(1); // Monday + expect(date.getDate()).toBe(16); + }); + }); + + describe('time.scheduled subscriptions', () => { + it('fires when current time matches schedule_times', async () => { + // Set to Monday 2026-03-09 at 08:59:00 — interval fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'daily-check', + event: 'time.scheduled', + enabled: true, + prompt: 'run daily check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Advance past the 60s check interval — time becomes 09:00 + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + prompt: 'run daily check', + subscriptionName: 'daily-check', + event: expect.objectContaining({ + type: 'time.scheduled', + }), + }) + ); + + engine.stop(); + }); + + it('does not fire when current time does not match', async () => { + // Set to Monday 2026-03-09 at 09:00:30 — interval fires at 09:01 + vi.setSystemTime(new Date('2026-03-09T09:00:30')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'daily-check', + event: 'time.scheduled', + enabled: true, + prompt: 'run daily check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('respects schedule_days filter — skips non-matching days', async () => { + // Saturday 2026-03-14 at 08:59:00 — interval fires at 09:00 + vi.setSystemTime(new Date('2026-03-14T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'weekday-check', + event: 'time.scheduled', + enabled: true, + prompt: 'run weekday check', + schedule_times: ['09:00'], + schedule_days: ['mon', 'tue', 'wed', 'thu', 'fri'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires when day matches schedule_days', async () => { + // Monday 2026-03-09 at 08:59:00 — interval fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'monday-check', + event: 'time.scheduled', + enabled: true, + prompt: 'monday task', + schedule_times: ['09:00'], + schedule_days: ['mon'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('skips when schedule_times is empty', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'no-times', + event: 'time.scheduled', + enabled: true, + prompt: 'should not run', + schedule_times: [], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // No interval should be created, no run triggered + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('does not fire when engine is disabled', async () => { + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'daily-check', + event: 'time.scheduled', + enabled: true, + prompt: 'run check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); // Disable + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('applies filter before firing', async () => { + // Monday at 08:59 — fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'filtered-schedule', + event: 'time.scheduled', + enabled: true, + prompt: 'filtered task', + schedule_times: ['09:00'], + filter: { matched_day: 'tue' }, // Won't match — today is Monday + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('filter not matched')); + + engine.stop(); + }); + + it('event payload includes matched_time and matched_day', async () => { + // Monday at 08:59 — fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'payload-check', + event: 'time.scheduled', + enabled: true, + prompt: 'check payload', + schedule_times: ['09:00'], + schedule_days: ['mon'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: 'time.scheduled', + triggerName: 'payload-check', + payload: expect.objectContaining({ + matched_time: '09:00', + matched_day: 'mon', + schedule_times: ['09:00'], + schedule_days: ['mon'], + }), + }), + }) + ); + + engine.stop(); + }); + + it('tracks nextTriggers via calculateNextScheduledTime', () => { + // Monday 2026-03-09 at 08:00 — next trigger should be 09:00 today + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'tracked-schedule', + event: 'time.scheduled', + enabled: true, + prompt: 'check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const status = engine.getStatus(); + const sessionStatus = status.find((s) => s.sessionId === 'session-1'); + expect(sessionStatus).toBeDefined(); + expect(sessionStatus!.subscriptionCount).toBe(1); + expect(sessionStatus!.nextTrigger).toBeDefined(); + + engine.stop(); + }); + + it('refreshes nextTriggers after time.scheduled fires', async () => { + // Monday 2026-03-09 at 08:59 — next trigger should be 09:00 today + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'refresh-schedule', + event: 'time.scheduled', + enabled: true, + prompt: 'check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const statusBefore = engine.getStatus(); + const subBefore = statusBefore.find((s) => s.sessionId === 'session-1'); + const nextBefore = subBefore!.nextTrigger!; + // nextTrigger should be pointing at 09:00 today (ISO string) + const nextBeforeDate = new Date(nextBefore); + expect(nextBeforeDate.getHours()).toBe(9); + expect(nextBeforeDate.getMinutes()).toBe(0); + + // Advance to 09:00 — the subscription fires + vi.advanceTimersByTime(60_000); + await vi.advanceTimersByTimeAsync(10); + + // After firing, nextTrigger should have advanced to a future time (tomorrow 09:00) + const statusAfter = engine.getStatus(); + const subAfter = statusAfter.find((s) => s.sessionId === 'session-1'); + expect(subAfter!.nextTrigger).toBeDefined(); + expect(new Date(subAfter!.nextTrigger!).getTime()).toBeGreaterThan(nextBeforeDate.getTime()); + + engine.stop(); + }); + + it('uses prompt_file when configured', async () => { + // Monday at 08:59 — fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'file-prompt', + event: 'time.scheduled', + enabled: true, + prompt: '', + prompt_file: 'check.md', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + // prompt_file takes precedence — engine passes prompt_file ?? prompt + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'check.md', + }) + ); + + engine.stop(); + }); + + it('passes output_prompt through', async () => { + // Monday at 08:59 — fires at 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'with-output', + event: 'time.scheduled', + enabled: true, + prompt: 'main task', + output_prompt: 'summarize results', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + + let runCount = 0; + const deps = createMockDeps({ + onCueRun: vi.fn(async (request) => { + runCount++; + return { + runId: `run-${runCount}`, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: 'task output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + }), + }); + + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(60_000); + + // Main run + output prompt run = 2 calls + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + // Second call should be the output prompt + expect(deps.onCueRun).toHaveBeenLastCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('summarize results'), + }) + ); + + engine.stop(); + }); + + it('clears timers on stop', () => { + vi.setSystemTime(new Date('2026-03-09T08:00:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'timer-cleanup', + event: 'time.scheduled', + enabled: true, + prompt: 'test', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + // After stop, advancing to 08:59 then 60s more = 09:00 + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + vi.advanceTimersByTime(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('skips disabled subscriptions', () => { + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'disabled-schedule', + event: 'time.scheduled', + enabled: false, + prompt: 'should not run', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.advanceTimersByTime(60_000); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('does not double-fire when config is refreshed within the same minute', () => { + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'morning-check', + event: 'time.scheduled', + enabled: true, + prompt: 'Run morning check', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const session = createMockSession(); + const deps = createMockDeps({ getSessions: vi.fn(() => [session]) }); + const engine = new CueEngine(deps); + engine.start(); + + // Advance to 09:00 — should fire once + vi.advanceTimersByTime(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Simulate config refresh within the same minute (e.g., YAML hot reload) + engine.refreshSession(session.id, session.projectRoot); + + // The new timer fires again in the same 09:00 minute — should NOT double-fire + vi.advanceTimersByTime(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('fires again in a new minute after the guard key is evicted', async () => { + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'multi-time', + event: 'time.scheduled', + enabled: true, + prompt: 'Run', + schedule_times: ['09:00', '09:02'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Advance to 09:00 — fires + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // 09:01 — no match, stale key for 09:00 is evicted + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // 09:02 — fires for the second scheduled time + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('clears scheduled fired keys when engine is stopped and restarted', () => { + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'restart-test', + event: 'time.scheduled', + enabled: true, + prompt: 'Run', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // First start: fire at 09:00 + engine.start(); + vi.advanceTimersByTime(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Stop and restart — keys should be cleared + engine.stop(); + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + engine.start(); + vi.advanceTimersByTime(60_000); + + // Should fire again because the engine was stopped (keys cleared) + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + }); + + describe('output prompt separate runId (Fix 5)', () => { + it('output prompt uses a different runId from main run', async () => { + const mainResult: CueRunResult = { + runId: 'run-main', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const outputResult: CueRunResult = { + ...mainResult, + runId: 'run-output', + stdout: 'formatted output', + }; + 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); + + expect(onCueRun).toHaveBeenCalledTimes(2); + const firstRunId = onCueRun.mock.calls[0][0].runId; + const secondRunId = onCueRun.mock.calls[1][0].runId; + expect(firstRunId).not.toBe(secondRunId); + + engine.stop(); + }); + + it('output prompt timeout falls back to main output', async () => { + const mainResult: CueRunResult = { + runId: 'run-main', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main-output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const timeoutResult: CueRunResult = { + ...mainResult, + runId: 'run-output', + status: 'timeout', + stdout: '', + }; + const onCueRun = vi + .fn() + .mockResolvedValueOnce(mainResult) + .mockResolvedValueOnce(timeoutResult); + + 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); + + // Activity log entry should have the main output (fallback) + const log = engine.getActivityLog(); + expect(log[0].stdout).toBe('main-output'); + + engine.stop(); + }); + + it('output prompt event includes outputPromptPhase: true', async () => { + const mainResult: CueRunResult = { + runId: 'run-main', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'timer', + event: {} as CueEvent, + status: 'completed', + stdout: 'main output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const outputResult: CueRunResult = { + ...mainResult, + runId: 'run-output', + stdout: 'formatted', + }; + 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); + + expect(onCueRun).toHaveBeenCalledTimes(2); + const secondCallEvent = onCueRun.mock.calls[1][0].event; + expect(secondCallEvent.payload.outputPromptPhase).toBe(true); + + engine.stop(); + }); + + it('completion chain receives output prompt stdout when successful', async () => { + // Session A has heartbeat + output_prompt; Session B watches session A via agent.completed + const sessionA = createMockSession({ + id: 'session-a', + name: 'Agent A', + projectRoot: '/proj/a', + }); + const sessionB = createMockSession({ + id: 'session-b', + name: 'Agent B', + projectRoot: '/proj/b', + }); + + const configA = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-a', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + output_prompt: 'format nicely', + interval_minutes: 60, + }, + ], + }); + const configB = createMockConfig({ + subscriptions: [ + { + name: 'chain-b', + event: 'agent.completed', + enabled: true, + prompt: 'react to A', + source_session: 'Agent A', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((root: string) => { + if (root === '/proj/a') return configA; + if (root === '/proj/b') return configB; + return null; + }); + + let runCount = 0; + const onCueRun = vi.fn(async (request: Parameters[0]) => { + runCount++; + const result: CueRunResult = { + runId: `run-${runCount}`, + sessionId: request.sessionId, + sessionName: request.sessionId === 'session-a' ? 'Agent A' : 'Agent B', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: runCount === 1 ? 'raw' : runCount === 2 ? 'formatted' : 'chain-output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + }); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [sessionA, sessionB]), + onCueRun, + }); + + const engine = new CueEngine(deps); + engine.start(); + + // Let the heartbeat fire (immediate) + output prompt + completion chain + await vi.advanceTimersByTimeAsync(100); + + // Session B's agent.completed event should have sourceOutput from the output prompt (formatted) + const chainCall = (onCueRun as ReturnType).mock.calls.find( + (call: unknown[]) => + (call[0] as { subscriptionName: string }).subscriptionName === 'chain-b' + ); + expect(chainCall).toBeDefined(); + expect((chainCall![0] as { event: CueEvent }).event.payload.sourceOutput).toContain( + 'formatted' + ); + + engine.stop(); + }); + }); + + describe('configuration hotloading', () => { + it('new subscription fires after hot reload', async () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'first', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config1); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // Capture the watchCueYaml onChange callback + let capturedOnChange: (() => void) | undefined; + mockWatchCueYaml.mockImplementation((_root: string, cb: () => void) => { + capturedOnChange = cb; + return vi.fn(); + }); + + engine.start(); + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Update config to have 2 subscriptions + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'first', + interval_minutes: 60, + }, + { + name: 'heartbeat-2', + event: 'time.heartbeat', + enabled: true, + prompt: 'second', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config2); + + // Invoke onChange to trigger hot reload + expect(capturedOnChange).toBeDefined(); + capturedOnChange!(); + + // Both heartbeats fire immediately on setup + await vi.advanceTimersByTimeAsync(0); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ prompt: 'second' })); + + engine.stop(); + }); + + it('removed subscription stops after hot reload', async () => { + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'first', + interval_minutes: 5, + }, + { + name: 'heartbeat-2', + event: 'time.heartbeat', + enabled: true, + prompt: 'second', + interval_minutes: 10, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config1); + const deps = createMockDeps(); + + let capturedOnChange: (() => void) | undefined; + mockWatchCueYaml.mockImplementation((_root: string, cb: () => void) => { + capturedOnChange = cb; + return vi.fn(); + }); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Reload with only heartbeat-1 + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-1', + event: 'time.heartbeat', + enabled: true, + prompt: 'first', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config2); + capturedOnChange!(); + + await vi.advanceTimersByTimeAsync(0); + vi.clearAllMocks(); + + // Advance 5 minutes — only heartbeat-1 should fire + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ prompt: 'first' })); + + engine.stop(); + }); + + it('YAML deletion tears down session', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 60, + }, + ], + }); + + let capturedOnChange: (() => void) | undefined; + mockWatchCueYaml.mockImplementation((_root: string, cb: () => void) => { + capturedOnChange = cb; + return vi.fn(); + }); + + mockLoadCueConfig.mockReturnValueOnce(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(0); + + // Reload returns null (YAML deleted) + mockLoadCueConfig.mockReturnValue(null); + capturedOnChange!(); + + // Session state should be removed + expect(engine.getStatus()).toHaveLength(0); + // Should log config removed + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('Config removed'), + expect.objectContaining({ type: 'configRemoved' }) + ); + + engine.stop(); + }); + + it('scheduledFiredKeys are cleaned on refresh', async () => { + // Start at 08:59 — 1 minute before the scheduled time + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'schedule-test', + event: 'time.scheduled', + enabled: true, + prompt: 'scheduled task', + schedule_times: ['09:00'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + + let capturedOnChange: (() => void) | undefined; + mockWatchCueYaml.mockImplementation((_root: string, cb: () => void) => { + capturedOnChange = cb; + return vi.fn(); + }); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + // Advance to 09:00 — should fire + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Refresh session — scheduledFiredKeys are cleared in teardownSession + capturedOnChange!(); + + // Reset system time to 08:59 so the next 60s advance lands at 09:00 again + vi.setSystemTime(new Date('2026-03-09T08:59:00')); + await vi.advanceTimersByTimeAsync(60_000); + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('changed max_concurrent applies to next drain', async () => { + const config1 = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + + let capturedOnChange: (() => void) | undefined; + mockWatchCueYaml.mockImplementation((_root: string, cb: () => void) => { + capturedOnChange = cb; + return vi.fn(); + }); + + // First run never resolves to keep the slot occupied + let resolveFirstRun: ((result: CueRunResult) => void) | undefined; + const firstRunPromise = new Promise((resolve) => { + resolveFirstRun = resolve; + }); + const subsequentResult: CueRunResult = { + runId: 'run-sub', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: {} as CueEvent, + status: 'completed', + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + const onCueRun = vi + .fn() + .mockReturnValueOnce(firstRunPromise) + .mockResolvedValue(subsequentResult); + + mockLoadCueConfig.mockReturnValue(config1); + const deps = createMockDeps({ onCueRun }); + const engine = new CueEngine(deps); + engine.start(); + + // First heartbeat fires immediately, occupying the single slot + await vi.advanceTimersByTimeAsync(0); + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Advance 1 minute — second heartbeat queued (max_concurrent=1, slot occupied) + await vi.advanceTimersByTimeAsync(60_000); + + // Reload with max_concurrent=2 + const config2 = createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'test', + interval_minutes: 1, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config2); + capturedOnChange!(); + + // The config reload tears down and reinitializes — the immediate heartbeat fires again + // With max_concurrent=2, the new heartbeat can dispatch immediately + await vi.advanceTimersByTimeAsync(0); + + // Resolve the first run to free the slot + resolveFirstRun!({ + ...subsequentResult, + runId: 'run-1', + }); + await vi.advanceTimersByTimeAsync(0); + + // After reload with max_concurrent=2, at least one additional run should have dispatched + expect(onCueRun.mock.calls.length).toBeGreaterThanOrEqual(2); + + engine.stop(); + }); + }); + + describe('prompt file existence warning (Fix 7)', () => { + it('logs warning when prompt_file is set but prompt is empty', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'missing-file-sub', + event: 'time.heartbeat', + enabled: true, + prompt: '', + prompt_file: 'missing.md', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + expect(deps.onLog).toHaveBeenCalledWith('warn', expect.stringContaining('prompt_file')); + expect(deps.onLog).toHaveBeenCalledWith('warn', expect.stringContaining('missing.md')); + + engine.stop(); + }); + + it('does not warn when prompt_file is set and prompt is populated', () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'valid-file-sub', + event: 'time.heartbeat', + enabled: true, + prompt: 'content from file', + prompt_file: 'exists.md', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + const warnCalls = (deps.onLog as ReturnType).mock.calls.filter( + (call: unknown[]) => + call[0] === 'warn' && typeof call[1] === 'string' && call[1].includes('prompt_file') + ); + expect(warnCalls).toHaveLength(0); + + engine.stop(); + }); + }); +}); diff --git a/src/__tests__/main/cue/cue-event-factory.test.ts b/src/__tests__/main/cue/cue-event-factory.test.ts new file mode 100644 index 0000000000..e15b35eb47 --- /dev/null +++ b/src/__tests__/main/cue/cue-event-factory.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { createCueEvent } from '../../../main/cue/cue-types'; + +describe('createCueEvent', () => { + it('returns an object with all 5 CueEvent fields', () => { + const event = createCueEvent('time.heartbeat', 'my-trigger', { foo: 'bar' }); + expect(event).toHaveProperty('id'); + expect(event).toHaveProperty('type'); + expect(event).toHaveProperty('timestamp'); + expect(event).toHaveProperty('triggerName'); + expect(event).toHaveProperty('payload'); + }); + + it('generates a valid UUID for id', () => { + const event = createCueEvent('file.changed', 'watcher'); + expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('generates a valid ISO timestamp', () => { + const event = createCueEvent('time.scheduled', 'daily-check'); + const parsed = new Date(event.timestamp).getTime(); + expect(Number.isNaN(parsed)).toBe(false); + expect(event.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('sets type to the provided event type', () => { + const event = createCueEvent('github.pull_request', 'pr-watcher'); + expect(event.type).toBe('github.pull_request'); + }); + + it('sets triggerName to the provided value', () => { + const event = createCueEvent('task.pending', 'task-scanner'); + expect(event.triggerName).toBe('task-scanner'); + }); + + it('defaults payload to empty object when omitted', () => { + const event = createCueEvent('time.heartbeat', 'heartbeat'); + expect(event.payload).toEqual({}); + }); + + it('includes provided payload values', () => { + const payload = { interval_minutes: 30, reconciled: true }; + const event = createCueEvent('time.heartbeat', 'heartbeat', payload); + expect(event.payload).toEqual(payload); + }); + + it('generates a unique id on each call', () => { + const event1 = createCueEvent('time.heartbeat', 'a'); + const event2 = createCueEvent('time.heartbeat', 'a'); + expect(event1.id).not.toBe(event2.id); + }); +}); 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 0000000000..5dd80d7939 --- /dev/null +++ b/src/__tests__/main/cue/cue-executor.test.ts @@ -0,0 +1,1354 @@ +/** + * 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. +// buildAgentArgs returns flags only — it does NOT append the prompt as a positional arg. +// The executor is responsible for appending the prompt after applyAgentConfigOverrides. +const mockBuildAgentArgs = vi.fn((_agent: unknown, _opts: unknown) => [ + '--print', + '--verbose', + '--output-format', + 'stream-json', + '--dangerously-skip-permissions', +]); +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 parsers — default returns null (no parser), overridden per test as needed +const mockGetOutputParser = vi.fn( + () => null as ReturnType +); +vi.mock('../../../main/parsers', () => ({ + getOutputParser: (...args: unknown[]) => mockGetOutputParser(...args), +})); + +// Mock child_process.spawn +class MockChildProcess extends EventEmitter { + pid = 12345; + 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, + getCueProcessList, + 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; + }); + + describe('prompt appended as CLI positional arg', () => { + it('appends -- then prompt for agents with no promptArgs/noPromptSeparator (default)', async () => { + // defaultAgentDef has neither promptArgs nor noPromptSeparator + const config = createExecutionConfig({ promptPath: 'Hello world' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const [, spawnedArgs] = mockSpawn.mock.calls[0] as [string, string[], unknown]; + // Last two args should be '--' and the substituted prompt + expect(spawnedArgs[spawnedArgs.length - 2]).toBe('--'); + expect(spawnedArgs[spawnedArgs.length - 1]).toContain('Hello world'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('uses promptArgs when the agent definition provides it', async () => { + mockGetAgentDefinition.mockReturnValue({ + ...defaultAgentDef, + promptArgs: (p: string) => ['-p', p], + }); + const config = createExecutionConfig({ promptPath: 'Hello world' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const [, spawnedArgs] = mockSpawn.mock.calls[0] as [string, string[], unknown]; + const pIdx = spawnedArgs.indexOf('-p'); + expect(pIdx).toBeGreaterThan(-1); + expect(spawnedArgs[pIdx + 1]).toContain('Hello world'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('appends prompt directly when noPromptSeparator is true', async () => { + mockGetAgentDefinition.mockReturnValue({ + ...defaultAgentDef, + noPromptSeparator: true, + }); + const config = createExecutionConfig({ promptPath: 'Hello world' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const [, spawnedArgs] = mockSpawn.mock.calls[0] as [string, string[], unknown]; + // Last arg is prompt, no '--' separator + expect(spawnedArgs[spawnedArgs.length - 1]).toContain('Hello world'); + expect(spawnedArgs[spawnedArgs.length - 2]).not.toBe('--'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('does not double-append prompt for SSH execution (wrapper handles it)', async () => { + const mockSshStore = { getSshRemotes: vi.fn(() => []) }; + + mockWrapSpawnWithSsh.mockResolvedValue({ + command: 'ssh', + args: ['user@host', 'claude', '--print', '--', 'Hello world'], + cwd: '/Users/test', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: { id: 'remote-1', name: 'Server', host: 'host' }, + }); + + const config = createExecutionConfig({ + promptPath: 'Hello world', + sshRemoteConfig: { enabled: true, remoteId: 'remote-1' }, + sshStore: mockSshStore, + }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const [, spawnedArgs] = mockSpawn.mock.calls[0] as [string, string[], unknown]; + // spawnArgs come from sshResult.args — the prompt appears exactly once + const promptOccurrences = spawnedArgs.filter((a) => a.includes('Hello world')).length; + expect(promptOccurrences).toBe(1); + + 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; + }); + + it('should populate task.pending event context correctly', async () => { + const event = createMockEvent({ + type: 'task.pending', + triggerName: 'Task watcher', + payload: { + path: '/projects/test/TODO.md', + filename: 'TODO.md', + directory: '/projects/test', + taskCount: 3, + taskList: '- [ ] Fix login bug\n- [ ] Update docs\n- [ ] Add tests', + content: '# TODO\n- [ ] Fix login bug\n- [ ] Update docs\n- [ ] Add tests', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.taskFile).toBe('/projects/test/TODO.md'); + expect(templateContext.cue?.taskFileName).toBe('TODO.md'); + expect(templateContext.cue?.taskFileDir).toBe('/projects/test'); + expect(templateContext.cue?.taskCount).toBe('3'); + expect(templateContext.cue?.taskList).toBe( + '- [ ] Fix login bug\n- [ ] Update docs\n- [ ] Add tests' + ); + expect(templateContext.cue?.taskContent).toBe( + '# TODO\n- [ ] Fix login bug\n- [ ] Update docs\n- [ ] Add tests' + ); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should preserve base cue context alongside task fields', async () => { + const event = createMockEvent({ + type: 'task.pending', + triggerName: 'Task watcher', + payload: { + path: '/projects/test/TODO.md', + filename: 'TODO.md', + directory: '/projects/test', + taskCount: 1, + taskList: '- [ ] Single task', + content: '- [ ] Single task', + }, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Base cue fields should still be populated + expect(templateContext.cue?.eventType).toBe('task.pending'); + expect(templateContext.cue?.triggerName).toBe('Watch config'); // from subscription.name + expect(templateContext.cue?.runId).toBeDefined(); + expect(templateContext.cue?.eventTimestamp).toBeDefined(); + + // Task-specific fields coexist + expect(templateContext.cue?.taskCount).toBe('1'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should default task fields to empty/zero when payload is missing', async () => { + const event = createMockEvent({ + type: 'task.pending', + triggerName: 'Task watcher', + payload: {}, + }); + + const templateContext = createMockTemplateContext(); + const config = createExecutionConfig({ event, templateContext }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + expect(templateContext.cue?.taskFile).toBe(''); + expect(templateContext.cue?.taskFileName).toBe(''); + expect(templateContext.cue?.taskFileDir).toBe(''); + expect(templateContext.cue?.taskCount).toBe('0'); + expect(templateContext.cue?.taskList).toBe(''); + expect(templateContext.cue?.taskContent).toBe(''); + + 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'); + }); + }); + + describe('stdout clean extraction via output parser', () => { + it('should return raw stdout when no parser is registered for the agent', async () => { + mockGetOutputParser.mockReturnValue(null); + + const config = createExecutionConfig({ toolType: 'claude-code' }); + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.stdout.emit('data', 'plain text output\n'); + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('plain text output\n'); + }); + + it('should extract result-event text when parser is available', async () => { + const ndjson = [ + JSON.stringify({ type: 'step_start', part: { type: 'step-start' } }), + JSON.stringify({ + type: 'text', + part: { text: 'Hello! Fun fact: bees can recognize human faces.' }, + }), + JSON.stringify({ + type: 'step_finish', + part: { reason: 'stop', tokens: { input: 10, output: 20 } }, + }), + ].join('\n'); + + mockGetOutputParser.mockReturnValue({ + parseJsonLine: (line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') { + return { type: 'result', text: msg.part?.text || '' }; + } + return { type: 'system', raw: msg }; + } catch { + return { type: 'text', text: line }; + } + }, + } as any); + + const config = createExecutionConfig({ toolType: 'opencode' }); + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.stdout.emit('data', ndjson); + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('Hello! Fun fact: bees can recognize human faces.'); + }); + + it('should join multiple result-event text chunks with newlines', async () => { + mockGetOutputParser.mockReturnValue({ + parseJsonLine: (line: string) => { + try { + const msg = JSON.parse(line); + if (msg.type === 'text') { + return { type: 'result', text: msg.part?.text || '' }; + } + return { type: 'system', raw: msg }; + } catch { + return { type: 'text', text: line }; + } + }, + } as any); + + const ndjson = [ + JSON.stringify({ type: 'text', part: { text: 'First paragraph.' } }), + JSON.stringify({ type: 'text', part: { text: 'Second paragraph.' } }), + ].join('\n'); + + const config = createExecutionConfig({ toolType: 'opencode' }); + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.stdout.emit('data', ndjson); + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe('First paragraph.\nSecond paragraph.'); + }); + + it('should fall back to raw stdout when parser finds no result events', async () => { + // Parser that only returns system events (no result events) + mockGetOutputParser.mockReturnValue({ + parseJsonLine: (_line: string) => ({ type: 'system', raw: {} }), + } as any); + + const rawOutput = '{"type":"step_start","part":{"type":"step-start"}}'; + + const config = createExecutionConfig({ toolType: 'opencode' }); + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + mockChild.stdout.emit('data', rawOutput); + mockChild.emit('close', 0); + const result = await resultPromise; + + expect(result.stdout).toBe(rawOutput); + }); + }); + + describe('getCueProcessList', () => { + it('should return empty array when no active processes', () => { + expect(getCueProcessList()).toEqual([]); + }); + + it('should return process info during active run', async () => { + const config = createExecutionConfig({ runId: 'list-test-run', toolType: 'claude-code' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + const list = getCueProcessList(); + expect(list).toHaveLength(1); + expect(list[0].runId).toBe('list-test-run'); + expect(list[0].pid).toBe(12345); + expect(list[0].toolType).toBe('claude-code'); + expect(list[0].cwd).toBe('/projects/test'); + expect(list[0].command).toBe('claude'); + expect(Array.isArray(list[0].args)).toBe(true); + expect(typeof list[0].startTime).toBe('number'); + + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should exclude completed processes', async () => { + const config = createExecutionConfig({ runId: 'completed-run' }); + + const resultPromise = executeCuePrompt(config); + await vi.advanceTimersByTimeAsync(0); + + // Process is active — should appear in list + const activeEntry = getActiveProcesses().get('completed-run'); + expect(activeEntry).toBeDefined(); + expect(getCueProcessList().some((p) => p.runId === 'completed-run')).toBe(true); + + mockChild.emit('close', 0); + await resultPromise; + + // Process completed — should be removed + expect(getActiveProcesses().has('completed-run')).toBe(false); + expect(getCueProcessList().some((p) => p.runId === 'completed-run')).toBe(false); + }); + }); +}); 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 0000000000..42ce00f1ba --- /dev/null +++ b/src/__tests__/main/cue/cue-file-watcher.test.ts @@ -0,0 +1,243 @@ +/** + * 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 with console.error fallback', () => { + 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(); + }); + + it('routes errors through onLog when provided', () => { + const onLog = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + createCueFileWatcher({ + watchGlob: '**/*.ts', + projectRoot: '/test', + debounceMs: 5000, + onEvent: vi.fn(), + triggerName: 'my-watcher', + onLog, + }); + + const errorHandler = mockOn.mock.calls.find((call) => call[0] === 'error')?.[1]; + expect(errorHandler).toBeDefined(); + + errorHandler(new Error('Watch error')); + + expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('my-watcher')); + expect(onLog).toHaveBeenCalledWith('error', expect.stringContaining('Watch error')); + expect(consoleSpy).not.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 0000000000..d65e29b01e --- /dev/null +++ b/src/__tests__/main/cue/cue-filter.test.ts @@ -0,0 +1,336 @@ +/** + * 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('NaN handling in numeric comparisons', () => { + it('rejects non-numeric payload value with > filter', () => { + expect(matchesFilter({ size: 'abc' }, { size: '>5' })).toBe(false); + }); + + it('rejects non-numeric payload value with >= filter', () => { + expect(matchesFilter({ size: 'abc' }, { size: '>=5' })).toBe(false); + }); + + it('rejects non-numeric payload value with < filter', () => { + expect(matchesFilter({ size: 'abc' }, { size: '<5' })).toBe(false); + }); + + it('rejects non-numeric payload value with <= filter', () => { + expect(matchesFilter({ size: 'abc' }, { size: '<=5' })).toBe(false); + }); + + it('rejects non-numeric threshold in >= filter', () => { + expect(matchesFilter({ size: 100 }, { size: '>=abc' })).toBe(false); + }); + + it('rejects non-numeric threshold in > filter', () => { + expect(matchesFilter({ size: 100 }, { size: '>abc' })).toBe(false); + }); + + it('rejects non-numeric threshold in < filter', () => { + expect(matchesFilter({ size: 100 }, { size: ' { + expect(matchesFilter({ size: 100 }, { size: '<=abc' })).toBe(false); + }); + + it('rejects empty string payload value with numeric filter', () => { + expect(matchesFilter({ size: '' }, { size: '>0' })).toBe(false); + }); + + it('rejects empty string payload that would coerce to 0 with >=0 filter', () => { + // Number('') === 0, but empty string is not a valid numeric operand + expect(matchesFilter({ size: '' }, { size: '>=0' })).toBe(false); + }); + + it('rejects null payload value with numeric filter', () => { + expect(matchesFilter({ size: null }, { size: '>=0' })).toBe(false); + }); + + it('rejects whitespace-only string with numeric filter', () => { + expect(matchesFilter({ size: ' ' }, { size: '>0' })).toBe(false); + }); + + it('rejects Infinity payload value with numeric filter', () => { + expect(matchesFilter({ size: Infinity }, { size: '>0' })).toBe(false); + }); + + it('rejects -Infinity payload value with numeric filter', () => { + expect(matchesFilter({ size: -Infinity }, { size: '>0' })).toBe(false); + }); + + it('rejects NaN payload value with numeric filter', () => { + expect(matchesFilter({ size: NaN }, { size: '>0' })).toBe(false); + }); + + it('rejects NaN threshold in filter expression', () => { + expect(matchesFilter({ size: 100 }, { size: '>NaN' })).toBe(false); + }); + + it('rejects empty threshold in filter expression', () => { + // filterValue '>=' sliced to '' → Number('') = 0, but should reject + expect(matchesFilter({ size: 5 }, { size: '>=' })).toBe(false); + }); + + it('handles boolean payload coercion with numeric filter', () => { + // Number(true) === 1, so true > 0 should pass + expect(matchesFilter({ active: true }, { active: '>0' })).toBe(true); + // Number(false) === 0, so false > 0 should fail + expect(matchesFilter({ active: false }, { active: '>0' })).toBe(false); + }); + }); + + describe('empty filter', () => { + it('matches everything when filter is empty', () => { + expect(matchesFilter({ any: 'value' }, {})).toBe(true); + expect(matchesFilter({}, {})).toBe(true); + }); + }); + }); + + describe('combined filter conditions', () => { + it('combined numeric + glob in same filter object', () => { + expect( + matchesFilter({ size: 1500, path: 'src/app.ts' }, { size: '>1000', path: 'src/**/*.ts' }) + ).toBe(true); + expect( + matchesFilter({ size: 500, path: 'src/app.ts' }, { size: '>1000', path: 'src/**/*.ts' }) + ).toBe(false); + }); + }); + + describe('unicode handling', () => { + it('matches unicode strings exactly', () => { + expect(matchesFilter({ name: '日本語' }, { name: '日本語' })).toBe(true); + expect(matchesFilter({ name: '日本語' }, { name: '中文' })).toBe(false); + }); + }); + + describe('deep dot notation', () => { + it('resolves 4-level deep path', () => { + expect(matchesFilter({ a: { b: { c: { d: 'found' } } } }, { 'a.b.c.d': 'found' })).toBe(true); + }); + + it('returns false for partial path', () => { + expect(matchesFilter({ a: { b: 42 } }, { 'a.b.c': 'anything' })).toBe(false); + }); + }); + + 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 0000000000..5f542c2577 --- /dev/null +++ b/src/__tests__/main/cue/cue-github-poller.test.ts @@ -0,0 +1,811 @@ +/** + * 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 via execFile) +vi.mock('child_process', () => ({ + default: { execFile: mockExecFile }, + execFile: mockExecFile, +})); + +// Mock cliDetection — resolveGhPath returns 'gh', getExpandedEnv returns process.env +vi.mock('../../../main/utils/cliDetection', () => ({ + resolveGhPath: vi.fn().mockResolvedValue('gh'), + getExpandedEnv: vi.fn().mockReturnValue(process.env), +})); + +// 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', + merged_at: '', + }); + + 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); + }); + + describe('first poll error resilience (Fix 3)', () => { + it('places seed marker when first poll fails', async () => { + const config = makeConfig(); + + // First call (--version) succeeds, but pr list fails + 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++; + cb(new Error('Network timeout'), '', ''); + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + expect(mockMarkGitHubItemSeen).toHaveBeenCalledWith('session-1:test-sub', '__seed_marker__'); + expect(config.onLog).toHaveBeenCalledWith('info', expect.stringContaining('seed marker set')); + + cleanup(); + }); + + it('second poll after first-poll error fires events for new items', async () => { + const config = makeConfig({ pollMinutes: 1 }); + + // First poll: pr list fails + // Second poll: pr list succeeds + let prListCallCount = 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')) { + prListCallCount++; + if (prListCallCount === 1) { + cb(new Error('Network timeout'), '', ''); + } else { + cb(null, JSON.stringify([samplePRs[0]]), ''); + } + } else { + cb(new Error('not found'), '', ''); + } + } + ); + + // After first poll error, seed marker is placed, so hasAnyGitHubSeen returns true + // This means second poll treats items as NOT first-run and fires events + mockHasAnyGitHubSeen.mockReturnValue(true); + + const cleanup = createCueGitHubPoller(config); + + // First poll at 2s — fails, seed marker placed + await vi.advanceTimersByTimeAsync(2000); + expect(config.onEvent).not.toHaveBeenCalled(); + + // Second poll at 2s + 1min — succeeds, fires events + await vi.advanceTimersByTimeAsync(60000); + expect(config.onEvent).toHaveBeenCalledTimes(1); + const event = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(event.payload.number).toBe(1); + + cleanup(); + }); + }); + + describe('ghState parameter', () => { + it('passes "closed" state to gh pr list when ghState is "closed"', async () => { + const config = makeConfig({ ghState: 'closed' }); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Verify the gh command was called with --state closed + const prListCall = mockExecFile.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('pr') + ); + expect(prListCall).toBeDefined(); + const args = prListCall![1] as string[]; + const stateIdx = args.indexOf('--state'); + expect(args[stateIdx + 1]).toBe('closed'); + + cleanup(); + }); + + it('queries closed PRs and filters by mergedAt when ghState is "merged"', async () => { + const mergedPRs = [ + { + number: 10, + title: 'Merged PR', + author: { login: 'alice' }, + url: 'https://github.com/owner/repo/pull/10', + body: 'Already merged', + state: 'CLOSED', + isDraft: false, + labels: [], + headRefName: 'feature', + baseRefName: 'main', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + mergedAt: '2026-03-02T10:00:00Z', + }, + { + number: 11, + title: 'Closed but not merged', + author: { login: 'bob' }, + url: 'https://github.com/owner/repo/pull/11', + body: 'Rejected', + state: 'CLOSED', + isDraft: false, + labels: [], + headRefName: 'bad-branch', + baseRefName: 'main', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-03-02T00:00:00Z', + mergedAt: null, + }, + ]; + + const config = makeConfig({ ghState: 'merged' }); + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify(mergedPRs), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + // Should only fire for the merged PR (number 10), not the closed one (number 11) + expect(config.onEvent).toHaveBeenCalledTimes(1); + const firedEvent = (config.onEvent as ReturnType).mock.calls[0][0]; + expect(firedEvent.payload.number).toBe(10); + expect(firedEvent.payload.state).toBe('merged'); + expect(firedEvent.payload.merged_at).toBe('2026-03-02T10:00:00Z'); + + cleanup(); + }); + + it('passes "all" state to gh issue list when ghState is "all"', async () => { + const config = makeConfig({ eventType: 'github.issue', ghState: 'all' }); + setupExecFile({ + '--version': '2.0.0', + 'issue list': JSON.stringify([]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const issueListCall = mockExecFile.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('issue') + ); + expect(issueListCall).toBeDefined(); + const args = issueListCall![1] as string[]; + const stateIdx = args.indexOf('--state'); + expect(args[stateIdx + 1]).toBe('all'); + + cleanup(); + }); + + it('defaults to "open" state when ghState is not specified', async () => { + const config = makeConfig(); // no ghState + setupExecFile({ + '--version': '2.0.0', + 'pr list': JSON.stringify([]), + }); + + const cleanup = createCueGitHubPoller(config); + await vi.advanceTimersByTimeAsync(2000); + + const prListCall = mockExecFile.mock.calls.find((call: unknown[]) => + (call[1] as string[]).includes('pr') + ); + expect(prListCall).toBeDefined(); + const args = prListCall![1] as string[]; + const stateIdx = args.indexOf('--state'); + expect(args[stateIdx + 1]).toBe('open'); + + cleanup(); + }); + }); +}); 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 0000000000..963dc73f72 --- /dev/null +++ b/src/__tests__/main/cue/cue-ipc-handlers.test.ts @@ -0,0 +1,460 @@ +/** + * 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 { + getSettings: vi.fn().mockReturnValue({ + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }), + 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(), + triggerSubscription: vi.fn().mockReturnValue(true), + getQueueStatus: vi.fn().mockReturnValue(new Map()), + refreshSession: vi.fn(), + removeSession: vi.fn(), + getGraphData: vi.fn().mockReturnValue([]), + 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:getSettings', + 'cue:getStatus', + 'cue:getActiveRuns', + 'cue:getActivityLog', + 'cue:enable', + 'cue:disable', + 'cue:stopRun', + 'cue:stopAll', + 'cue:triggerSubscription', + 'cue:getQueueStatus', + 'cue:refreshSession', + 'cue:removeSession', + 'cue:getGraphData', + '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:removeSession', () => { + it('should call engine.removeSession()', async () => { + const handler = registerAndGetHandler('cue:removeSession'); + await handler(null, { sessionId: 's1' }); + expect(mockEngine.removeSession).toHaveBeenCalledWith('s1'); + }); + }); + + 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('edge cases', () => { + it('cue:getStatus returns empty array when engine not started', async () => { + // Engine exists but getStatus returns empty (no sessions registered) + mockEngine.getStatus.mockReturnValue([]); + + const handler = registerAndGetHandler('cue:getStatus'); + const result = await handler(null); + expect(result).toEqual([]); + expect(mockEngine.getStatus).toHaveBeenCalledOnce(); + }); + + it('cue:getActivityLog with limit returns bounded results', async () => { + const manyEntries = Array.from({ length: 10 }, (_, i) => ({ + runId: `r${i}`, + sessionId: 's1', + sessionName: 'Test', + subscriptionName: 'timer', + event: { + id: `e${i}`, + type: 'time.heartbeat', + timestamp: new Date().toISOString(), + triggerName: 'timer', + payload: {}, + }, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })); + + // Simulate engine returning only the last 2 entries (bounded by limit) + mockEngine.getActivityLog.mockReturnValue(manyEntries.slice(-2)); + + const handler = registerAndGetHandler('cue:getActivityLog'); + const result = await handler(null, { limit: 2 }); + + expect(result).toHaveLength(2); + expect(mockEngine.getActivityLog).toHaveBeenCalledWith(2); + }); + + it('cue:validateYaml handles empty content', async () => { + // Empty string: yaml.load returns undefined/null for empty input + vi.mocked(yaml.load).mockReturnValue(undefined); + vi.mocked(validateCueConfig).mockReturnValue({ + valid: false, + errors: ['Config must have a "subscriptions" array'], + }); + + const handler = registerAndGetHandler('cue:validateYaml'); + const result = (await handler(null, { content: '' })) as { valid: boolean; errors: string[] }; + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + }); + + 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-multi-hop-chains.test.ts b/src/__tests__/main/cue/cue-multi-hop-chains.test.ts new file mode 100644 index 0000000000..170f07d70a --- /dev/null +++ b/src/__tests__/main/cue/cue-multi-hop-chains.test.ts @@ -0,0 +1,731 @@ +/** + * Tests for CueEngine multi-hop completion chains and circular chain detection. + * + * Tests cover: + * - Multi-hop chains (A -> B -> C) + * - Stdout propagation through chains + * - Failed middle step with filters + * - Circular chain detection (A -> B -> A) + * - Self-referencing subscription detection + * - Fan-in -> fan-out combination + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-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 cue-db +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + updateHeartbeat: vi.fn(), + getLastHeartbeat: vi.fn(() => null), + pruneCueEvents: vi.fn(), + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + +// Mock reconciler +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +describe('CueEngine multi-hop completion chains', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('A -> B -> C chain executes all 3 with correct payloads', async () => { + const sessions = [ + createMockSession({ + id: 'source', + name: 'Source', + cwd: '/proj/source', + projectRoot: '/proj/source', + }), + createMockSession({ + id: 'middle', + name: 'Middle', + cwd: '/proj/middle', + projectRoot: '/proj/middle', + }), + createMockSession({ + id: 'downstream', + name: 'Downstream', + cwd: '/proj/downstream', + projectRoot: '/proj/downstream', + }), + ]; + + const configSource = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-source', + event: 'time.heartbeat', + enabled: true, + prompt: 'do source work', + interval_minutes: 60, + }, + ], + }); + const configMiddle = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'do middle work', + source_session: 'Source', + }, + ], + }); + const configDownstream = createMockConfig({ + subscriptions: [ + { + name: 'on-middle-done', + event: 'agent.completed', + enabled: true, + prompt: 'do downstream work', + source_session: 'Middle', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj/source') return configSource; + if (projectRoot === '/proj/middle') return configMiddle; + if (projectRoot === '/proj/downstream') return configDownstream; + return null; + }); + + const onCueRun = vi.fn( + async (request: { + runId: string; + sessionId: string; + prompt: string; + subscriptionName: string; + event: CueEvent; + timeoutMs: number; + }) => { + const session = sessions.find((s) => s.id === request.sessionId); + const result: CueRunResult = { + runId: request.runId, + sessionId: request.sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed', + stdout: `output-${request.sessionId}`, + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + } + ); + + const deps = createMockDeps({ + getSessions: vi.fn(() => sessions), + onCueRun: onCueRun as CueEngineDeps['onCueRun'], + }); + const engine = new CueEngine(deps); + engine.start(); + + // Flush all async work (heartbeat fires immediately, then chains through) + await vi.advanceTimersByTimeAsync(0); + + expect(onCueRun).toHaveBeenCalledTimes(3); + + // First call: heartbeat fires Source + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'source', + prompt: 'do source work', + event: expect.objectContaining({ type: 'time.heartbeat' }), + }) + ); + + // Second call: Source completion triggers Middle + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'middle', + prompt: 'do middle work', + event: expect.objectContaining({ type: 'agent.completed', triggerName: 'on-source-done' }), + }) + ); + + // Third call: Middle completion triggers Downstream + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'downstream', + prompt: 'do downstream work', + event: expect.objectContaining({ type: 'agent.completed', triggerName: 'on-middle-done' }), + }) + ); + + engine.stop(); + }); + + it('stdout carries through chain', async () => { + const sessions = [ + createMockSession({ + id: 'source', + name: 'Source', + cwd: '/proj/source', + projectRoot: '/proj/source', + }), + createMockSession({ + id: 'middle', + name: 'Middle', + cwd: '/proj/middle', + projectRoot: '/proj/middle', + }), + createMockSession({ + id: 'downstream', + name: 'Downstream', + cwd: '/proj/downstream', + projectRoot: '/proj/downstream', + }), + ]; + + const configSource = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-source', + event: 'time.heartbeat', + enabled: true, + prompt: 'do source work', + interval_minutes: 60, + }, + ], + }); + const configMiddle = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'do middle work', + source_session: 'Source', + }, + ], + }); + const configDownstream = createMockConfig({ + subscriptions: [ + { + name: 'on-middle-done', + event: 'agent.completed', + enabled: true, + prompt: 'do downstream work', + source_session: 'Middle', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj/source') return configSource; + if (projectRoot === '/proj/middle') return configMiddle; + if (projectRoot === '/proj/downstream') return configDownstream; + return null; + }); + + const onCueRun = vi.fn( + async (request: { + runId: string; + sessionId: string; + prompt: string; + subscriptionName: string; + event: CueEvent; + timeoutMs: number; + }) => { + const session = sessions.find((s) => s.id === request.sessionId); + const result: CueRunResult = { + runId: request.runId, + sessionId: request.sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed', + stdout: `output-${request.sessionId}`, + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + } + ); + + const deps = createMockDeps({ + getSessions: vi.fn(() => sessions), + onCueRun: onCueRun as CueEngineDeps['onCueRun'], + }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(0); + + // Middle's event payload should contain Source's stdout + const middleCall = onCueRun.mock.calls.find((call) => call[0].sessionId === 'middle'); + expect(middleCall).toBeDefined(); + expect(middleCall![0].event.payload).toEqual( + expect.objectContaining({ + sourceOutput: 'output-source', + }) + ); + + // Downstream's event payload should contain Middle's stdout + const downstreamCall = onCueRun.mock.calls.find((call) => call[0].sessionId === 'downstream'); + expect(downstreamCall).toBeDefined(); + expect(downstreamCall![0].event.payload).toEqual( + expect.objectContaining({ + sourceOutput: 'output-middle', + }) + ); + + engine.stop(); + }); + + it('failed middle step stops chain when downstream has status filter', async () => { + const sessions = [ + createMockSession({ + id: 'source', + name: 'Source', + cwd: '/proj/source', + projectRoot: '/proj/source', + }), + createMockSession({ + id: 'middle', + name: 'Middle', + cwd: '/proj/middle', + projectRoot: '/proj/middle', + }), + createMockSession({ + id: 'downstream', + name: 'Downstream', + cwd: '/proj/downstream', + projectRoot: '/proj/downstream', + }), + ]; + + const configSource = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-source', + event: 'time.heartbeat', + enabled: true, + prompt: 'do source work', + interval_minutes: 60, + }, + ], + }); + const configMiddle = createMockConfig({ + subscriptions: [ + { + name: 'on-source-done', + event: 'agent.completed', + enabled: true, + prompt: 'do middle work', + source_session: 'Source', + }, + ], + }); + const configDownstream = createMockConfig({ + subscriptions: [ + { + name: 'on-middle-done', + event: 'agent.completed', + enabled: true, + prompt: 'do downstream work', + source_session: 'Middle', + filter: { status: 'completed' }, + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj/source') return configSource; + if (projectRoot === '/proj/middle') return configMiddle; + if (projectRoot === '/proj/downstream') return configDownstream; + return null; + }); + + const onCueRun = vi.fn( + async (request: { + runId: string; + sessionId: string; + prompt: string; + subscriptionName: string; + event: CueEvent; + timeoutMs: number; + }) => { + const session = sessions.find((s) => s.id === request.sessionId); + // Middle fails, everything else succeeds + const isFailed = request.sessionId === 'middle'; + const result: CueRunResult = { + runId: request.runId, + sessionId: request.sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName: request.subscriptionName, + event: request.event, + status: isFailed ? 'failed' : 'completed', + stdout: `output-${request.sessionId}`, + stderr: isFailed ? 'error occurred' : '', + exitCode: isFailed ? 1 : 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + } + ); + + const deps = createMockDeps({ + getSessions: vi.fn(() => sessions), + onCueRun: onCueRun as CueEngineDeps['onCueRun'], + }); + const engine = new CueEngine(deps); + engine.start(); + + await vi.advanceTimersByTimeAsync(0); + + // Source heartbeat fires, then Middle fires (triggered by Source completion), + // but Downstream should NOT fire because Middle failed and filter requires 'completed' + expect(onCueRun).toHaveBeenCalledTimes(2); + + // Verify the two calls are Source and Middle only + const calledSessionIds = onCueRun.mock.calls.map((call) => call[0].sessionId); + expect(calledSessionIds).toContain('source'); + expect(calledSessionIds).toContain('middle'); + expect(calledSessionIds).not.toContain('downstream'); + + engine.stop(); + }); + + it('circular chain A -> B -> A is bounded by MAX_CHAIN_DEPTH', async () => { + // The chain depth guard (MAX_CHAIN_DEPTH=10) is propagated through AgentCompletionData + // across async hops. When depth reaches the limit, notifyAgentCompleted aborts and logs. + const sessions = [ + createMockSession({ id: 'a', name: 'A', cwd: '/proj/a', projectRoot: '/proj/a' }), + createMockSession({ id: 'b', name: 'B', cwd: '/proj/b', projectRoot: '/proj/b' }), + ]; + + const configA = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-a', + event: 'time.heartbeat', + enabled: true, + prompt: 'do a work', + interval_minutes: 60, + }, + { + name: 'on-b-done', + event: 'agent.completed', + enabled: true, + prompt: 'react to b', + source_session: 'B', + }, + ], + }); + const configB = createMockConfig({ + subscriptions: [ + { + name: 'on-a-done', + event: 'agent.completed', + enabled: true, + prompt: 'react to a', + source_session: 'A', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj/a') return configA; + if (projectRoot === '/proj/b') return configB; + return null; + }); + + const onCueRun = vi.fn( + async (request: { + runId: string; + sessionId: string; + prompt: string; + subscriptionName: string; + event: CueEvent; + timeoutMs: number; + }) => { + const session = sessions.find((s) => s.id === request.sessionId); + const result: CueRunResult = { + runId: request.runId, + sessionId: request.sessionId, + sessionName: session?.name ?? 'Unknown', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed', + stdout: `output-${request.sessionId}`, + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + } + ); + + const onLog = vi.fn(); + + const deps = createMockDeps({ + getSessions: vi.fn(() => sessions), + onCueRun: onCueRun as CueEngineDeps['onCueRun'], + onLog, + }); + const engine = new CueEngine(deps); + engine.start(); + + // Flush all async hops until the chain depth guard fires + for (let i = 0; i < 15; i++) { + await vi.advanceTimersByTimeAsync(0); + } + + // The chain ran but was stopped by MAX_CHAIN_DEPTH + expect(onCueRun).toHaveBeenCalled(); + const callCount = onCueRun.mock.calls.length; + // Should be bounded — heartbeat(1) + chain hops limited by depth 10 + expect(callCount).toBeLessThanOrEqual(12); + + // Verify the chain alternated between A and B sessions + const sessionIds = onCueRun.mock.calls.map((call) => call[0].sessionId); + expect(sessionIds[0]).toBe('a'); + if (callCount > 1) expect(sessionIds[1]).toBe('b'); + + // The depth-exceeded error was logged + const errorLogs = onLog.mock.calls.filter( + (call) => call[0] === 'error' && (call[1] as string).includes('Max chain depth') + ); + expect(errorLogs.length).toBeGreaterThan(0); + + engine.stop(); + }); + + it('self-referencing subscription is bounded by MAX_CHAIN_DEPTH', async () => { + // A session watching its own completion creates a loop. + // The chain depth propagated via AgentCompletionData stops it. + const sessions = [ + createMockSession({ id: 'self', name: 'Self', cwd: '/proj/self', projectRoot: '/proj/self' }), + ]; + + const configSelf = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-self', + event: 'time.heartbeat', + enabled: true, + prompt: 'do self work', + interval_minutes: 60, + }, + { + name: 'on-self-done', + event: 'agent.completed', + enabled: true, + prompt: 'react to self', + source_session: 'Self', + }, + ], + }); + + mockLoadCueConfig.mockReturnValue(configSelf); + + let callCount = 0; + const onCueRun = vi.fn( + async (request: { + runId: string; + sessionId: string; + prompt: string; + subscriptionName: string; + event: CueEvent; + timeoutMs: number; + }) => { + callCount++; + const result: CueRunResult = { + runId: request.runId, + sessionId: request.sessionId, + sessionName: 'Self', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed', + stdout: `output-${callCount}`, + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + return result; + } + ); + + const onLog = vi.fn(); + + const deps = createMockDeps({ + getSessions: vi.fn(() => sessions), + onCueRun: onCueRun as CueEngineDeps['onCueRun'], + onLog, + }); + const engine = new CueEngine(deps); + engine.start(); + + // Flush all async hops until the chain depth guard fires + for (let i = 0; i < 15; i++) { + await vi.advanceTimersByTimeAsync(0); + } + + // All calls target the same session + const sessionIds = onCueRun.mock.calls.map((call) => call[0].sessionId); + expect(sessionIds.every((id) => id === 'self')).toBe(true); + + // First call is the heartbeat, subsequent calls are self-triggered completions + expect(onCueRun.mock.calls[0][0].subscriptionName).toBe('heartbeat-self'); + if (callCount > 1) { + expect(onCueRun.mock.calls[1][0].subscriptionName).toBe('on-self-done'); + } + + // The depth-exceeded error was logged + const errorLogs = onLog.mock.calls.filter( + (call) => call[0] === 'error' && (call[1] as string).includes('Max chain depth') + ); + expect(errorLogs.length).toBeGreaterThan(0); + + engine.stop(); + }); + + it('fan-in -> fan-out combination dispatches to all targets after all sources complete', async () => { + const sessions = [ + createMockSession({ + id: 'source-a', + name: 'SourceA', + cwd: '/proj/source-a', + projectRoot: '/proj/source-a', + }), + createMockSession({ + id: 'source-b', + name: 'SourceB', + cwd: '/proj/source-b', + projectRoot: '/proj/source-b', + }), + createMockSession({ + id: 'orchestrator', + name: 'Orchestrator', + cwd: '/proj/orch', + projectRoot: '/proj/orch', + }), + createMockSession({ + id: 'target-x', + name: 'TargetX', + cwd: '/proj/target-x', + projectRoot: '/proj/target-x', + }), + createMockSession({ + id: 'target-y', + name: 'TargetY', + cwd: '/proj/target-y', + projectRoot: '/proj/target-y', + }), + ]; + + const configOrch = createMockConfig({ + subscriptions: [ + { + name: 'fan-in-out', + event: 'agent.completed', + enabled: true, + prompt: 'orchestrate', + source_session: ['SourceA', 'SourceB'], + fan_out: ['TargetX', 'TargetY'], + }, + ], + }); + + mockLoadCueConfig.mockImplementation((projectRoot) => { + if (projectRoot === '/proj/orch') return configOrch; + return null; + }); + + const deps = createMockDeps({ getSessions: vi.fn(() => sessions) }); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // First source completes — fan-in should wait + engine.notifyAgentCompleted('source-a', { sessionName: 'SourceA', stdout: 'output-a' }); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Second source completes — fan-in should fire, then fan-out dispatches + engine.notifyAgentCompleted('source-b', { sessionName: 'SourceB', stdout: 'output-b' }); + await vi.advanceTimersByTimeAsync(0); + + // Fan-out should dispatch to both TargetX and TargetY + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'target-x', + prompt: 'orchestrate', + event: expect.objectContaining({ + type: 'agent.completed', + triggerName: 'fan-in-out', + payload: expect.objectContaining({ + fanOutIndex: 0, + }), + }), + }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'target-y', + prompt: 'orchestrate', + event: expect.objectContaining({ + type: 'agent.completed', + triggerName: 'fan-in-out', + payload: expect.objectContaining({ + fanOutIndex: 1, + }), + }), + }) + ); + + engine.stop(); + }); +}); 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 0000000000..6fc184d173 --- /dev/null +++ b/src/__tests__/main/cue/cue-reconciler.test.ts @@ -0,0 +1,422 @@ +/** + * 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); + }); + + it('does not reconcile time.scheduled subscriptions (by design)', () => { + // time.scheduled triggers re-check on their 60s interval after wake, + // so reconciliation is intentionally not needed for them. + const sessions = new Map(); + sessions.set('session-1', { + config: createConfig([ + { + name: 'daily-standup', + event: 'time.scheduled', + enabled: true, + prompt: 'run standup', + schedule_times: ['09:00'], + schedule_days: ['mon', 'tue', 'wed', 'thu', 'fri'], + }, + ]), + sessionName: 'Test', + }); + + const config = makeConfig({ + sleepStartMs: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago + wakeTimeMs: Date.now(), + sessions, + }); + + reconcileMissedTimeEvents(config); + + expect(dispatched).toHaveLength(0); + }); +}); diff --git a/src/__tests__/main/cue/cue-session-lifecycle.test.ts b/src/__tests__/main/cue/cue-session-lifecycle.test.ts new file mode 100644 index 0000000000..916c594059 --- /dev/null +++ b/src/__tests__/main/cue/cue-session-lifecycle.test.ts @@ -0,0 +1,465 @@ +/** + * Tests for CueEngine session lifecycle under active state. + * + * Tests cover: + * - removeSession clears queued events + * - removeSession clears fan-in tracker + * - removeSession with in-flight run completes cleanly + * - refreshSession during active run + * - refreshSession doesn't double-count active runs + * - teardownSession clears event queue (Fix 2 validation) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-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 cue-db +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + updateHeartbeat: vi.fn(), + getLastHeartbeat: vi.fn(() => null), + pruneCueEvents: vi.fn(), + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), +})); + +// Mock reconciler +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +describe('CueEngine session lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('removeSession clears queued events', async () => { + // Setup: max_concurrent=1, heartbeat with interval_minutes=1 + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 1, + }, + ], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + }); + mockLoadCueConfig.mockReturnValue(config); + + // First call returns a never-resolving promise (to occupy the slot) + let resolveRun: ((val: CueRunResult) => void) | null = null; + const onCueRun = vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ); + const deps = createMockDeps({ onCueRun: onCueRun as CueEngineDeps['onCueRun'] }); + const engine = new CueEngine(deps); + + engine.start(); + // Heartbeat fires immediately on start -> occupies the single slot + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Advance timer by 60s to fire another heartbeat -> goes into queue + vi.advanceTimersByTime(60 * 1000); + expect(onCueRun).toHaveBeenCalledTimes(1); // still 1 — second event is queued + + // Assert queue has 1 entry for session-1 + const queueStatus = engine.getQueueStatus(); + expect(queueStatus.get('session-1')).toBe(1); + + // Remove the session + engine.removeSession('session-1'); + + // Assert queue is now empty + const queueAfter = engine.getQueueStatus(); + expect(queueAfter.size).toBe(0); + + // Clean up: resolve the in-flight promise so the test exits cleanly + resolveRun!({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: {} as CueEvent, + status: 'completed', + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + engine.stop(); + }); + + it('removeSession clears fan-in tracker', () => { + // Setup: fan-in subscription with source_session: ['SourceA', 'SourceB'] + const config = createMockConfig({ + subscriptions: [ + { + name: 'all-done', + event: 'agent.completed', + enabled: true, + prompt: 'aggregate', + source_session: ['SourceA', 'SourceB'], + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); + + vi.clearAllMocks(); + + // Fire first completion -> fan-in waiting for SourceB + engine.notifyAgentCompleted('source-a', { sessionName: 'SourceA', stdout: 'output-a' }); + expect(deps.onCueRun).not.toHaveBeenCalled(); + + // Remove the owner session (session-1 which owns the fan-in subscription) + engine.removeSession('session-1'); + + // Fire second completion -> should NOT trigger anything since session was removed + engine.notifyAgentCompleted('source-b', { sessionName: 'SourceB', stdout: 'output-b' }); + + // Assert onCueRun was NOT called after the removal + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('removeSession with in-flight run completes cleanly', async () => { + // Setup: heartbeat subscription + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + + // Controllable promise for onCueRun + let resolveRun: ((val: CueRunResult) => void) | null = null; + const onCueRun = vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ); + const deps = createMockDeps({ onCueRun: onCueRun as CueEngineDeps['onCueRun'] }); + const engine = new CueEngine(deps); + + engine.start(); + // Heartbeat fires immediately -> occupies slot + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Remove session while run is in-flight + engine.removeSession('session-1'); + + // Resolve the in-flight promise + resolveRun!({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: {} as CueEvent, + status: 'completed', + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + // Assert no unhandled errors (test completes without throwing) + // Assert getActiveRuns returns empty after resolution + expect(engine.getActiveRuns()).toHaveLength(0); + + engine.stop(); + }); + + it('refreshSession during active run', async () => { + // Setup: heartbeat with interval_minutes=60 + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(config); + + // Track all resolve functions for controllable promises + const resolvers: ((val: CueRunResult) => void)[] = []; + const onCueRun = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + const deps = createMockDeps({ onCueRun: onCueRun as CueEngineDeps['onCueRun'] }); + const engine = new CueEngine(deps); + + engine.start(); + // First heartbeat fires immediately + expect(onCueRun).toHaveBeenCalledTimes(1); + expect(resolvers).toHaveLength(1); + + // Update config to return a new config with interval_minutes=5 + const newConfig = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work faster', + interval_minutes: 5, + }, + ], + }); + mockLoadCueConfig.mockReturnValue(newConfig); + + // Refresh the session (simulates config reload). + // The old run is still in-flight (activeRunCount=1). During initSession, + // the immediate heartbeat fire sees activeRunCount=1 >= maxConcurrent=1 + // (defaulted because session state isn't in the map yet during setup), + // so the new heartbeat goes into the queue instead of dispatching. + engine.refreshSession('session-1', '/projects/test'); + + // onCueRun is still 1 — the refresh's immediate heartbeat was queued + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Resolve the original in-flight promise — this decrements activeRunCount + // and drains the queue, dispatching the queued heartbeat + const completedResult: CueRunResult = { + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: {} as CueEvent, + status: 'completed', + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }; + resolvers[0](completedResult); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + // After the in-flight completes and drainQueue fires, the queued heartbeat dispatches + expect(onCueRun).toHaveBeenCalledTimes(2); + expect(resolvers).toHaveLength(2); + + // Now resolve the second run (drained from queue) so the slot is freed + resolvers[1](completedResult); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + // Advance time by 5 minutes -> new subscription interval fires with new config + vi.clearAllMocks(); + vi.advanceTimersByTime(5 * 60 * 1000); + expect(onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('refreshSession does not double-count active runs', async () => { + // Setup: heartbeat, max_concurrent=2, controllable onCueRun (never resolves). + // During initSession, the immediate heartbeat fire reads maxConcurrent from + // this.sessions.get(sessionId), which is not yet set (happens after the + // subscription setup loop), so it defaults to 1. With activeRunCount=1 + // from the orphaned in-flight run, the immediate fire goes into the queue. + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 60, + }, + ], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const onCueRun = vi.fn( + () => + new Promise(() => { + /* never resolves */ + }) + ); + const deps = createMockDeps({ onCueRun: onCueRun as CueEngineDeps['onCueRun'] }); + const engine = new CueEngine(deps); + + engine.start(); + // Heartbeat fires immediately -> 1 active run + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Refresh the session (tears down old timers, re-inits) + engine.refreshSession('session-1', '/projects/test'); + + // The immediate heartbeat during refresh was queued (not dispatched), + // because activeRunCount=1 and the session state isn't in the map yet + // during setupHeartbeatSubscription, so maxConcurrent defaults to 1. + expect(onCueRun).toHaveBeenCalledTimes(1); + + // The queue should have exactly 1 entry from the refresh's immediate fire + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + // Advance timer to trigger the interval heartbeat (60 min). + // Now the session state IS in the map, so max_concurrent=2 is read. + // activeRunCount=1 (orphaned) < max_concurrent=2, so it dispatches. + vi.advanceTimersByTime(60 * 60 * 1000); + + // We should have exactly 2 dispatched calls total: initial + interval + // (the queued immediate fire from refresh was drained when the interval fired + // or may remain queued depending on ordering — but no infinite loop or double-count) + expect(onCueRun.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(onCueRun.mock.calls.length).toBeLessThanOrEqual(3); + + engine.stop(); + }); + + it('teardownSession clears event queue (Fix 2 validation)', async () => { + // Setup: max_concurrent=1, heartbeat with interval_minutes=1 + const config = createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + enabled: true, + prompt: 'do work', + interval_minutes: 1, + }, + ], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, + }, + }); + mockLoadCueConfig.mockReturnValue(config); + + // Capture the watchCueYaml onChange callback + let yamlOnChange: (() => void) | null = null; + mockWatchCueYaml.mockImplementation((_projectRoot: string, onChange: () => void) => { + yamlOnChange = onChange; + return vi.fn(); + }); + + let resolveRun: ((val: CueRunResult) => void) | null = null; + const onCueRun = vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ); + const deps = createMockDeps({ onCueRun: onCueRun as CueEngineDeps['onCueRun'] }); + const engine = new CueEngine(deps); + + engine.start(); + // Heartbeat fires immediately -> occupies the single slot + expect(onCueRun).toHaveBeenCalledTimes(1); + + // Advance timer to queue events + vi.advanceTimersByTime(60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(1); + + vi.advanceTimersByTime(60 * 1000); + expect(engine.getQueueStatus().get('session-1')).toBe(2); + + // Call the onChange callback (simulates config file change -> refreshSession internally). + // refreshSession calls teardownSession which clears the queue, then initSession + // re-creates the session and fires the immediate heartbeat. Since the old in-flight + // run still occupies the slot (activeRunCount=1), the new immediate fire is queued. + expect(yamlOnChange).not.toBeNull(); + yamlOnChange!(); + + // After refresh, the old 2 queued events are cleared. The new immediate heartbeat + // goes into a fresh queue entry (1 item), not 2 items from before. + const queueAfter = engine.getQueueStatus(); + const queueCount = queueAfter.get('session-1') ?? 0; + // The old queue of 2 was cleared; at most 1 new entry from the refresh's immediate fire + expect(queueCount).toBeLessThanOrEqual(1); + + // Clean up: resolve the in-flight promise + resolveRun!({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: {} as CueEvent, + status: 'completed', + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(0); // flush microtasks + + engine.stop(); + }); +}); diff --git a/src/__tests__/main/cue/cue-sleep-prevention.test.ts b/src/__tests__/main/cue/cue-sleep-prevention.test.ts new file mode 100644 index 0000000000..beb98e0035 --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-prevention.test.ts @@ -0,0 +1,1129 @@ +/** + * Tests for Cue sleep prevention integration. + * + * Tests cover: + * - Schedule-level sleep prevention (heartbeat/scheduled subscriptions keep PC awake) + * - Run-level sleep prevention (active Cue runs keep PC awake) + * - Cleanup on teardown, removal, stop, and reset + * - Edge cases: disabled subs, agent_id mismatch, config refresh, optional callbacks + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueRunResult } from '../../../main/cue/cue-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 the database +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + pruneCueEvents: vi.fn(), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +describe('Cue Sleep Prevention', () => { + 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('schedule-level sleep prevention', () => { + it('adds schedule reason when session has heartbeat subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-sub', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('adds schedule reason when session has scheduled subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'scheduled-sub', + event: 'time.scheduled', + schedule_times: ['09:00'], + prompt: 'do stuff', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('does not add schedule reason for file.changed subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review changes', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for agent.completed subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'completion-sub', + event: 'agent.completed', + source_session: 'other-session', + prompt: 'follow up', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for github subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'pr-watcher', + event: 'github.pull_request', + prompt: 'review pr', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for task.pending subscriptions only', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'task-scanner', + event: 'task.pending', + watch: '**/*.md', + prompt: 'do tasks', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('adds schedule reason once for mixed subs (heartbeat + file.changed)', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat-sub', + event: 'time.heartbeat', + interval_minutes: 10, + prompt: 'check health', + enabled: true, + }, + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + const scheduleCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:schedule:') + ); + expect(scheduleCalls).toHaveLength(1); + expect(scheduleCalls[0][0]).toBe('cue:schedule:session-1'); + }); + + it('does not add schedule reason for disabled heartbeat subscription', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'disabled-heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: false, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('does not add schedule reason for heartbeat bound to different agent_id', () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'other-agent-heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'do stuff', + enabled: true, + agent_id: 'different-session', + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('adds separate schedule reasons for multiple sessions', () => { + const onPreventSleep = vi.fn(); + const session1 = createMockSession({ id: 'session-1', name: 'Session 1' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Session 2', + projectRoot: '/projects/test2', + }); + const deps = createMockDeps({ + onPreventSleep, + getSessions: vi.fn(() => [session1, session2]), + }); + + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-2'); + }); + + it('removes schedule reason on teardownSession via removeSession', () => { + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + engine.removeSession('session-1'); + + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('removes all schedule reasons on stop()', () => { + const onAllowSleep = vi.fn(); + const session1 = createMockSession({ id: 'session-1', name: 'Session 1' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Session 2', + projectRoot: '/projects/test2', + }); + const deps = createMockDeps({ + onAllowSleep, + getSessions: vi.fn(() => [session1, session2]), + }); + + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + engine.stop(); + + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-2'); + }); + + it('refreshSession re-adds schedule reason when config still has heartbeat', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + onAllowSleep.mockClear(); + + engine.refreshSession('session-1', '/projects/test'); + + // teardown releases, re-init re-adds + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('refreshSession cleans up reason when heartbeat removed from config', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + + // Initial config has heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + onAllowSleep.mockClear(); + + // Refreshed config has no heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + engine.refreshSession('session-1', '/projects/test'); + + // teardown releases the old reason + expect(onAllowSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + // re-init does NOT add schedule reason (no time-based subs) + expect(onPreventSleep).not.toHaveBeenCalledWith(expect.stringContaining('cue:schedule:')); + }); + + it('refreshSession adds reason when heartbeat added to config', () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + + // Initial config has no heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'file-watcher', + event: 'file.changed', + watch: '**/*.ts', + prompt: 'review', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + onPreventSleep.mockClear(); + + // Refreshed config adds heartbeat + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + engine.refreshSession('session-1', '/projects/test'); + + expect(onPreventSleep).toHaveBeenCalledWith('cue:schedule:session-1'); + }); + + it('operates normally when callbacks are not provided', () => { + const deps = createMockDeps(); // no onPreventSleep/onAllowSleep + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + + // Should not throw + expect(() => { + engine.start(); + engine.removeSession('session-1'); + engine.stop(); + }).not.toThrow(); + }); + }); + + describe('run-level sleep prevention', () => { + it('adds block reason when run starts', async () => { + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + // Advance to trigger the heartbeat (immediate fire on setup) + await vi.advanceTimersByTimeAsync(100); + + // Should have been called with a cue:run: reason + const runCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('removes block reason when run completes', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep, onAllowSleep }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + + // Let the run complete + await vi.advanceTimersByTimeAsync(100); + + // Find the run reason that was added + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runAddCalls.length).toBeGreaterThanOrEqual(1); + + const runReason = runAddCalls[0][0]; + + // Same reason should have been removed + expect(onAllowSleep).toHaveBeenCalledWith(runReason); + }); + + it('removes block reason when run fails', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(async () => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat' as const, + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'failed' as const, + stdout: '', + stderr: 'something broke', + exitCode: 1, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + expect(runAddCalls.length).toBeGreaterThanOrEqual(1); + + const runReason = runAddCalls[0][0]; + expect(onAllowSleep).toHaveBeenCalledWith(runReason); + }); + + it('removes block reason when run is manually stopped', async () => { + let resolveRun: (result: CueRunResult) => void; + const runPromise = new Promise((resolve) => { + resolveRun = resolve; + }); + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(() => runPromise), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Get the active run + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(1); + const runId = activeRuns[0].runId; + + // Stop the run + engine.stopRun(runId); + + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${runId}`); + + // Resolve the run promise to avoid hanging + resolveRun!({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + }); + + it('stopAll removes all run block reasons', async () => { + let resolveRun1: (result: CueRunResult) => void; + let resolveRun2: (result: CueRunResult) => void; + let callCount = 0; + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(() => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => { + resolveRun1 = resolve; + }); + } + return new Promise((resolve) => { + resolveRun2 = resolve; + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Trigger a second run by advancing to next heartbeat + vi.advanceTimersByTime(5 * 60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(2); + + engine.stopAll(); + + // Both run reasons should have been released + for (const run of activeRuns) { + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${run.runId}`); + } + + // Resolve promises to avoid hanging + const makeResult = (runId: string): CueRunResult => ({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + resolveRun1!(makeResult(activeRuns[0].runId)); + resolveRun2!(makeResult(activeRuns[1].runId)); + await vi.advanceTimersByTimeAsync(100); + }); + + it('engine stop (reset) removes all run block reasons', async () => { + let resolveRun: (result: CueRunResult) => void; + + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn( + () => + new Promise((resolve) => { + resolveRun = resolve; + }) + ), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 5, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + const activeRuns = engine.getActiveRuns(); + expect(activeRuns.length).toBe(1); + const runId = activeRuns[0].runId; + + engine.stop(); + + // reset() should release run reason + expect(onAllowSleep).toHaveBeenCalledWith(`cue:run:${runId}`); + + // Resolve to avoid hanging + resolveRun!({ + runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + }); + + it('multiple concurrent runs each get their own block reason', async () => { + let callCount = 0; + const resolvers: Array<(result: CueRunResult) => void> = []; + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onCueRun: vi.fn(() => { + callCount++; + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 1, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Trigger more runs + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + const runReasons = onPreventSleep.mock.calls + .filter((call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:')) + .map((call) => call[0]); + + // Each run should have a unique reason + const uniqueReasons = new Set(runReasons); + expect(uniqueReasons.size).toBe(runReasons.length); + expect(runReasons.length).toBe(3); + + // Clean up + engine.stop(); + for (const resolve of resolvers) { + resolve({ + runId: 'cleanup', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'stopped', + stdout: '', + stderr: '', + exitCode: null, + durationMs: 0, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + } + await vi.advanceTimersByTimeAsync(100); + }); + + it('run with output prompt has single add/remove pair for the main runId', async () => { + const onPreventSleep = vi.fn(); + const onAllowSleep = vi.fn(); + let callCount = 0; + const deps = createMockDeps({ + onPreventSleep, + onAllowSleep, + onCueRun: vi.fn(async (request) => ({ + runId: request.runId, + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: `output-${++callCount}`, + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 60, + prompt: 'main task', + output_prompt: 'summarize results', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Count run-level sleep calls + const runAddCalls = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + const runRemoveCalls = onAllowSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ); + + // One add (main runId) and one remove (same runId in finally) + expect(runAddCalls).toHaveLength(1); + expect(runRemoveCalls).toHaveLength(1); + expect(runAddCalls[0][0]).toBe(runRemoveCalls[0][0]); + }); + + it('queued event does not add block reason until dispatched', async () => { + let resolveRun: (result: CueRunResult) => void; + let callCount = 0; + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ + onPreventSleep, + onCueRun: vi.fn(() => { + callCount++; + if (callCount === 1) { + return new Promise((resolve) => { + resolveRun = resolve; + }); + } + return Promise.resolve({ + runId: 'run-2', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e2', + type: 'time.heartbeat' as const, + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'completed' as const, + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + }), + }); + mockLoadCueConfig.mockReturnValue( + createMockConfig({ + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, // Only 1 concurrent + queue_size: 10, + }, + subscriptions: [ + { + name: 'heartbeat', + event: 'time.heartbeat', + interval_minutes: 1, + prompt: 'check', + enabled: true, + }, + ], + }) + ); + + const engine = new CueEngine(deps); + engine.start(); + await vi.advanceTimersByTimeAsync(100); + + // Count current run-level adds + const runAddsBefore = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsBefore).toBe(1); // First run started + + // Trigger another event (will be queued since max_concurrent=1) + vi.advanceTimersByTime(60 * 1000); + await vi.advanceTimersByTimeAsync(100); + + // Queued event should NOT have added a run reason + const runAddsAfterQueue = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsAfterQueue).toBe(1); // Still just the first run + + // Now resolve the first run — queued event should be dispatched + resolveRun!({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: 'heartbeat', + event: { + id: 'e1', + type: 'time.heartbeat', + triggerName: 'heartbeat', + timestamp: new Date().toISOString(), + payload: {}, + }, + status: 'completed', + stdout: '', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + }); + await vi.advanceTimersByTimeAsync(100); + + // Now the queued event should have been dispatched and added its own reason + const runAddsAfterDrain = onPreventSleep.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].startsWith('cue:run:') + ).length; + expect(runAddsAfterDrain).toBe(2); + }); + }); +}); 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 0000000000..dc2f96471d --- /dev/null +++ b/src/__tests__/main/cue/cue-sleep-wake.test.ts @@ -0,0 +1,277 @@ +/** + * 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 } from '../../../main/cue/cue-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 } from '../../../main/cue/cue-engine'; +import { createMockDeps } from './cue-test-helpers'; + +/** Sleep-wake tests need a config with a default timer subscription */ +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, + }; +} + +describe('CueEngine sleep/wake detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + // Reset mockInitCueDb to a no-op (clearAllMocks doesn't reset mockImplementation) + mockInitCueDb.mockReset(); + 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 return gracefully without enabling (no throw) + engine.start(); + + // Should log the error and not enable the engine + expect(deps.onLog).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Failed to initialize Cue database') + ); + expect(engine.isEnabled()).toBe(false); + }); + + 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-startup.test.ts b/src/__tests__/main/cue/cue-startup.test.ts new file mode 100644 index 0000000000..f1959fc183 --- /dev/null +++ b/src/__tests__/main/cue/cue-startup.test.ts @@ -0,0 +1,530 @@ +/** + * Tests for the app.startup Cue event type. + * + * Tests cover: + * - Fires on system startup (isSystemBoot=true) + * - Does NOT fire on user feature toggle (isSystemBoot=false) + * - Deduplication on YAML hot-reload (refreshSession) + * - Does NOT re-fire on engine stop/start toggle + * - Fires again on next system boot after removeSession + * - enabled: false is respected + * - agent_id binding is respected + * - Filter matching + * - Fan-out dispatch + * - Chaining with agent.completed + * - Multiple startup subs per session + * - Multiple sessions each fire independently + * - Event payload contains reason: 'system_startup' + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-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 the database +vi.mock('../../../main/cue/cue-db', () => ({ + initCueDb: vi.fn(), + closeCueDb: vi.fn(), + pruneCueEvents: vi.fn(), + isCueDbReady: () => true, + recordCueEvent: vi.fn(), + updateCueEventStatus: vi.fn(), + updateHeartbeat: vi.fn(), + getLastHeartbeat: vi.fn(() => null), +})); + +// Mock reconciler +vi.mock('../../../main/cue/cue-reconciler', () => ({ + reconcileMissedTimeEvents: 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'; +import { createMockSession, createMockConfig, createMockDeps } from './cue-test-helpers'; + +describe('CueEngine app.startup', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockWatchCueYaml.mockReturnValue(vi.fn()); + mockCreateCueFileWatcher.mockReturnValue(vi.fn()); + mockCreateCueGitHubPoller.mockReturnValue(vi.fn()); + mockCreateCueTaskScanner.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createStartupConfig(overrides: Partial = {}): CueConfig { + return createMockConfig({ + subscriptions: [ + { + name: 'init-workspace', + event: 'app.startup', + enabled: true, + prompt: 'Set up workspace', + ...overrides, + }, + ], + }); + } + + it('fires on system startup (isSystemBoot=true)', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + subscriptionName: 'init-workspace', + prompt: 'Set up workspace', + event: expect.objectContaining({ + type: 'app.startup', + triggerName: 'init-workspace', + }), + }) + ); + + engine.stop(); + }); + + it('does NOT fire on user feature toggle (isSystemBoot=false)', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(); // No true — simulates user toggling Cue on + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('does not re-fire on refreshSession (YAML hot-reload)', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Simulate YAML hot-reload + engine.refreshSession('session-1', '/projects/test'); + + // Should still be only 1 call — deduplication prevents re-fire + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('does NOT re-fire on engine stop/start toggle', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // User toggles Cue off then on (feature toggle, not system boot) + engine.stop(); + engine.start(); // isSystemBoot defaults to false + + // Should NOT re-fire — startupFiredKeys persist across stop/start + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('fires again on next system boot after removeSession', async () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Wait for the first run to complete so concurrency slot is free + await vi.advanceTimersByTimeAsync(100); + + // Remove session — clears startup fired keys for that session + engine.removeSession('session-1'); + + // Simulate a new system boot cycle + engine.stop(); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + + engine.stop(); + }); + + it('respects enabled: false', () => { + const config = createStartupConfig({ enabled: false }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('respects agent_id binding — skips if agent_id does not match session', () => { + const config = createStartupConfig({ agent_id: 'other-session' }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + + engine.stop(); + }); + + it('fires when agent_id matches session', () => { + const config = createStartupConfig({ agent_id: 'session-1' }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('respects filter — does not fire when filter does not match', () => { + const config = createStartupConfig({ + filter: { reason: 'nonexistent_reason' }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).not.toHaveBeenCalled(); + expect(deps.onLog).toHaveBeenCalledWith('cue', expect.stringContaining('filter not matched')); + + engine.stop(); + }); + + it('fires when filter matches', () => { + const config = createStartupConfig({ + filter: { reason: 'system_startup' }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it('works with fan_out — dispatches to all targets', async () => { + const session1 = createMockSession({ id: 'session-1', name: 'Main' }); + const session2 = createMockSession({ id: 'session-2', name: 'Worker-A' }); + const session3 = createMockSession({ id: 'session-3', name: 'Worker-B' }); + + const config = createMockConfig({ + subscriptions: [ + { + name: 'fan-out-init', + event: 'app.startup', + enabled: true, + prompt: 'Initialize', + fan_out: ['Worker-A', 'Worker-B'], + }, + ], + }); + mockLoadCueConfig.mockImplementation((root) => { + return root === '/projects/test' ? config : null; + }); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2, session3]), + }); + const engine = new CueEngine(deps); + engine.start(true); + + // Fan-out dispatches to both targets + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-2' })); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-3' })); + + engine.stop(); + }); + + it('chains with agent.completed', async () => { + const session1 = createMockSession({ id: 'session-1', name: 'Initializer' }); + const session2 = createMockSession({ + id: 'session-2', + name: 'Post-Init', + projectRoot: '/projects/test2', + }); + + const config1 = createMockConfig({ + subscriptions: [ + { + name: 'startup-trigger', + event: 'app.startup', + enabled: true, + prompt: 'Initialize workspace', + }, + ], + }); + + const config2 = createMockConfig({ + subscriptions: [ + { + name: 'post-init', + event: 'agent.completed', + enabled: true, + prompt: 'Run post-init tasks', + source_session: 'Initializer', + }, + ], + }); + + mockLoadCueConfig.mockImplementation((root) => { + if (root === '/projects/test') return config1; + if (root === '/projects/test2') return config2; + return null; + }); + + const onCueRun = vi.fn(async (request: Parameters[0]) => ({ + runId: request.runId, + sessionId: request.sessionId, + sessionName: request.sessionId === 'session-1' ? 'Initializer' : 'Post-Init', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: 'done', + stderr: '', + exitCode: 0, + durationMs: 50, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2]), + onCueRun, + }); + const engine = new CueEngine(deps); + engine.start(true); + + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + subscriptionName: 'startup-trigger', + }) + ); + + await vi.advanceTimersByTimeAsync(100); + + expect(onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-2', + subscriptionName: 'post-init', + }) + ); + + engine.stop(); + }); + + it('multiple startup subs per session each fire independently', async () => { + const config = createMockConfig({ + subscriptions: [ + { + name: 'init-deps', + event: 'app.startup', + enabled: true, + prompt: 'Install dependencies', + }, + { + name: 'init-env', + event: 'app.startup', + enabled: true, + prompt: 'Check environment', + }, + ], + settings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 2, + queue_size: 10, + }, + }); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + await vi.advanceTimersByTimeAsync(100); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ subscriptionName: 'init-deps' }) + ); + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ subscriptionName: 'init-env' }) + ); + + engine.stop(); + }); + + it('startup across multiple sessions fires independently', () => { + const session1 = createMockSession({ id: 'session-1', name: 'Agent A', projectRoot: '/proj1' }); + const session2 = createMockSession({ id: 'session-2', name: 'Agent B', projectRoot: '/proj2' }); + + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps({ + getSessions: vi.fn(() => [session1, session2]), + }); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledTimes(2); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1' })); + expect(deps.onCueRun).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-2' })); + + engine.stop(); + }); + + it('event payload contains reason: system_startup', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onCueRun).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + type: 'app.startup', + payload: expect.objectContaining({ reason: 'system_startup' }), + }), + }) + ); + + engine.stop(); + }); + + it('does not prevent system sleep via schedule reason', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const onPreventSleep = vi.fn(); + const deps = createMockDeps({ onPreventSleep }); + const engine = new CueEngine(deps); + engine.start(true); + + const scheduleCalls = onPreventSleep.mock.calls.filter( + (args: unknown[]) => + typeof args[0] === 'string' && (args[0] as string).startsWith('cue:schedule:') + ); + expect(scheduleCalls).toHaveLength(0); + + engine.stop(); + }); + + it('logs trigger message', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + engine.start(true); + + expect(deps.onLog).toHaveBeenCalledWith( + 'cue', + expect.stringContaining('"init-workspace" triggered (app.startup)') + ); + + engine.stop(); + }); + + it('does not fire when engine is not enabled', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + // Don't call start() — engine is disabled + + expect(deps.onCueRun).not.toHaveBeenCalled(); + }); + + it('isSystemBoot flag persists across stop/start — second system boot still deduplicates', () => { + const config = createStartupConfig(); + mockLoadCueConfig.mockReturnValue(config); + + const deps = createMockDeps(); + const engine = new CueEngine(deps); + + // First system boot + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(1); + + // Stop and start again as system boot — should NOT fire (keys persisted) + engine.stop(); + engine.start(true); + expect(deps.onCueRun).toHaveBeenCalledTimes(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 0000000000..1446fa6cb9 --- /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-template-variables.test.ts b/src/__tests__/main/cue/cue-template-variables.test.ts new file mode 100644 index 0000000000..123f23240e --- /dev/null +++ b/src/__tests__/main/cue/cue-template-variables.test.ts @@ -0,0 +1,167 @@ +/** + * Tests for Cue-specific template variable substitution. + * + * Validates that substituteTemplateVariables correctly handles all CUE_* prefixed + * variables for file.changed, agent.completed, task.pending, github.*, and base + * event contexts. + */ + +import { describe, it, expect } from 'vitest'; +import { + substituteTemplateVariables, + type TemplateContext, +} from '../../../shared/templateVariables'; + +function makeContext(cue: TemplateContext['cue'] = {}): TemplateContext { + return { + session: { + id: 'session-1', + name: 'Test Agent', + toolType: 'claude-code', + cwd: '/projects/test', + }, + cue, + }; +} + +describe('Cue template variable substitution', () => { + it('substitutes all file.changed variables', () => { + const ctx = makeContext({ + filePath: '/projects/test/src/app.ts', + fileName: 'app.ts', + fileDir: '/projects/test/src', + fileExt: '.ts', + fileChangeType: 'change', + }); + const template = + 'File {{CUE_FILE_PATH}} name={{CUE_FILE_NAME}} dir={{CUE_FILE_DIR}} ext={{CUE_FILE_EXT}} change={{CUE_FILE_CHANGE_TYPE}}'; + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe( + 'File /projects/test/src/app.ts name=app.ts dir=/projects/test/src ext=.ts change=change' + ); + }); + + it('substitutes all agent.completed variables', () => { + const ctx = makeContext({ + sourceSession: 'worker-1', + sourceOutput: 'Build succeeded', + sourceStatus: 'completed', + sourceExitCode: '0', + sourceDuration: '12345', + sourceTriggeredBy: 'file-watcher-sub', + }); + const template = [ + 'session={{CUE_SOURCE_SESSION}}', + 'output={{CUE_SOURCE_OUTPUT}}', + 'status={{CUE_SOURCE_STATUS}}', + 'exit={{CUE_SOURCE_EXIT_CODE}}', + 'duration={{CUE_SOURCE_DURATION}}', + 'trigger={{CUE_SOURCE_TRIGGERED_BY}}', + ].join(' '); + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe( + 'session=worker-1 output=Build succeeded status=completed exit=0 duration=12345 trigger=file-watcher-sub' + ); + }); + + it('substitutes all task.pending variables', () => { + const ctx = makeContext({ + taskFile: '/projects/test/tasks/todo.md', + taskFileName: 'todo.md', + taskFileDir: '/projects/test/tasks', + taskCount: '3', + taskList: '- [ ] task one\n- [ ] task two\n- [ ] task three', + taskContent: '# TODO\n- [ ] task one\n- [ ] task two\n- [ ] task three', + }); + const template = [ + 'file={{CUE_TASK_FILE}}', + 'name={{CUE_TASK_FILE_NAME}}', + 'dir={{CUE_TASK_FILE_DIR}}', + 'count={{CUE_TASK_COUNT}}', + 'list={{CUE_TASK_LIST}}', + 'content={{CUE_TASK_CONTENT}}', + ].join(' '); + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe( + 'file=/projects/test/tasks/todo.md name=todo.md dir=/projects/test/tasks count=3 list=- [ ] task one\n- [ ] task two\n- [ ] task three content=# TODO\n- [ ] task one\n- [ ] task two\n- [ ] task three' + ); + }); + + it('substitutes all github variables', () => { + const ctx = makeContext({ + ghType: 'pull_request', + ghNumber: '42', + ghTitle: 'Add feature X', + ghAuthor: 'alice', + ghUrl: 'https://github.com/owner/repo/pull/42', + ghBody: 'This PR adds feature X', + ghLabels: 'enhancement,priority', + ghState: 'open', + ghRepo: 'owner/repo', + ghBranch: 'feature-x', + ghBaseBranch: 'main', + ghAssignees: 'bob,charlie', + ghMergedAt: '2026-03-15T12:00:00Z', + }); + const template = [ + 'type={{CUE_GH_TYPE}}', + 'num={{CUE_GH_NUMBER}}', + 'title={{CUE_GH_TITLE}}', + 'author={{CUE_GH_AUTHOR}}', + 'url={{CUE_GH_URL}}', + 'body={{CUE_GH_BODY}}', + 'labels={{CUE_GH_LABELS}}', + 'state={{CUE_GH_STATE}}', + 'repo={{CUE_GH_REPO}}', + 'branch={{CUE_GH_BRANCH}}', + 'base={{CUE_GH_BASE_BRANCH}}', + 'assignees={{CUE_GH_ASSIGNEES}}', + 'merged={{CUE_GH_MERGED_AT}}', + ].join(' '); + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe( + 'type=pull_request num=42 title=Add feature X author=alice url=https://github.com/owner/repo/pull/42 body=This PR adds feature X labels=enhancement,priority state=open repo=owner/repo branch=feature-x base=main assignees=bob,charlie merged=2026-03-15T12:00:00Z' + ); + }); + + it('substitutes base event variables', () => { + const ctx = makeContext({ + eventType: 'file.changed', + eventTimestamp: '2026-03-15T10:30:00Z', + triggerName: 'watch-src', + runId: 'abc-123-def', + }); + const template = + 'event={{CUE_EVENT_TYPE}} ts={{CUE_EVENT_TIMESTAMP}} trigger={{CUE_TRIGGER_NAME}} run={{CUE_RUN_ID}}'; + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe( + 'event=file.changed ts=2026-03-15T10:30:00Z trigger=watch-src run=abc-123-def' + ); + }); + + it('produces empty string for missing cue context fields', () => { + const ctx = makeContext({}); + const template = + 'event={{CUE_EVENT_TYPE}} file={{CUE_FILE_PATH}} session={{CUE_SOURCE_SESSION}} task={{CUE_TASK_FILE}} gh={{CUE_GH_TYPE}}'; + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe('event= file= session= task= gh='); + }); + + it('handles special characters in variable values', () => { + const ctx = makeContext({ + sourceOutput: 'Line 1\nLine "2"\nCurly {braces} and {{double}}', + }); + const template = 'output={{CUE_SOURCE_OUTPUT}}'; + const result = substituteTemplateVariables(template, ctx); + expect(result).toBe('output=Line 1\nLine "2"\nCurly {braces} and {{double}}'); + }); + + it('preserves 5000-char sourceOutput without truncation', () => { + const longOutput = 'x'.repeat(5000); + const ctx = makeContext({ sourceOutput: longOutput }); + const template = '{{CUE_SOURCE_OUTPUT}}'; + const result = substituteTemplateVariables(template, ctx); + expect(result).toHaveLength(5000); + expect(result).toBe(longOutput); + }); +}); diff --git a/src/__tests__/main/cue/cue-test-helpers.ts b/src/__tests__/main/cue/cue-test-helpers.ts new file mode 100644 index 0000000000..ddf4b0f01a --- /dev/null +++ b/src/__tests__/main/cue/cue-test-helpers.ts @@ -0,0 +1,54 @@ +/** + * Shared test factories for Cue engine tests. + * + * Provides createMockSession, createMockConfig, and createMockDeps + * used across 6+ Cue test files. Centralizes the factory functions + * to avoid duplication and ensure consistent defaults. + */ + +import { vi } from 'vitest'; +import type { CueConfig, CueEvent, CueRunResult } from '../../../main/cue/cue-types'; +import type { SessionInfo } from '../../../shared/types'; +import type { CueEngineDeps } from '../../../main/cue/cue-engine'; + +export function createMockSession(overrides: Partial = {}): SessionInfo { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + cwd: '/projects/test', + projectRoot: '/projects/test', + ...overrides, + }; +} + +export function createMockConfig(overrides: Partial = {}): CueConfig { + return { + subscriptions: [], + settings: { timeout_minutes: 30, timeout_on_fail: 'break', max_concurrent: 1, queue_size: 10 }, + ...overrides, + }; +} + +export function createMockDeps(overrides: Partial = {}): CueEngineDeps { + return { + getSessions: vi.fn(() => [createMockSession()]), + onCueRun: vi.fn(async (request: Parameters[0]) => ({ + runId: 'run-1', + sessionId: 'session-1', + sessionName: 'Test Session', + subscriptionName: request.subscriptionName, + event: request.event, + status: 'completed' as const, + stdout: 'output', + stderr: '', + exitCode: 0, + durationMs: 100, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + })), + onStopCueRun: vi.fn(() => true), + onLog: vi.fn(), + ...overrides, + }; +} 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 0000000000..112a5292e1 --- /dev/null +++ b/src/__tests__/main/cue/cue-yaml-loader.test.ts @@ -0,0 +1,1519 @@ +/** + * 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('resolves output_prompt_file to output_prompt content', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('.maestro/prompts/format-output.md')) { + return 'Format the output as markdown'; + } + return ` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Do the thing + output_prompt_file: .maestro/prompts/format-output.md + interval_minutes: 5 +`; + }); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].output_prompt).toBe('Format the output as markdown'); + expect(result!.subscriptions[0].output_prompt_file).toBe('.maestro/prompts/format-output.md'); + }); + + it('keeps inline output_prompt when both output_prompt and output_prompt_file exist', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Do the thing + output_prompt: Inline output prompt + output_prompt_file: .maestro/prompts/should-be-ignored.md + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result!.subscriptions[0].output_prompt).toBe('Inline output prompt'); + }); + + it('sets output_prompt to undefined when output_prompt_file is missing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((p: string) => { + if (String(p).endsWith('.maestro/prompts/missing.md')) { + throw new Error('ENOENT: no such file or directory'); + } + return ` +subscriptions: + - name: test-sub + event: time.heartbeat + prompt: Do the thing + output_prompt_file: .maestro/prompts/missing.md + interval_minutes: 5 +`; + }); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].output_prompt).toBeUndefined(); + }); + + 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')]) + ); + }); + + it('rejects unknown event types with a helpful message', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'typo', event: 'file.change', prompt: 'Do it', watch: 'src/**' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('unknown event type "file.change"')]) + ); + expect(result.errors[0]).toContain('Valid types:'); + }); + + it('rejects completely bogus event types', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'bogus', event: 'webhook.incoming', prompt: 'Run' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('unknown event type "webhook.incoming"')]) + ); + }); + + it('does not reject known event types as unknown', () => { + const knownTypes = [ + { event: 'time.heartbeat', interval_minutes: 5 }, + { event: 'time.scheduled', schedule_times: ['09:00'] }, + { event: 'file.changed', watch: '**/*.ts' }, + { event: 'agent.completed', source_session: 'agent-1' }, + { event: 'github.pull_request' }, + { event: 'github.issue' }, + { event: 'task.pending', watch: '*.md' }, + ]; + for (const typeConfig of knownTypes) { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', prompt: 'Run', ...typeConfig }], + }); + expect(result.errors.filter((e: string) => e.includes('unknown event type'))).toHaveLength( + 0 + ); + } + }); + + it('rejects invalid gh_state values for GitHub triggers', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'github.pull_request', prompt: 'Run', gh_state: 'invalid' }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"gh_state" must be one of')]) + ); + }); + + it('rejects gh_state "merged" for github.issue events', () => { + const result = validateCueConfig({ + subscriptions: [{ name: 'test', event: 'github.issue', prompt: 'Run', gh_state: 'merged' }], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('"merged" is only valid for github.pull_request'), + ]) + ); + }); + + it('accepts valid gh_state values for GitHub triggers', () => { + for (const ghState of ['open', 'closed', 'merged', 'all']) { + const result = validateCueConfig({ + subscriptions: [ + { name: 'test', event: 'github.pull_request', prompt: 'Run', gh_state: ghState }, + ], + }); + const ghStateErrors = result.errors.filter((e: string) => e.includes('gh_state')); + expect(ghStateErrors).toHaveLength(0); + } + }); + }); + + 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(); + }); + + it('parses gh_state from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: merged-prs + event: github.pull_request + prompt: Review merged PR + gh_state: merged +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].gh_state).toBe('merged'); + }); + + it('ignores invalid gh_state values during parsing', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-state + event: github.pull_request + prompt: Review + gh_state: invalid +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].gh_state).toBeUndefined(); + }); + + it('defaults gh_state to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: pr-watch + event: github.pull_request + prompt: Review +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].gh_state).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 label', () => { + it('parses label field from YAML', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: morning-check + event: time.heartbeat + prompt: Do morning checks + interval_minutes: 60 + label: Morning Check +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].label).toBe('Morning Check'); + }); + + it('defaults label to undefined when not specified', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: no-label + event: time.heartbeat + prompt: Do stuff + interval_minutes: 5 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].label).toBeUndefined(); + }); + + it('ignores non-string label values', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +subscriptions: + - name: bad-label + event: time.heartbeat + prompt: Do stuff + interval_minutes: 5 + label: 12345 +`); + + const result = loadCueConfig('/projects/test'); + expect(result).not.toBeNull(); + expect(result!.subscriptions[0].label).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, + }); + }); + }); + + describe('validateCueConfig — name validation', () => { + it('rejects empty string subscription name', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: '', event: 'time.heartbeat', prompt: 'Do it', interval_minutes: 5 }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('"name" is required and must be a non-empty string'), + ]) + ); + }); + + it('rejects whitespace-only subscription name', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: ' ', event: 'time.heartbeat', prompt: 'Do it', interval_minutes: 5 }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('"name" is required and must be a non-empty string'), + ]) + ); + }); + + it('rejects duplicate subscription names', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'dupe', event: 'time.heartbeat', prompt: 'First', interval_minutes: 5 }, + { name: 'dupe', event: 'file.changed', prompt: 'Second', watch: 'src/**' }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('duplicate subscription name "dupe"')]) + ); + }); + + it('accepts unique subscription names', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'sub-a', event: 'time.heartbeat', prompt: 'First', interval_minutes: 5 }, + { name: 'sub-b', event: 'file.changed', prompt: 'Second', watch: 'src/**' }, + ], + }); + // Check no name-related errors + const nameErrors = result.errors.filter( + (e: string) => e.includes('duplicate') || e.includes('"name"') + ); + expect(nameErrors).toHaveLength(0); + }); + + it('detects duplicates after trimming whitespace', () => { + const result = validateCueConfig({ + subscriptions: [ + { name: 'watcher', event: 'time.heartbeat', prompt: 'First', interval_minutes: 5 }, + { name: ' watcher ', event: 'file.changed', prompt: 'Second', watch: 'src/**' }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('duplicate subscription name "watcher"')]) + ); + }); + }); + + describe('validateCueConfig — schedule_times range validation', () => { + it('rejects schedule_times with hour out of range (25:00)', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.scheduled', + prompt: 'Do it', + schedule_times: ['25:00'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('invalid hour (0-23) or minute (0-59)')]) + ); + }); + + it('rejects schedule_times with minute out of range (12:60)', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.scheduled', + prompt: 'Do it', + schedule_times: ['12:60'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('invalid hour (0-23) or minute (0-59)')]) + ); + }); + + it('rejects schedule_times with both hour and minute out of range (99:99)', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.scheduled', + prompt: 'Do it', + schedule_times: ['99:99'], + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('invalid hour (0-23) or minute (0-59)')]) + ); + }); + + it('accepts schedule_times with valid boundary value 00:00', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.scheduled', + prompt: 'Do it', + schedule_times: ['00:00'], + }, + ], + }); + const timeErrors = result.errors.filter((e: string) => e.includes('invalid hour')); + expect(timeErrors).toHaveLength(0); + }); + + it('accepts schedule_times with valid boundary value 23:59', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.scheduled', + prompt: 'Do it', + schedule_times: ['23:59'], + }, + ], + }); + const timeErrors = result.errors.filter((e: string) => e.includes('invalid hour')); + expect(timeErrors).toHaveLength(0); + }); + }); + + describe('validateCueConfig — interval_minutes upper bound', () => { + it('rejects interval_minutes above 10080 (7 days)', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt: 'Do it', + interval_minutes: 10081, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining('10080')])); + }); + + it('accepts interval_minutes at upper bound (10080)', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt: 'Do it', + interval_minutes: 10080, + }, + ], + }); + const intervalErrors = result.errors.filter((e: string) => e.includes('interval_minutes')); + expect(intervalErrors).toHaveLength(0); + }); + + it('rejects NaN interval_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt: 'Do it', + interval_minutes: NaN, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('rejects Infinity interval_minutes', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt: 'Do it', + interval_minutes: Infinity, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('interval_minutes')]) + ); + }); + + it('accepts normal interval_minutes value', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'test', + event: 'time.heartbeat', + prompt: 'Do it', + interval_minutes: 60, + }, + ], + }); + const intervalErrors = result.errors.filter((e: string) => e.includes('interval_minutes')); + expect(intervalErrors).toHaveLength(0); + }); + }); + + describe('watch glob validation (Fix 6)', () => { + it('accepts valid glob pattern for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'good-glob', + event: 'file.changed', + prompt: 'test', + watch: '**/*.ts', + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('accepts valid glob pattern for task.pending', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'good-task-glob', + event: 'task.pending', + prompt: 'test', + watch: 'docs/**/*.md', + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects empty watch string for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'empty-glob', + event: 'file.changed', + prompt: 'test', + watch: '', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e: string) => e.includes('watch'))).toBe(true); + }); + + it('rejects empty watch string for task.pending', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'empty-task-glob', + event: 'task.pending', + prompt: 'test', + watch: '', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e: string) => e.includes('watch'))).toBe(true); + }); + + it('picomatch accepts unbalanced bracket pattern without throwing', () => { + // picomatch treats [*.ts as a literal — it does NOT throw + // so the try/catch validation passes it as valid + const result = validateCueConfig({ + subscriptions: [ + { + name: 'unbalanced-bracket', + event: 'file.changed', + prompt: 'test', + watch: '[*.ts', + }, + ], + }); + // picomatch does not throw on this pattern, so validation passes + expect(result.valid).toBe(true); + }); + + it('accepts complex valid glob patterns', () => { + const patterns = ['src/**/*.{ts,tsx}', '*.md', 'docs/**/README.md', '!node_modules/**']; + for (const watch of patterns) { + const result = validateCueConfig({ + subscriptions: [ + { + name: `glob-${watch.replace(/[^a-z]/gi, '')}`, + event: 'file.changed', + prompt: 'test', + watch, + }, + ], + }); + const watchErrors = result.errors.filter((e: string) => e.includes('glob pattern')); + expect(watchErrors).toHaveLength(0); + } + }); + + it('rejects non-string watch value for file.changed', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'non-string-watch', + event: 'file.changed', + prompt: 'test', + watch: 123 as unknown as string, + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e: string) => e.includes('watch'))).toBe(true); + }); + }); + + describe('loadCueConfig with filter (continued)', () => { + 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(); + }); + }); + + describe('validateCueConfig — app.startup', () => { + it('accepts a minimal app.startup subscription', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init', + event: 'app.startup', + prompt: 'Set up workspace', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts app.startup with optional filter', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-filtered', + event: 'app.startup', + prompt: 'Set up workspace', + filter: { reason: 'engine_start' }, + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts app.startup with prompt_file instead of prompt', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-file', + event: 'app.startup', + prompt_file: 'prompts/init.md', + }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects app.startup without prompt or prompt_file', () => { + const result = validateCueConfig({ + subscriptions: [ + { + name: 'init-no-prompt', + event: 'app.startup', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"prompt" or "prompt_file" is required')]) + ); + }); + + it('rejects app.startup without name', () => { + const result = validateCueConfig({ + subscriptions: [ + { + event: 'app.startup', + prompt: 'Init', + }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('"name" is required')]) + ); + }); + }); +}); diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts new file mode 100644 index 0000000000..25b373897f --- /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 fbb88905f2..c7f4678214 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/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index 02c829cc11..7de9a74148 100644 --- a/src/__tests__/main/ipc/handlers/agents.test.ts +++ b/src/__tests__/main/ipc/handlers/agents.test.ts @@ -67,6 +67,10 @@ vi.mock('../../../../main/utils/execFile', () => ({ // Mock fs vi.mock('fs', () => ({ existsSync: vi.fn(), + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + }, })); // Mock ssh-command-builder for remote model discovery tests @@ -1043,7 +1047,7 @@ describe('agents IPC handlers', () => { ], '/test/project' ); - expect(result).toEqual(['/help', '/compact', '/clear']); + expect(result).toEqual([{ name: '/help' }, { name: '/compact' }, { name: '/clear' }]); }); it('should use custom path if provided', async () => { @@ -1075,7 +1079,23 @@ describe('agents IPC handlers', () => { expect(execFileNoThrow).toHaveBeenCalledWith('/custom/claude', expect.any(Array), '/test'); }); - it('should return null for non-Claude Code agents', async () => { + it('should return null for unsupported agents', async () => { + const mockAgent = { + id: 'codex', + available: true, + path: '/usr/bin/codex', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'codex', '/test'); + + expect(result).toBeNull(); + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('should return empty array when no custom commands exist for opencode', async () => { const mockAgent = { id: 'opencode', available: true, @@ -1084,13 +1104,252 @@ describe('agents IPC handlers', () => { mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + // All disk reads return ENOENT (no custom commands) + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockRejectedValue(enoent); + const handler = handlers.get('agents:discoverSlashCommands'); const result = await handler!({} as any, 'opencode', '/test'); - expect(result).toBeNull(); + // No built-in commands — only custom .md commands are discoverable + expect(result).toEqual([]); expect(execFileNoThrow).not.toHaveBeenCalled(); }); + it('should discover opencode commands from project .opencode/commands/*.md with prompt content', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + // Project commands dir has custom .md files + vi.mocked(fs.promises.readdir).mockImplementation(async (dir) => { + if (String(dir).includes('/test/.opencode/commands')) { + return ['deploy.md', 'lint.md', 'README.txt'] as any; + } + throw enoent; + }); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + const p = String(filePath); + if (p.endsWith('deploy.md')) return 'Deploy the application to production'; + if (p.endsWith('lint.md')) return 'Run linting on the codebase'; + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + const names = result.map((c: any) => c.name); + expect(names).toContain('deploy'); + expect(names).toContain('lint'); + // Non-.md files should be ignored + expect(names).not.toContain('README.txt'); + // Custom commands should have prompt content + const deployCmd = result.find((c: any) => c.name === 'deploy'); + expect(deployCmd.prompt).toBe('Deploy the application to production'); + }); + + it('should discover opencode commands from ~/.opencode/commands/ (home directory)', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + const homeDir = require('os').homedir(); + vi.mocked(fs.promises.readdir).mockImplementation(async (dir) => { + if (String(dir) === `${homeDir}/.opencode/commands`) { + return ['octest.md'] as any; + } + throw enoent; + }); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + const p = String(filePath); + if (p.endsWith('octest.md')) return 'Report your status.'; + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + const names = result.map((c: any) => c.name); + expect(names).toContain('octest'); + const octest = result.find((c: any) => c.name === 'octest'); + expect(octest.prompt).toBe('Report your status.'); + }); + + it('should strip YAML frontmatter from command .md files', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockImplementation(async (dir) => { + if (String(dir).includes('/test/.opencode/commands')) { + return ['deploy.md'] as any; + } + throw enoent; + }); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + const p = String(filePath); + if (p.endsWith('deploy.md')) + return '---\ndescription: Deploy cmd\nagent: build\n---\n\nDeploy the app.'; + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + const deployCmd = result.find((c: any) => c.name === 'deploy'); + expect(deployCmd.prompt).toBe('Deploy the app.'); + }); + + it('should discover opencode commands from opencode.json config', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return JSON.stringify({ command: { 'my-cmd': { prompt: 'Do the thing' } } }); + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + const names = result.map((c: any) => c.name); + expect(names).toContain('my-cmd'); + // Config commands should have prompt content + const myCmd = result.find((c: any) => c.name === 'my-cmd'); + expect(myCmd.prompt).toBe('Do the thing'); + }); + + it('should ignore array values in opencode.json command property', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return JSON.stringify({ command: ['not', 'an', 'object'] }); + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + // Array config ignored, no built-ins — result should be empty + expect(result).toEqual([]); + }); + + it('should gracefully handle malformed opencode.json and return empty array', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath).includes('/test/opencode.json')) { + return '{ invalid json, }'; + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + // Malformed JSON skipped gracefully, no built-ins — empty result + expect(result).toEqual([]); + }); + + it('should honor OPENCODE_CONFIG env var for config discovery', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const originalEnv = process.env.OPENCODE_CONFIG; + process.env.OPENCODE_CONFIG = '/custom/path/opencode.json'; + + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(enoent); + vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => { + if (String(filePath) === '/custom/path/opencode.json') { + return JSON.stringify({ command: { 'env-cmd': { prompt: 'From env config' } } }); + } + throw enoent; + }); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'opencode', '/test'); + + const envCmd = result.find((c: any) => c.name === 'env-cmd'); + expect(envCmd).toBeDefined(); + expect(envCmd.prompt).toBe('From env config'); + + // Restore env + if (originalEnv === undefined) { + delete process.env.OPENCODE_CONFIG; + } else { + process.env.OPENCODE_CONFIG = originalEnv; + } + }); + + it('should rethrow non-ENOENT errors for opencode discovery', async () => { + const mockAgent = { + id: 'opencode', + available: true, + path: '/usr/bin/opencode', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + // Permission error (not ENOENT) + const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' }); + vi.mocked(fs.promises.readdir).mockRejectedValue(permError); + vi.mocked(fs.promises.readFile).mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + ); + + const handler = handlers.get('agents:discoverSlashCommands'); + await expect(handler!({} as any, 'opencode', '/test')).rejects.toThrow('EACCES'); + }); + it('should return null when agent is not available', async () => { mockAgentDetector.getAgent.mockResolvedValue({ id: 'claude-code', available: false }); diff --git a/src/__tests__/main/ipc/handlers/autorun.test.ts b/src/__tests__/main/ipc/handlers/autorun.test.ts index ab5dd2133d..007e800a77 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/bmad.test.ts b/src/__tests__/main/ipc/handlers/bmad.test.ts new file mode 100644 index 0000000000..5157d04b02 --- /dev/null +++ b/src/__tests__/main/ipc/handlers/bmad.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for the BMAD IPC handlers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; +import { registerBmadHandlers } from '../../../../main/ipc/handlers/bmad'; +import * as bmadManager from '../../../../main/bmad-manager'; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn(), + }, +})); + +vi.mock('../../../../main/bmad-manager', () => ({ + getBmadMetadata: vi.fn(), + getBmadPrompts: vi.fn(), + getBmadCommandBySlash: vi.fn(), + saveBmadPrompt: vi.fn(), + resetBmadPrompt: vi.fn(), + refreshBmadPrompts: vi.fn(), +})); + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('bmad IPC handlers', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + handlers = new Map(); + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler); + }); + registerBmadHandlers(); + }); + + afterEach(() => { + handlers.clear(); + }); + + it('registers all BMAD handlers', () => { + for (const channel of [ + 'bmad:getMetadata', + 'bmad:getPrompts', + 'bmad:getCommand', + 'bmad:savePrompt', + 'bmad:resetPrompt', + 'bmad:refresh', + ]) { + expect(handlers.has(channel)).toBe(true); + } + }); + + it('returns metadata from the manager', async () => { + const metadata = { + lastRefreshed: '2026-03-14T00:00:00Z', + commitSha: 'ac769b2', + sourceVersion: '6.1.0', + sourceUrl: 'https://github.com/bmad-code-org/BMAD-METHOD', + }; + vi.mocked(bmadManager.getBmadMetadata).mockResolvedValue(metadata); + + const result = await handlers.get('bmad:getMetadata')!({} as any); + + expect(result).toEqual({ success: true, metadata }); + }); + + it('returns all commands from the manager', async () => { + const commands = [ + { + id: 'help', + command: '/bmad-help', + description: 'Get help', + prompt: '# Help', + isCustom: false, + isModified: false, + }, + ]; + vi.mocked(bmadManager.getBmadPrompts).mockResolvedValue(commands); + + const result = await handlers.get('bmad:getPrompts')!({} as any); + + expect(result).toEqual({ success: true, commands }); + }); + + it('returns a command by slash command', async () => { + const command = { + id: 'help', + command: '/bmad-help', + description: 'Get help', + prompt: '# Help', + isCustom: false, + isModified: false, + }; + vi.mocked(bmadManager.getBmadCommandBySlash).mockResolvedValue(command); + + const result = await handlers.get('bmad:getCommand')!({} as any, '/bmad-help'); + + expect(bmadManager.getBmadCommandBySlash).toHaveBeenCalledWith('/bmad-help'); + expect(result).toEqual({ success: true, command }); + }); + + it('saves a prompt customization', async () => { + vi.mocked(bmadManager.saveBmadPrompt).mockResolvedValue(undefined); + + const result = await handlers.get('bmad:savePrompt')!({} as any, 'help', '# Custom'); + + expect(bmadManager.saveBmadPrompt).toHaveBeenCalledWith('help', '# Custom'); + expect(result).toEqual({ success: true }); + }); + + it('resets a prompt to its default', async () => { + vi.mocked(bmadManager.resetBmadPrompt).mockResolvedValue('# Default'); + + const result = await handlers.get('bmad:resetPrompt')!({} as any, 'help'); + + expect(bmadManager.resetBmadPrompt).toHaveBeenCalledWith('help'); + expect(result).toEqual({ success: true, prompt: '# Default' }); + }); + + it('refreshes prompts from GitHub', async () => { + const metadata = { + lastRefreshed: '2026-03-14T00:00:00Z', + commitSha: 'ac769b2', + sourceVersion: '6.1.0', + sourceUrl: 'https://github.com/bmad-code-org/BMAD-METHOD', + }; + vi.mocked(bmadManager.refreshBmadPrompts).mockResolvedValue(metadata); + + const result = await handlers.get('bmad:refresh')!({} as any); + + expect(result).toEqual({ success: true, metadata }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/feedback.test.ts b/src/__tests__/main/ipc/handlers/feedback.test.ts new file mode 100644 index 0000000000..6ec7ef5c3b --- /dev/null +++ b/src/__tests__/main/ipc/handlers/feedback.test.ts @@ -0,0 +1,466 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ipcMain } from 'electron'; + +const registeredHandlers = new Map(); +const mockProcessManager = { + write: vi.fn(), +}; + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: Function) => { + registeredHandlers.set(channel, handler); + }), + }, + app: { + isPackaged: false, + getAppPath: () => '/mock/app', + getVersion: () => '0.15.3', + }, +})); + +vi.mock('fs/promises', () => ({ + default: { + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../main/utils/cliDetection', () => ({ + isGhInstalled: vi.fn(), + setCachedGhStatus: vi.fn(), + getCachedGhStatus: vi.fn(), + getExpandedEnv: vi.fn(() => ({ PATH: '/usr/bin' })), +})); + +vi.mock('../../../../main/utils/execFile', () => ({ + execFileNoThrow: vi.fn(), +})); + +vi.mock('../../../../main/process-manager/utils/imageUtils', () => ({ + saveImageToTempFile: vi.fn(), + buildImagePromptPrefix: vi.fn((paths: string[]) => + paths.length > 0 ? `[Attached images: ${paths.join(', ')}]\n\n` : '' + ), + cleanupTempFiles: vi.fn(), +})); + +import fs from 'fs/promises'; +import { + getCachedGhStatus, + isGhInstalled, + setCachedGhStatus, +} from '../../../../main/utils/cliDetection'; +import { execFileNoThrow } from '../../../../main/utils/execFile'; +import { + cleanupTempFiles, + saveImageToTempFile, +} from '../../../../main/process-manager/utils/imageUtils'; +import { registerFeedbackHandlers } from '../../../../main/ipc/handlers/feedback'; + +describe('feedback handlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + registeredHandlers.clear(); + mockProcessManager.write.mockReset(); + registerFeedbackHandlers({ + getProcessManager: () => mockProcessManager as any, + }); + }); + + it('registers feedback handlers', () => { + expect(ipcMain.handle).toHaveBeenCalledWith('feedback:check-gh-auth', expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith('feedback:submit', expect.any(Function)); + expect(ipcMain.handle).toHaveBeenCalledWith('feedback:compose-prompt', expect.any(Function)); + }); + + it('returns cached gh auth result when available', async () => { + vi.mocked(getCachedGhStatus).mockReturnValue({ installed: true, authenticated: true }); + + const handler = registeredHandlers.get('feedback:check-gh-auth'); + const result = await handler!({}); + + expect(result).toEqual({ authenticated: true }); + expect(isGhInstalled).not.toHaveBeenCalled(); + }); + + it('creates a structured bug report issue with uploaded screenshot markdown', async () => { + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'jeffscottward', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '{}', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify({ + content: { + download_url: + 'https://raw.githubusercontent.com/jeffscottward/maestro-feedback-attachments/main/feedback/example-bug.png', + }, + }), + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'https://github.com/RunMaestro/Maestro/issues/999', + stderr: '', + } as any); + + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const handler = registeredHandlers.get('feedback:submit'); + const result = await handler!( + {}, + { + sessionId: 'session-123', + category: 'bug_report', + summary: 'Feedback modal crashes', + expectedBehavior: 'The issue should be created successfully.', + details: 'The modal closes without creating a GitHub issue.', + reproductionSteps: '1. Open Maestro\n2. Click Feedback\n3. Click Send Feedback', + additionalContext: 'Occurs on the first submit attempt.', + agentProvider: 'codex', + sshRemoteEnabled: false, + attachments: [{ name: 'bug.png', dataUrl: 'data:image/png;base64,abc123' }], + } + ); + const bodyWriteCall = vi + .mocked(fs.writeFile) + .mock.calls.find(([targetPath]) => String(targetPath).includes('maestro-feedback-body-')); + const writtenBody = String(bodyWriteCall?.[1] ?? ''); + + expect(saveImageToTempFile).not.toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalled(); + expect(writtenBody).toContain('## Summary\nFeedback modal crashes'); + expect(writtenBody).toContain('- Maestro version: 0.15.3'); + expect(writtenBody).toContain('- Install source: Dev build'); + expect(writtenBody).toContain('- Agent/provider involved: codex'); + expect(writtenBody).toContain('- SSH remote execution: Disabled'); + expect(writtenBody).toContain( + '## Steps to Reproduce\n1. Open Maestro\n2. Click Feedback\n3. Click Send Feedback' + ); + expect(writtenBody).toContain( + '## Expected Behavior\nThe issue should be created successfully.' + ); + expect(writtenBody).toContain( + '## Actual Behavior\nThe modal closes without creating a GitHub issue.' + ); + expect(writtenBody).toContain('## Additional Context\nOccurs on the first submit attempt.'); + expect(writtenBody).toContain('## Screenshots / Recordings'); + expect(execFileNoThrow).toHaveBeenLastCalledWith( + 'gh', + expect.arrayContaining([ + 'issue', + 'create', + '--title', + 'Bug: Feedback modal crashes', + '--label', + 'Maestro-feedback', + ]), + undefined, + { PATH: '/usr/bin' } + ); + expect(mockProcessManager.write).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('creates a structured feature request issue without screenshots', async () => { + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'https://github.com/RunMaestro/Maestro/issues/1000', + stderr: '', + } as any); + + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const handler = registeredHandlers.get('feedback:submit'); + const result = await handler!( + {}, + { + sessionId: 'session-123', + category: 'feature_request', + summary: 'Add a diagnostics copy action', + expectedBehavior: 'Users should be able to copy a sanitized diagnostics block.', + details: 'Issue reporting still requires manual environment gathering.', + agentProvider: 'codex', + sshRemoteEnabled: true, + } + ); + const bodyWriteCall = vi + .mocked(fs.writeFile) + .mock.calls.find(([targetPath]) => String(targetPath).includes('maestro-feedback-body-')); + const writtenBody = String(bodyWriteCall?.[1] ?? ''); + + expect(writtenBody).toContain('## Summary\nAdd a diagnostics copy action'); + expect(writtenBody).toContain( + '## Details\nIssue reporting still requires manual environment gathering.' + ); + expect(writtenBody).toContain( + '## Desired Outcome\nUsers should be able to copy a sanitized diagnostics block.' + ); + expect(writtenBody).toContain('## Screenshots / Recordings\nNot provided.'); + expect(execFileNoThrow).toHaveBeenLastCalledWith( + 'gh', + expect.arrayContaining(['--title', 'Feature: Add a diagnostics copy action']), + undefined, + { PATH: '/usr/bin' } + ); + expect(result).toEqual({ success: true }); + }); + + it('composes feedback prompts with uploaded screenshot markdown', async () => { + vi.mocked(fs.readFile).mockResolvedValue( + '# Feedback\n\n{{FEEDBACK}}\n\n{{ATTACHMENT_CONTEXT}}\n' + ); + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: 'jeffscottward', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '{}', + stderr: '', + } as any) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify({ + content: { + download_url: + 'https://raw.githubusercontent.com/jeffscottward/maestro-feedback-attachments/main/feedback/example-bug.png', + }, + }), + stderr: '', + } as any); + + const handler = registeredHandlers.get('feedback:compose-prompt'); + const result = await handler!( + {}, + { + feedbackText: 'Please include the screenshot.', + attachments: [{ name: 'bug.png', dataUrl: 'data:image/png;base64,abc123' }], + } + ); + + expect(result.prompt).toContain('Please include the screenshot.'); + expect(result.prompt).toContain( + '![bug.png](https://raw.githubusercontent.com/jeffscottward/maestro-feedback-attachments/main/feedback/example-bug.png)' + ); + expect(cleanupTempFiles).not.toHaveBeenCalled(); + }); + + describe('feedback:search-issues', () => { + it('returns empty issues for empty query', async () => { + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!({}, { query: '' }); + expect(result).toEqual({ issues: [] }); + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('returns empty issues when query has only stop words', async () => { + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!({}, { query: 'the and or but' }); + expect(result).toEqual({ issues: [] }); + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + + it('extracts keywords and runs parallel searches', async () => { + const mockIssue = { + number: 42, + title: 'Split pane layouts', + url: 'https://github.com/RunMaestro/Maestro/issues/42', + state: 'open', + labels: [{ name: 'feature' }], + createdAt: '2026-03-01T00:00:00Z', + author: { login: 'testuser' }, + }; + + vi.mocked(execFileNoThrow).mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([mockIssue]), + stderr: '', + } as any); + + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!( + {}, + { query: 'Tiled tab groups: drag-and-drop split-pane layouts with persistence' } + ); + + // Should have called gh search multiple times (keyword chunks) + expect(execFileNoThrow).toHaveBeenCalled(); + const calls = vi.mocked(execFileNoThrow).mock.calls; + for (const call of calls) { + expect(call[0]).toBe('gh'); + expect(call[1]).toContain('search'); + expect(call[1]).toContain('issues'); + expect(call[1]).toContain('--repo'); + expect(call[1]).toContain('RunMaestro/Maestro'); + } + + // Should return the issue with mapped fields + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toEqual({ + number: 42, + title: 'Split pane layouts', + url: 'https://github.com/RunMaestro/Maestro/issues/42', + state: 'open', + labels: ['feature'], + createdAt: '2026-03-01T00:00:00Z', + author: 'testuser', + commentCount: 0, + }); + }); + + it('deduplicates issues across multiple search results', async () => { + const issue1 = { + number: 10, + title: 'Issue A', + url: 'https://github.com/RunMaestro/Maestro/issues/10', + state: 'open', + labels: [], + createdAt: '2026-03-01T00:00:00Z', + author: { login: 'user1' }, + }; + const issue2 = { + number: 20, + title: 'Issue B', + url: 'https://github.com/RunMaestro/Maestro/issues/20', + state: 'closed', + labels: [], + createdAt: '2026-03-02T00:00:00Z', + author: { login: 'user2' }, + }; + + // First search returns both, second returns issue1 again + vi.mocked(execFileNoThrow) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify([issue1, issue2]), + stderr: '', + } as any) + .mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([issue1]), + stderr: '', + } as any); + + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!( + {}, + { query: 'split pane tiling drag drop layouts persistence' } + ); + + const numbers = result.issues.map((i: any) => i.number); + expect(numbers).toContain(10); + expect(numbers).toContain(20); + // No duplicates + expect(new Set(numbers).size).toBe(numbers.length); + }); + + it('handles gh search failures gracefully', async () => { + vi.mocked(execFileNoThrow).mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: 'error', + } as any); + + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!({}, { query: 'split pane layouts' }); + expect(result).toEqual({ issues: [] }); + }); + + it('handles invalid JSON from gh gracefully', async () => { + vi.mocked(execFileNoThrow).mockResolvedValue({ + exitCode: 0, + stdout: 'not valid json', + stderr: '', + } as any); + + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!({}, { query: 'split pane layouts' }); + expect(result).toEqual({ issues: [] }); + }); + + it('caps results at 10 issues', async () => { + const issues = Array.from({ length: 5 }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1}`, + url: `https://github.com/RunMaestro/Maestro/issues/${i + 1}`, + state: 'open', + labels: [], + createdAt: '2026-03-01T00:00:00Z', + author: { login: 'user' }, + })); + + // Each search returns 5 unique issues — with enough chunks this could exceed 10 + let callCount = 0; + vi.mocked(execFileNoThrow).mockImplementation(async () => { + const batch = issues.map((iss) => ({ + ...iss, + number: iss.number + callCount * 5, + title: `Issue ${iss.number + callCount * 5}`, + })); + callCount++; + return { exitCode: 0, stdout: JSON.stringify(batch), stderr: '' } as any; + }); + + const handler = registeredHandlers.get('feedback:search-issues'); + const result = await handler!( + {}, + { query: 'alpha bravo charlie delta echo foxtrot golf hotel india juliet' } + ); + + expect(result.issues.length).toBeLessThanOrEqual(10); + }); + }); + + it('revalidates gh auth when cache is empty', async () => { + vi.mocked(getCachedGhStatus).mockReturnValue(null); + vi.mocked(isGhInstalled).mockResolvedValue(true); + vi.mocked(execFileNoThrow).mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + } as any); + + const handler = registeredHandlers.get('feedback:check-gh-auth'); + const result = await handler!({}); + + expect(execFileNoThrow).toHaveBeenCalledWith('gh', ['auth', 'status'], undefined, { + PATH: '/usr/bin', + }); + expect(setCachedGhStatus).toHaveBeenCalledWith(true, true); + expect(result).toEqual({ authenticated: true }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/filesystem.test.ts b/src/__tests__/main/ipc/handlers/filesystem.test.ts index a70b487bd7..8f74ef1891 100644 --- a/src/__tests__/main/ipc/handlers/filesystem.test.ts +++ b/src/__tests__/main/ipc/handlers/filesystem.test.ts @@ -403,6 +403,100 @@ describe('filesystem handlers', () => { folderCount: 5, }); }); + + it('should respect custom ignore patterns for local directories', async () => { + const mockFs = (await import('fs/promises')).default; + + // Root has: src/ (dir), .git/ (dir), file.txt (file) + vi.mocked(mockFs.readdir).mockImplementation(async (dirPath: any) => { + if (dirPath === '/project') { + return [ + { name: 'src', isDirectory: () => true, isFile: () => false }, + { name: '.git', isDirectory: () => true, isFile: () => false }, + { name: 'file.txt', isDirectory: () => false, isFile: () => true }, + ] as any; + } + if (dirPath.includes('/src')) { + return [{ name: 'index.ts', isDirectory: () => false, isFile: () => true }] as any; + } + return []; + }); + vi.mocked(mockFs.stat).mockResolvedValue({ size: 100 } as any); + + const handler = registeredHandlers.get('fs:directorySize'); + + // Without ignore patterns — uses defaults (node_modules, __pycache__) + // .git is NOT ignored by default + const resultNoIgnore = await handler!({}, '/project'); + expect(resultNoIgnore.folderCount).toBe(2); // src + .git + expect(resultNoIgnore.fileCount).toBe(2); // file.txt + index.ts + + // With .git in ignore patterns — .git is excluded + vi.mocked(mockFs.readdir).mockImplementation(async (dirPath: any) => { + if (dirPath === '/project') { + return [ + { name: 'src', isDirectory: () => true, isFile: () => false }, + { name: '.git', isDirectory: () => true, isFile: () => false }, + { name: 'file.txt', isDirectory: () => false, isFile: () => true }, + ] as any; + } + if (dirPath.includes('/src')) { + return [{ name: 'index.ts', isDirectory: () => false, isFile: () => true }] as any; + } + return []; + }); + + const resultWithIgnore = await handler!( + {}, + '/project', + undefined, // no SSH + ['.git', 'node_modules'], // custom ignore patterns + false // no gitignore + ); + expect(resultWithIgnore.folderCount).toBe(1); // only src + expect(resultWithIgnore.fileCount).toBe(2); // file.txt + index.ts + }); + + it('should honor .gitignore when enabled', async () => { + const mockFs = (await import('fs/promises')).default; + + // .gitignore contains "dist" + vi.mocked(mockFs.readFile).mockImplementation(async (filePath: any) => { + if (typeof filePath === 'string' && filePath.endsWith('.gitignore')) { + return 'dist\n*.log\n'; + } + throw new Error('ENOENT'); + }); + + vi.mocked(mockFs.readdir).mockImplementation(async (dirPath: any) => { + if (dirPath === '/project') { + return [ + { name: 'src', isDirectory: () => true, isFile: () => false }, + { name: 'dist', isDirectory: () => true, isFile: () => false }, + { name: 'app.ts', isDirectory: () => false, isFile: () => true }, + { name: 'debug.log', isDirectory: () => false, isFile: () => true }, + ] as any; + } + if (dirPath.includes('/src')) { + return [{ name: 'index.ts', isDirectory: () => false, isFile: () => true }] as any; + } + return []; + }); + vi.mocked(mockFs.stat).mockResolvedValue({ size: 50 } as any); + + const handler = registeredHandlers.get('fs:directorySize'); + const result = await handler!( + {}, + '/project', + undefined, // no SSH + ['node_modules'], // base patterns + true // honor gitignore + ); + + // dist is ignored (from .gitignore), debug.log is ignored (from .gitignore *.log) + expect(result.folderCount).toBe(1); // only src + expect(result.fileCount).toBe(2); // app.ts + index.ts + }); }); describe('fs:fetchImageAsBase64', () => { diff --git a/src/__tests__/main/ipc/handlers/groupChat.test.ts b/src/__tests__/main/ipc/handlers/groupChat.test.ts index 4d3fffc46d..83c9bf4d40 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 e612489d77..ff1216e06f 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 add55b37c8..4e128308b5 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 29b01fefc7..3735e7aef8 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', ]; @@ -857,7 +860,7 @@ describe('process IPC handlers', () => { 'session-1', 'ls -la', '/test/dir', - 'zsh', // default shell + process.platform === 'win32' ? 'powershell' : 'zsh', // default shell {}, // shell env vars null // sshRemoteConfig (not set in this test) ); @@ -976,6 +979,180 @@ describe('process IPC handlers', () => { }); }); + 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 = { @@ -1700,4 +1877,239 @@ describe('process IPC handlers', () => { expect(spawnCall.sshStdinScript).toContain('custom-agent'); }); }); + + describe('appendSystemPrompt delivery', () => { + const mockSshRemote = { + id: 'remote-1', + name: 'Dev Server', + host: 'dev.example.com', + port: 22, + username: 'devuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + remoteEnv: {}, + }; + + it('should deliver system prompt via CLI for supported agents (local)', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + requiresPty: true, + path: '/usr/local/bin/claude', + capabilities: { + supportsAppendSystemPrompt: true, + supportsStreamJsonInput: true, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/home/user/project', + command: 'claude', + args: ['--print'], + prompt: 'Hello world', + appendSystemPrompt: 'You are Maestro system prompt content', + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + if (process.platform === 'win32') { + // Windows: uses --append-system-prompt-file with temp file + const idx = spawnCall.args.indexOf('--append-system-prompt-file'); + expect(idx).toBeGreaterThan(-1); + expect(spawnCall.args[idx + 1]).toContain('maestro-sysprompt-session-1'); + } else { + // Non-Windows: passes inline + const idx = spawnCall.args.indexOf('--append-system-prompt'); + expect(idx).toBeGreaterThan(-1); + expect(spawnCall.args[idx + 1]).toBe('You are Maestro system prompt content'); + } + // User prompt should remain clean (not embedded) + expect(spawnCall.prompt).toBe('Hello world'); + expect(spawnCall.prompt).not.toContain('Maestro system prompt'); + }); + + it('should embed system prompt in user message for unsupported agents (local)', async () => { + const mockAgent = { + id: 'codex', + name: 'Codex', + requiresPty: false, + path: '/usr/local/bin/codex', + capabilities: { + supportsAppendSystemPrompt: false, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'codex', + cwd: '/home/user/project', + command: 'codex', + args: [], + prompt: 'Fix the bug', + appendSystemPrompt: 'You are Maestro system prompt content', + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + // --append-system-prompt should NOT be in args + expect(spawnCall.args).not.toContain('--append-system-prompt'); + // System prompt should be embedded in the user prompt + expect(spawnCall.prompt).toContain('You are Maestro system prompt content'); + expect(spawnCall.prompt).toContain('Fix the bug'); + expect(spawnCall.prompt).toContain('# User Request'); + }); + + it('should use system prompt as sole content when no user prompt for unsupported agents', async () => { + const mockAgent = { + id: 'codex', + name: 'Codex', + requiresPty: false, + capabilities: { + supportsAppendSystemPrompt: false, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'codex', + cwd: '/home/user/project', + command: 'codex', + args: [], + prompt: '', // Empty prompt + appendSystemPrompt: 'You are Maestro system prompt content', + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + // System prompt should become the sole prompt + expect(spawnCall.prompt).toBe('You are Maestro system prompt content'); + }); + + it('should include --append-system-prompt in SSH remote args via finalArgs', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + requiresPty: true, + binaryName: 'claude', + capabilities: { + supportsAppendSystemPrompt: true, + supportsStreamJsonInput: true, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/home/devuser/project', + command: 'claude', + args: ['--print'], + prompt: 'Hello via SSH', + appendSystemPrompt: 'Maestro SSH system prompt', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + // Should use SSH + expect(spawnCall.command).toBe('ssh'); + // The stdin script should contain --append-system-prompt in the exec command + expect(spawnCall.sshStdinScript).toContain('--append-system-prompt'); + expect(spawnCall.sshStdinScript).toContain('Maestro SSH system prompt'); + // The user prompt should be passed via stdin passthrough (after the script) + expect(spawnCall.sshStdinScript).toContain('Hello via SSH'); + }); + + it('should embed system prompt in SSH stdin for unsupported agents', async () => { + const mockAgent = { + id: 'opencode', + name: 'OpenCode', + requiresPty: false, + binaryName: 'opencode', + capabilities: { + supportsAppendSystemPrompt: false, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'opencode', + cwd: '/home/devuser/project', + command: 'opencode', + args: [], + prompt: 'Fix the bug remotely', + appendSystemPrompt: 'Maestro SSH system prompt', + sessionSshRemoteConfig: { + enabled: true, + remoteId: 'remote-1', + }, + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + expect(spawnCall.command).toBe('ssh'); + // --append-system-prompt should NOT be in the SSH script args + expect(spawnCall.sshStdinScript).not.toContain('--append-system-prompt'); + // System prompt should be embedded in the stdin input (as part of effectivePrompt) + expect(spawnCall.sshStdinScript).toContain('Maestro SSH system prompt'); + expect(spawnCall.sshStdinScript).toContain('Fix the bug remotely'); + expect(spawnCall.sshStdinScript).toContain('# User Request'); + }); + + it('should not add --append-system-prompt when appendSystemPrompt is not provided', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + requiresPty: true, + capabilities: { + supportsAppendSystemPrompt: true, + }, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/home/user/project', + command: 'claude', + args: ['--print'], + prompt: 'Hello world', + // No appendSystemPrompt + }); + + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + expect(spawnCall.args).not.toContain('--append-system-prompt'); + expect(spawnCall.prompt).toBe('Hello world'); + }); + }); }); diff --git a/src/__tests__/main/ipc/handlers/symphony.test.ts b/src/__tests__/main/ipc/handlers/symphony.test.ts index 4f07ce826a..5266b2b8e7 100644 --- a/src/__tests__/main/ipc/handlers/symphony.test.ts +++ b/src/__tests__/main/ipc/handlers/symphony.test.ts @@ -47,6 +47,11 @@ vi.mock('../../../../main/utils/symphony-fork', () => ({ ensureForkSetup: vi.fn(), })); +// Mock cliDetection — resolveGhPath returns 'gh' so existing assertions still match +vi.mock('../../../../main/utils/cliDetection', () => ({ + resolveGhPath: vi.fn().mockResolvedValue('gh'), +})); + // Mock the logger vi.mock('../../../../main/utils/logger', () => ({ logger: { @@ -101,11 +106,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 +1087,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 +1112,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 +1132,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 +1147,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 b93836feef..46277ad76c 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/agents.test.ts b/src/__tests__/main/preload/agents.test.ts index 2722670125..f398072a27 100644 --- a/src/__tests__/main/preload/agents.test.ts +++ b/src/__tests__/main/preload/agents.test.ts @@ -91,7 +91,7 @@ describe('Agents Preload API', () => { const result = await api.get('claude-code'); - expect(mockInvoke).toHaveBeenCalledWith('agents:get', 'claude-code'); + expect(mockInvoke).toHaveBeenCalledWith('agents:get', 'claude-code', undefined); expect(result).toEqual(mockAgent); }); @@ -102,6 +102,14 @@ describe('Agents Preload API', () => { expect(result).toBeNull(); }); + + it('should invoke agents:get with SSH remote ID', async () => { + mockInvoke.mockResolvedValue({ id: 'claude-code', available: true }); + + await api.get('claude-code', 'remote-1'); + + expect(mockInvoke).toHaveBeenCalledWith('agents:get', 'claude-code', 'remote-1'); + }); }); describe('getCapabilities', () => { diff --git a/src/__tests__/main/preload/feedback.test.ts b/src/__tests__/main/preload/feedback.test.ts new file mode 100644 index 0000000000..a666d18d8b --- /dev/null +++ b/src/__tests__/main/preload/feedback.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); + +vi.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => mockInvoke(...args), + }, +})); + +import { createFeedbackApi } from '../../../main/preload/feedback'; + +describe('Feedback Preload API', () => { + let api: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + api = createFeedbackApi(); + }); + + it('invokes feedback:check-gh-auth', async () => { + mockInvoke.mockResolvedValue({ authenticated: true }); + + const result = await api.checkGhAuth(); + + expect(mockInvoke).toHaveBeenCalledWith('feedback:check-gh-auth'); + expect(result.authenticated).toBe(true); + }); + + it('invokes feedback:submit with attachments payload', async () => { + mockInvoke.mockResolvedValue({ success: true }); + const payload = { + sessionId: 'session-123', + category: 'bug_report' as const, + summary: 'Feedback modal crashes', + expectedBehavior: 'The issue should be created.', + details: 'The modal closes without creating an issue.', + reproductionSteps: '1. Open Feedback\n2. Click Send Feedback', + agentProvider: 'codex', + sshRemoteEnabled: false, + attachments: [{ name: 'bug.png', dataUrl: 'data:image/png;base64,abc123' }], + }; + + const result = await api.submit(payload); + + expect(mockInvoke).toHaveBeenCalledWith('feedback:submit', { + ...payload, + attachments: payload.attachments, + }); + expect(result.success).toBe(true); + }); + + it('invokes feedback:compose-prompt with attachments payload', async () => { + mockInvoke.mockResolvedValue({ prompt: 'rendered prompt' }); + const attachments = [{ name: 'bug.png', dataUrl: 'data:image/png;base64,abc123' }]; + + const result = await api.composePrompt('Something broke', attachments); + + expect(mockInvoke).toHaveBeenCalledWith('feedback:compose-prompt', { + feedbackText: 'Something broke', + attachments, + }); + expect(result.prompt).toBe('rendered prompt'); + }); +}); diff --git a/src/__tests__/main/preload/fs.test.ts b/src/__tests__/main/preload/fs.test.ts index 6ce83b4076..02235044bb 100644 --- a/src/__tests__/main/preload/fs.test.ts +++ b/src/__tests__/main/preload/fs.test.ts @@ -134,7 +134,30 @@ describe('Filesystem Preload API', () => { const result = await api.directorySize('/home/user/project'); - expect(mockInvoke).toHaveBeenCalledWith('fs:directorySize', '/home/user/project', undefined); + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:directorySize', + '/home/user/project', + undefined, + undefined, + undefined + ); + expect(result).toEqual(mockSize); + }); + + it('should pass ignore patterns and honorGitignore to IPC', async () => { + const mockSize = { totalSize: 5120, fileCount: 5, folderCount: 1 }; + mockInvoke.mockResolvedValue(mockSize); + + const patterns = ['.git', 'node_modules', '*.log']; + const result = await api.directorySize('/project', undefined, patterns, true); + + expect(mockInvoke).toHaveBeenCalledWith( + 'fs:directorySize', + '/project', + undefined, + patterns, + true + ); expect(result).toEqual(mockSize); }); }); diff --git a/src/__tests__/main/preload/notifications.test.ts b/src/__tests__/main/preload/notifications.test.ts index 093eb33683..4de6284b77 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 7988edeeba..d45b9f1044 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.test.ts b/src/__tests__/main/process-manager.test.ts index 33c0e85a4c..aaf75f5581 100644 --- a/src/__tests__/main/process-manager.test.ts +++ b/src/__tests__/main/process-manager.test.ts @@ -429,6 +429,195 @@ describe('process-manager.ts', () => { expect(event).toBeNull(); }); }); + + describe('kill() PTY signal handling', () => { + it('should send SIGTERM (not default SIGHUP) to PTY processes', () => { + const mockPtyKill = vi.fn(); + const mockOnExit = vi.fn(); + // Inject a fake managed process into the processes map via spawn internals + // We access the private map through the public get() to verify registration, + // but we need to inject directly for kill testing. + const processes = (processManager as any).processes as Map; + processes.set('pty-session', { + sessionId: 'pty-session', + toolType: 'terminal', + isTerminal: true, + pid: 12345, + cwd: '/tmp', + startTime: Date.now(), + ptyProcess: { + pid: 12345, + kill: mockPtyKill, + onExit: mockOnExit, + onData: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + }); + + processManager.kill('pty-session'); + + expect(mockPtyKill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should schedule SIGKILL escalation for PTY processes', () => { + vi.useFakeTimers(); + const mockPtyKill = vi.fn(); + const mockOnExit = vi.fn(); + const processes = (processManager as any).processes as Map; + processes.set('pty-session', { + sessionId: 'pty-session', + toolType: 'terminal', + isTerminal: true, + pid: 12345, + cwd: '/tmp', + startTime: Date.now(), + ptyProcess: { + pid: 12345, + kill: mockPtyKill, + onExit: mockOnExit, + onData: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + }); + + processManager.kill('pty-session'); + + // First call is SIGTERM + expect(mockPtyKill).toHaveBeenCalledTimes(1); + expect(mockPtyKill).toHaveBeenCalledWith('SIGTERM'); + + // Advance past escalation timeout (2000ms) + vi.advanceTimersByTime(2100); + + // SIGKILL should have been sent as escalation + expect(mockPtyKill).toHaveBeenCalledTimes(2); + expect(mockPtyKill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); + }); + + it('should cancel SIGKILL escalation if PTY exits on its own', () => { + vi.useFakeTimers(); + const mockPtyKill = vi.fn(); + let exitCallback: (() => void) | undefined; + const mockOnExit = vi.fn((cb: () => void) => { + exitCallback = cb; + }); + const processes = (processManager as any).processes as Map; + processes.set('pty-session', { + sessionId: 'pty-session', + toolType: 'terminal', + isTerminal: true, + pid: 12345, + cwd: '/tmp', + startTime: Date.now(), + ptyProcess: { + pid: 12345, + kill: mockPtyKill, + onExit: mockOnExit, + onData: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + }); + + processManager.kill('pty-session'); + + // Simulate PTY exiting on its own before escalation + exitCallback?.(); + + // Advance past escalation timeout + vi.advanceTimersByTime(2100); + + // Only SIGTERM should have been sent (SIGKILL cancelled) + expect(mockPtyKill).toHaveBeenCalledTimes(1); + expect(mockPtyKill).toHaveBeenCalledWith('SIGTERM'); + + vi.useRealTimers(); + }); + }); + + describe('spawn() kill-before-spawn guard', () => { + it('should kill existing process before spawning with same sessionId', () => { + const mockPtyKill = vi.fn(); + const mockOnExit = vi.fn(); + const processes = (processManager as any).processes as Map; + processes.set('dup-session', { + sessionId: 'dup-session', + toolType: 'terminal', + isTerminal: true, + pid: 11111, + cwd: '/tmp', + startTime: Date.now(), + ptyProcess: { + pid: 11111, + kill: mockPtyKill, + onExit: mockOnExit, + onData: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + }); + + // Attempting to spawn with same sessionId should kill the old one first + // spawn() will fail because node-pty is mocked, but the kill guard runs first + try { + processManager.spawn({ + sessionId: 'dup-session', + toolType: 'terminal', + cwd: '/tmp', + command: 'zsh', + args: [], + shell: 'zsh', + }); + } catch { + // spawn may fail due to mock — we only care about the kill call + } + + // The old process should have been killed with SIGTERM + expect(mockPtyKill).toHaveBeenCalledWith('SIGTERM'); + }); + }); + + describe('killAll() map safety', () => { + it('should kill all processes even when kill() deletes from the map', () => { + const kills: string[] = []; + const originalKill = processManager.kill.bind(processManager); + processManager.kill = (sessionId: string) => { + kills.push(sessionId); + return originalKill(sessionId); + }; + + // Add multiple fake processes + const processes = (processManager as any).processes as Map; + for (const id of ['a', 'b', 'c']) { + processes.set(id, { + sessionId: id, + toolType: 'terminal', + isTerminal: true, + pid: 1, + cwd: '/tmp', + startTime: Date.now(), + ptyProcess: { + pid: 1, + kill: vi.fn(), + onExit: vi.fn(), + onData: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }, + }); + } + + processManager.killAll(); + + // All three should have been killed (snapshot keys before iteration) + expect(kills).toEqual(expect.arrayContaining(['a', 'b', 'c'])); + expect(kills).toHaveLength(3); + }); + }); }); describe('data buffering', () => { 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 0000000000..1c6aadcc57 --- /dev/null +++ b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts @@ -0,0 +1,266 @@ +/** + * 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), +})); + +vi.mock('../../../../main/process-manager/utils/pathResolver', () => ({ + resolveShellPath: vi.fn((shell: string) => shell), +})); + +// ── Imports (after mocks) ────────────────────────────────────────────────── + +import { PtySpawner } from '../../../../main/process-manager/spawners/PtySpawner'; +import type { ManagedProcess, ProcessConfig } from '../../../../main/process-manager/types'; +import { resolveShellPath } from '../../../../main/process-manager/utils/pathResolver'; +import { isWindows } from '../../../../shared/platformDetection'; + +// ── 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('Windows shell resolution', () => { + it('resolves shell ID to executable via resolveShellPath', () => { + vi.mocked(isWindows).mockReturnValueOnce(true); + vi.mocked(resolveShellPath).mockReturnValueOnce('powershell.exe'); + + const { spawner } = createTestContext(); + spawner.spawn(createBaseConfig({ shell: 'powershell' })); + + expect(resolveShellPath).toHaveBeenCalledWith('powershell'); + expect(mockPtySpawn).toHaveBeenCalledWith( + 'powershell.exe', + [], + expect.objectContaining({ name: 'xterm-256color' }) + ); + }); + }); + + 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 7ae084a8a8..24c4410efc 100644 --- a/src/__tests__/main/services/symphony-runner.test.ts +++ b/src/__tests__/main/services/symphony-runner.test.ts @@ -38,6 +38,11 @@ vi.mock('../../../main/utils/symphony-fork', () => ({ ensureForkSetup: vi.fn(), })); +// Mock cliDetection — resolveGhPath returns 'gh' so existing assertions still match +vi.mock('../../../main/utils/cliDetection', () => ({ + resolveGhPath: vi.fn().mockResolvedValue('gh'), +})); + // Mock global fetch const mockFetch = vi.fn(); global.fetch = mockFetch; @@ -708,7 +713,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 +794,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 +812,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 +863,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 +917,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 +931,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 +1117,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 51ab6f4174..bc1ef483a9 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__/main/stores/utils.test.ts b/src/__tests__/main/stores/utils.test.ts index 87c7c72201..0bb0757ea9 100644 --- a/src/__tests__/main/stores/utils.test.ts +++ b/src/__tests__/main/stores/utils.test.ts @@ -240,6 +240,19 @@ describe('stores/utils', () => { }); }); + it('should default useNativeTitleBar to true on Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const result = getEarlySettings('/test/path'); + + expect(result).toEqual({ + crashReportingEnabled: true, + disableGpuAcceleration: false, + useNativeTitleBar: true, + autoHideMenuBar: false, + }); + }); + it('should not auto-disable GPU acceleration on native Linux', () => { // Mock Linux platform Object.defineProperty(process, 'platform', { value: 'linux' }); diff --git a/src/__tests__/main/utils/context-groomer.test.ts b/src/__tests__/main/utils/context-groomer.test.ts index 97960661f3..aa277b7631 100644 --- a/src/__tests__/main/utils/context-groomer.test.ts +++ b/src/__tests__/main/utils/context-groomer.test.ts @@ -509,4 +509,49 @@ describe('groomContext', () => { expect(config.cwd).toBe('/project'); expect(config.prompt).toBe('summarize this'); }); + + it('calls onProgress callback with chunk data on each data event', async () => { + const detector = createMockAgentDetector(agent); + const onProgress = vi.fn(); + + // Override spawn to emit multiple data chunks before exiting + mockPM.spawn = vi.fn((config: Record) => { + (mockPM as any)._lastSpawnConfig = config; + const sessionId = config.sessionId as string; + setTimeout(() => { + mockPM._emitData(sessionId, 'chunk1'); + mockPM._emitData(sessionId, 'chunk2'); + mockPM._emitData(sessionId, 'chunk3'); + mockPM._emitExit(sessionId, 0); + }, 10); + return { pid: 12345, success: true }; + }); + + await groomContext( + { + projectRoot: '/project', + agentType: 'claude-code', + prompt: 'summarize', + onProgress, + }, + mockPM, + detector + ); + + expect(onProgress).toHaveBeenCalledTimes(3); + // First call: 1 chunk, 6 bytes ("chunk1") + expect(onProgress.mock.calls[0][0]).toMatchObject({ + chunkCount: 1, + bytesReceived: 6, + }); + // Third call: 3 chunks, 18 bytes total + expect(onProgress.mock.calls[2][0]).toMatchObject({ + chunkCount: 3, + bytesReceived: 18, + }); + // All calls should have elapsedMs >= 0 + for (const call of onProgress.mock.calls) { + expect(call[0].elapsedMs).toBeGreaterThanOrEqual(0); + } + }); }); diff --git a/src/__tests__/main/utils/logger.test.ts b/src/__tests__/main/utils/logger.test.ts index 4e8e157aa7..60b1a12496 100644 --- a/src/__tests__/main/utils/logger.test.ts +++ b/src/__tests__/main/utils/logger.test.ts @@ -695,4 +695,482 @@ describe('Logger', () => { ]); }); }); + + describe('Log File Path', () => { + it('should return a dated log file path with local date', async () => { + const logPath = logger.getLogFilePath(); + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const expectedDateStr = `${year}-${month}-${day}`; + + expect(logPath).toContain(`maestro-debug-${expectedDateStr}.log`); + }); + + it('should include logs directory in the path', async () => { + const logPath = logger.getLogFilePath(); + // Path should end with /logs/maestro-debug-YYYY-MM-DD.log + expect(logPath).toMatch(/[/\\]logs[/\\]maestro-debug-\d{4}-\d{2}-\d{2}\.log$/); + }); + + it('should include Maestro in the path', async () => { + const logPath = logger.getLogFilePath(); + expect(logPath).toContain('Maestro'); + }); + }); + + describe('Log Rotation', () => { + it('should have rotation state fields initialized', async () => { + // The logger should have a valid current log date + const logPath = logger.getLogFilePath(); + // Path should contain today's date + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const expectedDateStr = `${year}-${month}-${day}`; + expect(logPath).toContain(`maestro-debug-${expectedDateStr}.log`); + }); + + it('should not rotate when date has not changed', async () => { + // Enable file logging to activate rotation checks + logger.enableFileLogging(); + + const initialPath = logger.getLogFilePath(); + + // Log a message - should not cause rotation since date hasn't changed + logger.info('test message'); + + expect(logger.getLogFilePath()).toBe(initialPath); + + logger.disableFileLogging(); + }); + + it('should rotate log file when date changes', async () => { + // Enable file logging + logger.enableFileLogging(); + + const initialPath = logger.getLogFilePath(); + + // Mock Date to return tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const originalDate = globalThis.Date; + const mockDate = class extends originalDate { + constructor(...args: ConstructorParameters) { + if (args.length === 0) { + super(tomorrow.getTime()); + } else { + // @ts-expect-error - spread constructor args + super(...args); + } + } + static now() { + return tomorrow.getTime(); + } + }; + // @ts-expect-error - replacing Date globally + globalThis.Date = mockDate; + + try { + // Log a message - should trigger rotation + logger.info('message after date change'); + + const newPath = logger.getLogFilePath(); + expect(newPath).not.toBe(initialPath); + + // New path should contain tomorrow's date + const year = tomorrow.getFullYear(); + const month = String(tomorrow.getMonth() + 1).padStart(2, '0'); + const day = String(tomorrow.getDate()).padStart(2, '0'); + const expectedDateStr = `${year}-${month}-${day}`; + expect(newPath).toContain(`maestro-debug-${expectedDateStr}.log`); + } finally { + globalThis.Date = originalDate; + logger.disableFileLogging(); + } + }); + }); + + describe('Legacy Log Migration', () => { + beforeEach(() => { + logger.disableFileLogging(); + }); + + it('should migrate legacy maestro-debug.log on enableFileLogging', async () => { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const platform = process.platform; + let appDataDir: string; + if (platform === 'win32') { + appDataDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + appDataDir = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + appDataDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const logsDir = path.join(appDataDir, 'Maestro', 'logs'); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // Create a legacy log file + const legacyPath = path.join(logsDir, 'maestro-debug.log'); + fs.writeFileSync(legacyPath, 'legacy log content'); + + // Use a recent past date (3 days ago) so it won't be cleaned up by cleanOldLogs + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 3); + fs.utimesSync(legacyPath, pastDate, pastDate); + + const year = pastDate.getFullYear(); + const month = String(pastDate.getMonth() + 1).padStart(2, '0'); + const day = String(pastDate.getDate()).padStart(2, '0'); + const expectedDateStr = `${year}-${month}-${day}`; + const expectedTarget = path.join(logsDir, `maestro-debug-${expectedDateStr}.log`); + + // Make sure target doesn't exist yet + try { + fs.unlinkSync(expectedTarget); + } catch { + /* ignore */ + } + + try { + logger.enableFileLogging(); + + // Legacy file should be gone (renamed) + expect(fs.existsSync(legacyPath)).toBe(false); + + // Target dated file should exist + expect(fs.existsSync(expectedTarget)).toBe(true); + + // Console should log the migration + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + `[Logger] Migrated legacy log file to maestro-debug-${expectedDateStr}.log` + ) + ); + + logger.disableFileLogging(); + } finally { + // Cleanup + for (const f of [legacyPath, expectedTarget]) { + try { + if (fs.existsSync(f)) fs.unlinkSync(f); + } catch { + // ignore + } + } + } + }); + + it('should delete legacy file if target dated file already exists', async () => { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const platform = process.platform; + let appDataDir: string; + if (platform === 'win32') { + appDataDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + appDataDir = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + appDataDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const logsDir = path.join(appDataDir, 'Maestro', 'logs'); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // Create a legacy log file with a recent past mtime (3 days ago) + const legacyPath = path.join(logsDir, 'maestro-debug.log'); + fs.writeFileSync(legacyPath, 'legacy log content'); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 3); + fs.utimesSync(legacyPath, pastDate, pastDate); + + const year = pastDate.getFullYear(); + const month = String(pastDate.getMonth() + 1).padStart(2, '0'); + const day = String(pastDate.getDate()).padStart(2, '0'); + const targetPath = path.join(logsDir, `maestro-debug-${year}-${month}-${day}.log`); + + // Pre-create the target file + fs.writeFileSync(targetPath, 'existing dated content'); + + try { + logger.enableFileLogging(); + + // Legacy file should be deleted to prevent orphans + expect(fs.existsSync(legacyPath)).toBe(false); + + // Target file should still have original content (not overwritten) + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('existing dated content'); + + logger.disableFileLogging(); + } finally { + for (const f of [legacyPath, targetPath]) { + try { + if (fs.existsSync(f)) fs.unlinkSync(f); + } catch { + // ignore + } + } + } + }); + + it('should not fail if no legacy log file exists', async () => { + // Just enable and disable - should not throw + logger.enableFileLogging(); + logger.disableFileLogging(); + }); + }); + + describe('Enable/Disable File Logging Integration', () => { + beforeEach(() => { + // Ensure logger starts disabled so enable path is actually tested + logger.disableFileLogging(); + }); + + it('should set currentLogDate and logFilePath when enabling file logging', async () => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const expectedDateStr = `${year}-${month}-${day}`; + + logger.enableFileLogging(); + + expect(logger.getLogFilePath()).toContain(`maestro-debug-${expectedDateStr}.log`); + + logger.disableFileLogging(); + }); + + it('should call cleanOldLogs during enableFileLogging', async () => { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const platform = process.platform; + let appDataDir: string; + if (platform === 'win32') { + appDataDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + appDataDir = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + appDataDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const logsDir = path.join(appDataDir, 'Maestro', 'logs'); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // Create an old log file that should be cleaned up + const oldFile = 'maestro-debug-2020-01-01.log'; + const oldFilePath = path.join(logsDir, oldFile); + fs.writeFileSync(oldFilePath, 'old content'); + + try { + logger.enableFileLogging(); + + // Old file should have been deleted by cleanOldLogs called during enable + expect(fs.existsSync(oldFilePath)).toBe(false); + + logger.disableFileLogging(); + } finally { + try { + if (fs.existsSync(oldFilePath)) fs.unlinkSync(oldFilePath); + } catch { + /* ignore */ + } + } + }); + }); + + describe('Log Cleanup (cleanOldLogs)', () => { + beforeEach(() => { + logger.disableFileLogging(); + }); + + it('should delete log files older than 7 days during rotation', async () => { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + // Determine the logs directory the logger uses + const platform = process.platform; + let appDataDir: string; + if (platform === 'win32') { + appDataDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + appDataDir = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + appDataDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const logsDir = path.join(appDataDir, 'Maestro', 'logs'); + + // Create the logs directory + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // Create some old log files (10 days ago) and a recent one (2 days ago) + const oldFile = 'maestro-debug-2020-01-01.log'; + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 2); + const recentYear = recentDate.getFullYear(); + const recentMonth = String(recentDate.getMonth() + 1).padStart(2, '0'); + const recentDay = String(recentDate.getDate()).padStart(2, '0'); + const recentFile = `maestro-debug-${recentYear}-${recentMonth}-${recentDay}.log`; + const nonMatchingFile = 'other-file.log'; + + const oldFilePath = path.join(logsDir, oldFile); + const recentFilePath = path.join(logsDir, recentFile); + const nonMatchingPath = path.join(logsDir, nonMatchingFile); + + // Write dummy content + fs.writeFileSync(oldFilePath, 'old log content'); + fs.writeFileSync(recentFilePath, 'recent log content'); + fs.writeFileSync(nonMatchingPath, 'non-matching content'); + + try { + // Enable file logging + logger.enableFileLogging(); + + // Mock Date to simulate tomorrow (triggers rotation which calls cleanOldLogs) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const originalDate = globalThis.Date; + const mockDate = class extends originalDate { + constructor(...args: ConstructorParameters) { + if (args.length === 0) { + super(tomorrow.getTime()); + } else { + // @ts-expect-error - spread constructor args + super(...args); + } + } + static now() { + return tomorrow.getTime(); + } + }; + // @ts-expect-error - replacing Date globally + globalThis.Date = mockDate; + + try { + // Trigger rotation (which calls cleanOldLogs) + logger.info('trigger rotation'); + + // Old file should be deleted + expect(fs.existsSync(oldFilePath)).toBe(false); + + // Recent file should still exist + expect(fs.existsSync(recentFilePath)).toBe(true); + + // Non-matching file should still exist + expect(fs.existsSync(nonMatchingPath)).toBe(true); + } finally { + globalThis.Date = originalDate; + logger.disableFileLogging(); + } + } finally { + // Cleanup test files + for (const f of [oldFilePath, recentFilePath, nonMatchingPath]) { + try { + if (fs.existsSync(f)) fs.unlinkSync(f); + } catch { + // ignore cleanup errors + } + } + } + }); + + it('should not delete log files that are exactly 7 days old', async () => { + const fs = await import('fs'); + const path = await import('path'); + const os = await import('os'); + + const platform = process.platform; + let appDataDir: string; + if (platform === 'win32') { + appDataDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else if (platform === 'darwin') { + appDataDir = path.join(os.homedir(), 'Library', 'Application Support'); + } else { + appDataDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const logsDir = path.join(appDataDir, 'Maestro', 'logs'); + + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + // Create a file exactly 7 days old from tomorrow's perspective (since rotation runs "tomorrow") + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); + const year = sevenDaysAgo.getFullYear(); + const month = String(sevenDaysAgo.getMonth() + 1).padStart(2, '0'); + const day = String(sevenDaysAgo.getDate()).padStart(2, '0'); + const borderlineFile = `maestro-debug-${year}-${month}-${day}.log`; + const borderlineFilePath = path.join(logsDir, borderlineFile); + + fs.writeFileSync(borderlineFilePath, 'borderline log content'); + + try { + logger.enableFileLogging(); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const originalDate = globalThis.Date; + const mockDate = class extends originalDate { + constructor(...args: ConstructorParameters) { + if (args.length === 0) { + super(tomorrow.getTime()); + } else { + // @ts-expect-error - spread constructor args + super(...args); + } + } + static now() { + return tomorrow.getTime(); + } + }; + // @ts-expect-error - replacing Date globally + globalThis.Date = mockDate; + + try { + logger.info('trigger rotation'); + + // File exactly 7 days old should NOT be deleted (only > 7) + expect(fs.existsSync(borderlineFilePath)).toBe(true); + } finally { + globalThis.Date = originalDate; + logger.disableFileLogging(); + } + } finally { + try { + if (fs.existsSync(borderlineFilePath)) fs.unlinkSync(borderlineFilePath); + } catch { + // ignore + } + } + }); + + it('should handle missing logs directory gracefully', async () => { + // This tests that cleanOldLogs doesn't throw when the directory doesn't exist + // Since cleanOldLogs is called during rotation, and rotation creates the directory, + // we just verify no errors are thrown during normal operation + logger.enableFileLogging(); + logger.info('test message'); + logger.disableFileLogging(); + // If we got here without errors, the test passes + }); + }); }); diff --git a/src/__tests__/main/utils/symphony-fork.test.ts b/src/__tests__/main/utils/symphony-fork.test.ts index 4221653125..d856697523 100644 --- a/src/__tests__/main/utils/symphony-fork.test.ts +++ b/src/__tests__/main/utils/symphony-fork.test.ts @@ -20,6 +20,10 @@ vi.mock('../../../main/agents/path-prober', () => ({ getExpandedEnv: () => ({ PATH: '/usr/bin' }), })); +vi.mock('../../../main/utils/cliDetection', () => ({ + resolveGhPath: vi.fn().mockResolvedValue('gh'), +})); + import { ensureForkSetup } from '../../../main/utils/symphony-fork'; function ok(stdout: string): ExecResult { diff --git a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts index df7ed829f5..edd157e975 100644 --- a/src/__tests__/main/web-server/handlers/messageHandlers.test.ts +++ b/src/__tests__/main/web-server/handlers/messageHandlers.test.ts @@ -14,6 +14,10 @@ * - Close tab * - Rename tab * - Subscribe to session updates + * - Open file tab + * - Refresh file tree + * - Refresh auto-run documents + * - Select session with focus (window foregrounding) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -66,6 +70,13 @@ function createMockCallbacks(): MessageHandlerCallbacks { newTab: vi.fn().mockResolvedValue({ tabId: 'new-tab-123' }), closeTab: vi.fn().mockResolvedValue(true), renameTab: vi.fn().mockResolvedValue(true), + starTab: vi.fn().mockResolvedValue(true), + reorderTab: vi.fn().mockResolvedValue(true), + toggleBookmark: vi.fn().mockResolvedValue(true), + openFileTab: vi.fn().mockResolvedValue(true), + refreshFileTree: vi.fn().mockResolvedValue(true), + refreshAutoRunDocs: vi.fn().mockResolvedValue(true), + configureAutoRun: vi.fn().mockResolvedValue({ success: true }), getSessions: vi.fn().mockReturnValue([ { id: 'session-1', @@ -73,7 +84,7 @@ function createMockCallbacks(): MessageHandlerCallbacks { toolType: 'claude-code', state: 'idle', inputMode: 'ai', - cwd: '/test', + cwd: '/home/user/project', }, ]), getLiveSessionInfo: vi.fn().mockReturnValue(undefined), @@ -283,7 +294,7 @@ describe('WebSocketMessageHandler', () => { }); await vi.waitFor(() => { - expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined); + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined, undefined); }); const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); @@ -299,7 +310,7 @@ describe('WebSocketMessageHandler', () => { }); await vi.waitFor(() => { - expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', 'tab-5'); + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', 'tab-5', undefined); }); }); @@ -536,6 +547,352 @@ describe('WebSocketMessageHandler', () => { }); }); + describe('Open File Tab (Web → Desktop)', () => { + it('should forward open file tab to desktop with sessionId and filePath', async () => { + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/home/user/project/src/index.ts', + }); + + await vi.waitFor(() => { + expect(callbacks.openFileTab).toHaveBeenCalledWith( + 'session-1', + '/home/user/project/src/index.ts' + ); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('open_file_tab_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + expect(response.filePath).toBe('/home/user/project/src/index.ts'); + }); + + it('should reject open file tab with missing sessionId', () => { + handler.handleMessage(client, { + type: 'open_file_tab', + filePath: '/home/user/project/src/index.ts', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('open_file_tab_result'); + expect(response.success).toBe(false); + expect(response.error).toContain('Missing sessionId or filePath'); + expect(callbacks.openFileTab).not.toHaveBeenCalled(); + }); + + it('should reject open file tab with missing filePath', () => { + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('open_file_tab_result'); + expect(response.success).toBe(false); + expect(response.error).toContain('Missing sessionId or filePath'); + expect(callbacks.openFileTab).not.toHaveBeenCalled(); + }); + + it('should handle open file tab callback failure', async () => { + (callbacks.openFileTab as any).mockRejectedValue(new Error('File not found')); + + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/home/user/project/nonexistent/file.ts', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('open_file_tab_result'); + expect(lastResponse.success).toBe(false); + expect(lastResponse.error).toContain('File not found'); + }); + }); + + it('should reject path traversal attempts', () => { + handler.handleMessage(client, { + type: 'open_file_tab', + sessionId: 'session-1', + filePath: '/home/user/project/../../etc/passwd', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('open_file_tab_result'); + expect(response.success).toBe(false); + expect(response.error).toContain('Invalid file path'); + expect(callbacks.openFileTab).not.toHaveBeenCalled(); + }); + }); + + describe('Refresh File Tree (Web → Desktop)', () => { + it('should forward refresh file tree to desktop', async () => { + handler.handleMessage(client, { + type: 'refresh_file_tree', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + expect(callbacks.refreshFileTree).toHaveBeenCalledWith('session-1'); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('refresh_file_tree_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + }); + + it('should reject refresh file tree with missing sessionId', () => { + handler.handleMessage(client, { + type: 'refresh_file_tree', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId'); + expect(callbacks.refreshFileTree).not.toHaveBeenCalled(); + }); + + it('should handle refresh file tree callback failure', async () => { + (callbacks.refreshFileTree as any).mockRejectedValue(new Error('Tree refresh failed')); + + handler.handleMessage(client, { + type: 'refresh_file_tree', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Tree refresh failed'); + }); + }); + }); + + describe('Refresh Auto Run Docs (Web → Desktop)', () => { + it('should forward refresh auto run docs to desktop', async () => { + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + expect(callbacks.refreshAutoRunDocs).toHaveBeenCalledWith('session-1'); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('refresh_auto_run_docs_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + }); + + it('should reject refresh auto run docs with missing sessionId', () => { + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId'); + expect(callbacks.refreshAutoRunDocs).not.toHaveBeenCalled(); + }); + + it('should handle refresh auto run docs callback failure', async () => { + (callbacks.refreshAutoRunDocs as any).mockRejectedValue(new Error('Auto-run refresh failed')); + + handler.handleMessage(client, { + type: 'refresh_auto_run_docs', + sessionId: 'session-1', + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Auto-run refresh failed'); + }); + }); + }); + + describe('Configure Auto Run (Web → Desktop)', () => { + it('should forward configure auto run with valid config', async () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'doc1.md' }, { filename: 'doc2.md', resetOnCompletion: true }], + prompt: 'Custom prompt', + loopEnabled: true, + maxLoops: 3, + launch: true, + }); + + await vi.waitFor(() => { + expect(callbacks.configureAutoRun).toHaveBeenCalledWith('session-1', { + documents: [{ filename: 'doc1.md' }, { filename: 'doc2.md', resetOnCompletion: true }], + prompt: 'Custom prompt', + loopEnabled: true, + maxLoops: 3, + saveAsPlaybook: undefined, + launch: true, + }); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(true); + expect(response.sessionId).toBe('session-1'); + }); + + it('should reject configure auto run with missing sessionId', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + documents: [{ filename: 'doc1.md' }], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('Missing sessionId'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should reject configure auto run with missing documents', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('documents'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should reject configure auto run with empty documents array', () => { + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('documents'); + expect(callbacks.configureAutoRun).not.toHaveBeenCalled(); + }); + + it('should forward configure auto run with saveAsPlaybook', async () => { + (callbacks.configureAutoRun as any).mockResolvedValue({ + success: true, + playbookId: 'pb-123', + }); + + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'doc1.md' }], + saveAsPlaybook: 'My Playbook', + }); + + await vi.waitFor(() => { + expect(callbacks.configureAutoRun).toHaveBeenCalledWith('session-1', { + documents: [{ filename: 'doc1.md' }], + prompt: undefined, + loopEnabled: undefined, + maxLoops: undefined, + saveAsPlaybook: 'My Playbook', + launch: undefined, + }); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('configure_auto_run_result'); + expect(response.success).toBe(true); + expect(response.playbookId).toBe('pb-123'); + }); + + it('should handle configure auto run callback failure', async () => { + (callbacks.configureAutoRun as any).mockRejectedValue( + new Error('Auto-run configuration failed') + ); + + handler.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'doc1.md' }], + }); + + await vi.waitFor(() => { + const calls = (client.socket.send as any).mock.calls; + const lastResponse = JSON.parse(calls[calls.length - 1][0]); + expect(lastResponse.type).toBe('error'); + expect(lastResponse.message).toContain('Auto-run configuration failed'); + }); + }); + + it('should handle missing configureAutoRun callback', () => { + const handlerNoCallbacks = new WebSocketMessageHandler(); + handlerNoCallbacks.setCallbacks({ + getSessionDetail: vi.fn(), + }); + + handlerNoCallbacks.handleMessage(client, { + type: 'configure_auto_run', + sessionId: 'session-1', + documents: [{ filename: 'doc1.md' }], + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('error'); + expect(response.message).toContain('not configured'); + }); + }); + + describe('Select Session with Focus (Web → Desktop)', () => { + it('should forward session selection with focus flag', async () => { + handler.handleMessage(client, { + type: 'select_session', + sessionId: 'session-2', + focus: true, + }); + + await vi.waitFor(() => { + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined, true); + }); + + const response = JSON.parse((client.socket.send as any).mock.calls[0][0]); + expect(response.type).toBe('select_session_result'); + expect(response.success).toBe(true); + }); + + it('should forward session selection with focus and tabId', async () => { + handler.handleMessage(client, { + type: 'select_session', + sessionId: 'session-2', + tabId: 'tab-3', + focus: true, + }); + + await vi.waitFor(() => { + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', 'tab-3', true); + }); + }); + + it('should forward session selection without focus flag', async () => { + handler.handleMessage(client, { + type: 'select_session', + sessionId: 'session-2', + }); + + await vi.waitFor(() => { + expect(callbacks.selectSession).toHaveBeenCalledWith('session-2', undefined, undefined); + }); + }); + }); + describe('Unknown Message Types', () => { it('should echo unknown message types for debugging', () => { handler.handleMessage(client, { diff --git a/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts index 98f64f41f1..bb591005fa 100644 --- a/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts +++ b/src/__tests__/main/web-server/managers/CallbackRegistry.test.ts @@ -457,7 +457,7 @@ describe('CallbackRegistry', () => { await registry.selectSession('session-10'); - expect(callback).toHaveBeenCalledWith('session-10', undefined); + expect(callback).toHaveBeenCalledWith('session-10', undefined, undefined); }); it('passes sessionId and tabId arguments to the callback', async () => { @@ -466,7 +466,7 @@ describe('CallbackRegistry', () => { await registry.selectSession('session-10', 'tab-2'); - expect(callback).toHaveBeenCalledWith('session-10', 'tab-2'); + expect(callback).toHaveBeenCalledWith('session-10', 'tab-2', undefined); }); }); diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 54e552deb7..541d0e1174 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -37,6 +37,39 @@ vi.mock('../../../main/web-server/WebServer', () => { setStarTabCallback = vi.fn(); setReorderTabCallback = vi.fn(); setToggleBookmarkCallback = vi.fn(); + setOpenFileTabCallback = vi.fn(); + setRefreshFileTreeCallback = vi.fn(); + setRefreshAutoRunDocsCallback = vi.fn(); + setConfigureAutoRunCallback = vi.fn(); + setGetAutoRunDocsCallback = vi.fn(); + setGetAutoRunDocContentCallback = vi.fn(); + setSaveAutoRunDocCallback = vi.fn(); + setStopAutoRunCallback = vi.fn(); + setGetSettingsCallback = vi.fn(); + setSetSettingCallback = vi.fn(); + setGetGroupsCallback = vi.fn(); + setCreateGroupCallback = vi.fn(); + setRenameGroupCallback = vi.fn(); + setDeleteGroupCallback = vi.fn(); + setMoveSessionToGroupCallback = vi.fn(); + setCreateSessionCallback = vi.fn(); + setDeleteSessionCallback = vi.fn(); + setRenameSessionCallback = vi.fn(); + setGetGitStatusCallback = vi.fn(); + setGetGitDiffCallback = vi.fn(); + setGetGroupChatsCallback = vi.fn(); + setStartGroupChatCallback = vi.fn(); + setGetGroupChatStateCallback = vi.fn(); + setStopGroupChatCallback = vi.fn(); + setSendGroupChatMessageCallback = vi.fn(); + setMergeContextCallback = vi.fn(); + setTransferContextCallback = vi.fn(); + setSummarizeContextCallback = vi.fn(); + setGetCueSubscriptionsCallback = vi.fn(); + setToggleCueSubscriptionCallback = vi.fn(); + setGetCueActivityCallback = vi.fn(); + setGetUsageDashboardCallback = vi.fn(); + setGetAchievementsCallback = vi.fn(); constructor(port: number, securityToken?: string) { this.port = port; @@ -337,6 +370,13 @@ describe('web-server/web-server-factory', () => { expect(server.setCloseTabCallback).toHaveBeenCalled(); expect(server.setRenameTabCallback).toHaveBeenCalled(); }); + + it('should register file and auto-run callbacks', () => { + expect(server.setOpenFileTabCallback).toHaveBeenCalled(); + expect(server.setRefreshFileTreeCallback).toHaveBeenCalled(); + expect(server.setRefreshAutoRunDocsCallback).toHaveBeenCalled(); + expect(server.setConfigureAutoRunCallback).toHaveBeenCalled(); + }); }); describe('getSessionsCallback behavior', () => { diff --git a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx index 7ebc0d7c5a..da82122536 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 37a7e22bbc..4dd6aad21d 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/AboutModal.test.tsx b/src/__tests__/renderer/components/AboutModal.test.tsx index c8d9e0d3f1..aa3230b778 100644 --- a/src/__tests__/renderer/components/AboutModal.test.tsx +++ b/src/__tests__/renderer/components/AboutModal.test.tsx @@ -17,6 +17,17 @@ vi.mock('lucide-react', () => ({ × ), + MessageSquarePlus: ({ + className, + style, + }: { + className?: string; + style?: React.CSSProperties; + }) => ( + + ✉ + + ), Wand2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( 🪄 diff --git a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx index cf594c6fd3..55ba3f53d5 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, @@ -906,8 +905,10 @@ describe('AgentSessionsModal', () => { const input = screen.getByPlaceholderText(/Search.*sessions/); // First item should be selected initially - const firstButton = screen.getByText('First').closest('button'); - expect(firstButton).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + await waitFor(() => { + const firstButton = screen.getByText('First').closest('button'); + expect(firstButton).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + }); fireEvent.keyDown(input, { key: 'ArrowDown' }); diff --git a/src/__tests__/renderer/components/AppAgentModals.test.tsx b/src/__tests__/renderer/components/AppAgentModals.test.tsx new file mode 100644 index 0000000000..e875efc50a --- /dev/null +++ b/src/__tests__/renderer/components/AppAgentModals.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppAgentModals } from '../../../renderer/components/AppModals'; +import type { Theme, Session, AgentError } from '../../../renderer/types'; +import type { + AppAgentModalsProps, + GroupChatErrorInfo, +} from '../../../renderer/components/AppModals/AppAgentModals'; + +vi.mock('../../../renderer/components/AgentErrorModal', () => ({ + AgentErrorModal: (props: any) => ( +
+ ), +})); +vi.mock('../../../renderer/components/MergeSessionModal', () => ({ + MergeSessionModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/SendToAgentModal', () => ({ + SendToAgentModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/TransferProgressModal', () => ({ + TransferProgressModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/LeaderboardRegistrationModal', () => ({ + LeaderboardRegistrationModal: (props: any) => ( +
+ ), +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +function createMockSession(overrides: Partial): Session { + return { + id: 'session-1', + name: 'Agent 1', + state: 'idle', + toolType: 'claude-code', + cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, + ...overrides, + } as Session; +} + +const defaultProps: AppAgentModalsProps = { + theme: testTheme, + sessions: [], + activeSession: null, + groupChats: [], + + // LeaderboardRegistrationModal + leaderboardRegistrationOpen: false, + onCloseLeaderboardRegistration: vi.fn(), + autoRunStats: { + cumulativeTimeMs: 0, + totalRuns: 0, + currentBadgeLevel: 0, + longestRunMs: 0, + longestRunTimestamp: 0, + }, + keyboardMasteryStats: { + totalShortcutsUsed: 0, + uniqueShortcutsUsed: 0, + shortcutUsageCounts: {}, + level: 0, + levelName: 'Novice', + progress: 0, + }, + leaderboardRegistration: null, + onSaveLeaderboardRegistration: vi.fn(), + onLeaderboardOptOut: vi.fn(), + + // AgentErrorModal (individual) + errorSession: null, + effectiveAgentError: null, + recoveryActions: [], + onDismissAgentError: vi.fn(), + + // AgentErrorModal (group chats) + groupChatError: null, + groupChatRecoveryActions: [], + onClearGroupChatError: vi.fn(), + + // MergeSessionModal + mergeSessionModalOpen: false, + onCloseMergeSession: vi.fn(), + onMerge: vi.fn(), + + // TransferProgressModal + transferState: 'idle', + transferProgress: null, + transferSourceAgent: null, + transferTargetAgent: null, + onCancelTransfer: vi.fn(), + onCompleteTransfer: vi.fn(), + + // SendToAgentModal + sendToAgentModalOpen: false, + onCloseSendToAgent: vi.fn(), + onSendToAgent: vi.fn(), +}; + +describe('AppAgentModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render any modals when all booleans/values are default', () => { + const { container } = render(); + expect(screen.queryByTestId('leaderboard-registration-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('agent-error-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('merge-session-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('transfer-progress-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('send-to-agent-modal')).not.toBeInTheDocument(); + }); + + it('renders LeaderboardRegistrationModal when leaderboardRegistrationOpen is true', () => { + render(); + expect(screen.getByTestId('leaderboard-registration-modal')).toBeInTheDocument(); + }); + + it('renders AgentErrorModal when effectiveAgentError is set', () => { + const error: AgentError = { + type: 'crash', + message: 'Test error', + recoverable: true, + }; + const errorSession = createMockSession({ id: 'err-session', toolType: 'claude-code' }); + render( + + ); + expect(screen.getByTestId('agent-error-modal')).toBeInTheDocument(); + }); + + it('renders AgentErrorModal for group chat errors when groupChatError is set', () => { + const groupChatError: GroupChatErrorInfo = { + groupChatId: 'gc-1', + participantId: 'p-1', + participantName: 'Test Agent', + error: { + type: 'crash', + message: 'Group chat error', + recoverable: true, + }, + }; + render( + + ); + const modals = screen.getAllByTestId('agent-error-modal'); + expect(modals.length).toBeGreaterThanOrEqual(1); + const groupChatModal = modals.find((m) => m.getAttribute('data-agent-name') === 'Test Agent'); + expect(groupChatModal).toBeTruthy(); + }); + + it('renders MergeSessionModal when mergeSessionModalOpen and activeSession has activeTabId', () => { + const activeSession = createMockSession({ id: 'merge-session', activeTabId: 'tab-1' }); + render( + + ); + expect(screen.getByTestId('merge-session-modal')).toBeInTheDocument(); + }); + + it('does not render MergeSessionModal when activeSession has no activeTabId', () => { + const activeSession = createMockSession({ id: 'merge-session' }); + render( + + ); + expect(screen.queryByTestId('merge-session-modal')).not.toBeInTheDocument(); + }); + + it('renders TransferProgressModal when transferState is grooming with required fields', () => { + render( + + ); + expect(screen.getByTestId('transfer-progress-modal')).toBeInTheDocument(); + }); + + it('renders SendToAgentModal when sendToAgentModalOpen and activeSession has activeTabId', () => { + const activeSession = createMockSession({ id: 'send-session', activeTabId: 'tab-1' }); + render( + + ); + expect(screen.getByTestId('send-to-agent-modal')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AppConfirmModals.test.tsx b/src/__tests__/renderer/components/AppConfirmModals.test.tsx index 6e2f2e182c..08c1080e45 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/AppGroupChatModals.test.tsx b/src/__tests__/renderer/components/AppGroupChatModals.test.tsx new file mode 100644 index 0000000000..0e24d52917 --- /dev/null +++ b/src/__tests__/renderer/components/AppGroupChatModals.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppGroupChatModals } from '../../../renderer/components/AppModals'; +import type { Theme, GroupChat } from '../../../renderer/types'; + +vi.mock('../../../renderer/components/GroupChatModal', () => ({ + GroupChatModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/DeleteGroupChatModal', () => ({ + DeleteGroupChatModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/RenameGroupChatModal', () => ({ + RenameGroupChatModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/GroupChatInfoOverlay', () => ({ + GroupChatInfoOverlay: (props: any) =>
, +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +const mockGroupChat: GroupChat = { + id: 'gc-1', + name: 'Test Group Chat', + moderatorAgentId: 'agent-1', + moderatorSessionId: 'mod-session-1', + participants: [ + { agentId: 'agent-1', sessionId: 'session-1' }, + { agentId: 'agent-2', sessionId: 'session-2' }, + ], + createdAt: Date.now(), + logPath: '/tmp/gc-1.log', + imagesDir: '/tmp/gc-1-images', +}; + +const defaultProps = { + theme: testTheme, + groupChats: [] as GroupChat[], + showNewGroupChatModal: false, + onCloseNewGroupChatModal: vi.fn(), + onCreateGroupChat: vi.fn(), + showDeleteGroupChatModal: null as string | null, + onCloseDeleteGroupChatModal: vi.fn(), + onConfirmDeleteGroupChat: vi.fn(), + showRenameGroupChatModal: null as string | null, + onCloseRenameGroupChatModal: vi.fn(), + onRenameGroupChat: vi.fn(), + showEditGroupChatModal: null as string | null, + onCloseEditGroupChatModal: vi.fn(), + onUpdateGroupChat: vi.fn(), + showGroupChatInfo: false, + activeGroupChatId: null as string | null, + groupChatMessages: [], + onCloseGroupChatInfo: vi.fn(), + onOpenModeratorSession: vi.fn(), +}; + +describe('AppGroupChatModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render any modals when all booleans are false/null', () => { + render(); + expect(screen.queryByTestId('group-chat-modal-create')).not.toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-edit')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-info-overlay')).not.toBeInTheDocument(); + }); + + it('renders create GroupChatModal when showNewGroupChatModal is true', () => { + render(); + expect(screen.getByTestId('group-chat-modal-create')).toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-edit')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-info-overlay')).not.toBeInTheDocument(); + }); + + it('renders DeleteGroupChatModal when showDeleteGroupChatModal matches a group chat', () => { + render( + + ); + expect(screen.getByTestId('delete-group-chat-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-create')).not.toBeInTheDocument(); + }); + + it('does not render DeleteGroupChatModal when group chat not found', () => { + render( + + ); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + }); + + it('renders RenameGroupChatModal when showRenameGroupChatModal matches a group chat', () => { + render( + + ); + expect(screen.getByTestId('rename-group-chat-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-create')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + }); + + it('renders edit GroupChatModal when showEditGroupChatModal matches a group chat', () => { + render( + + ); + expect(screen.getByTestId('group-chat-modal-edit')).toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-create')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-info-overlay')).not.toBeInTheDocument(); + }); + + it('renders GroupChatInfoOverlay when showGroupChatInfo and activeGroupChatId match a group chat', () => { + render( + + ); + expect(screen.getByTestId('group-chat-info-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('group-chat-modal-create')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-group-chat-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-chat-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AppGroupModals.test.tsx b/src/__tests__/renderer/components/AppGroupModals.test.tsx new file mode 100644 index 0000000000..8d0ac85aaa --- /dev/null +++ b/src/__tests__/renderer/components/AppGroupModals.test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppGroupModals } from '../../../renderer/components/AppModals'; +import type { Theme } from '../../../renderer/types'; + +vi.mock('../../../renderer/components/CreateGroupModal', () => ({ + CreateGroupModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/RenameGroupModal', () => ({ + RenameGroupModal: (props: any) =>
, +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +const defaultProps = { + theme: testTheme, + groups: [], + setGroups: vi.fn(), + createGroupModalOpen: false, + onCloseCreateGroupModal: vi.fn(), + renameGroupModalOpen: false, + renameGroupId: null, + renameGroupValue: '', + setRenameGroupValue: vi.fn(), + renameGroupEmoji: '', + setRenameGroupEmoji: vi.fn(), + onCloseRenameGroupModal: vi.fn(), +}; + +describe('AppGroupModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render any modals when all booleans are false', () => { + const { container } = render(); + expect(screen.queryByTestId('create-group-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-modal')).not.toBeInTheDocument(); + }); + + it('renders CreateGroupModal when createGroupModalOpen is true', () => { + render(); + expect(screen.getByTestId('create-group-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('rename-group-modal')).not.toBeInTheDocument(); + }); + + it('renders RenameGroupModal when renameGroupModalOpen is true and renameGroupId is set', () => { + render( + + ); + expect(screen.getByTestId('rename-group-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('create-group-modal')).not.toBeInTheDocument(); + }); + + it('does not render RenameGroupModal when renameGroupId is null', () => { + render(); + expect(screen.queryByTestId('rename-group-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AppInfoModals.test.tsx b/src/__tests__/renderer/components/AppInfoModals.test.tsx new file mode 100644 index 0000000000..8ba2a8b0a9 --- /dev/null +++ b/src/__tests__/renderer/components/AppInfoModals.test.tsx @@ -0,0 +1,175 @@ +/** + * Tests for AppInfoModals component + * + * Verifies conditional rendering of 5 info/display modals: + * - ShortcutsHelpModal (shortcutsHelpOpen) + * - AboutModal (aboutModalOpen) + * - UpdateCheckModal (updateCheckModalOpen) + * - ProcessMonitor (processMonitorOpen) - lazy loaded + * - UsageDashboardModal (usageDashboardOpen) - lazy loaded + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppInfoModals } from '../../../renderer/components/AppModals'; +import type { Theme } from '../../../renderer/types'; + +// Mock all child modal components +vi.mock('../../../renderer/components/ShortcutsHelpModal', () => ({ + ShortcutsHelpModal: (props: any) =>
, +})); + +vi.mock('../../../renderer/components/AboutModal', () => ({ + AboutModal: (props: any) =>
, +})); + +vi.mock('../../../renderer/components/UpdateCheckModal', () => ({ + UpdateCheckModal: (props: any) =>
, +})); + +vi.mock('../../../renderer/components/ProcessMonitor', () => ({ + ProcessMonitor: (props: any) =>
, +})); + +vi.mock('../../../renderer/components/UsageDashboard', () => ({ + UsageDashboardModal: (props: any) =>
, +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +const defaultProps = { + theme: testTheme, + shortcutsHelpOpen: false, + onCloseShortcutsHelp: vi.fn(), + shortcuts: {}, + tabShortcuts: {}, + hasNoAgents: false, + keyboardMasteryStats: { + totalShortcutsUsed: 0, + uniqueShortcutsUsed: 0, + shortcutUsageCounts: {}, + level: 0, + levelName: 'Novice', + progress: 0, + }, + aboutModalOpen: false, + onCloseAboutModal: vi.fn(), + autoRunStats: { + cumulativeTimeMs: 0, + totalRuns: 0, + currentBadgeLevel: 0, + longestRunMs: 0, + longestRunTimestamp: 0, + }, + usageStats: null, + handsOnTimeMs: 0, + onOpenLeaderboardRegistration: vi.fn(), + isLeaderboardRegistered: false, + leaderboardRegistration: null, + updateCheckModalOpen: false, + onCloseUpdateCheckModal: vi.fn(), + processMonitorOpen: false, + onCloseProcessMonitor: vi.fn(), + sessions: [], + groups: [], + groupChats: [], + onNavigateToSession: vi.fn(), + onNavigateToGroupChat: vi.fn(), + usageDashboardOpen: false, + onCloseUsageDashboard: vi.fn(), + defaultStatsTimeRange: undefined, + colorBlindMode: undefined, +}; + +describe('AppInfoModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not render any modals when all booleans are false', () => { + render(); + + expect(screen.queryByTestId('shortcuts-help-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('about-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('update-check-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('process-monitor')).not.toBeInTheDocument(); + expect(screen.queryByTestId('usage-dashboard')).not.toBeInTheDocument(); + }); + + it('renders ShortcutsHelpModal when shortcutsHelpOpen is true', () => { + render(); + + expect(screen.getByTestId('shortcuts-help-modal')).toBeInTheDocument(); + }); + + it('renders AboutModal when aboutModalOpen is true', () => { + render(); + + expect(screen.getByTestId('about-modal')).toBeInTheDocument(); + }); + + it('renders UpdateCheckModal when updateCheckModalOpen is true', () => { + render(); + + expect(screen.getByTestId('update-check-modal')).toBeInTheDocument(); + }); + + it('renders ProcessMonitor when processMonitorOpen is true', async () => { + render(); + + expect(await screen.findByTestId('process-monitor')).toBeInTheDocument(); + }); + + it('renders UsageDashboardModal when usageDashboardOpen is true', async () => { + render(); + + expect(await screen.findByTestId('usage-dashboard')).toBeInTheDocument(); + }); + + it('renders multiple modals simultaneously', async () => { + render( + + ); + + expect(screen.getByTestId('shortcuts-help-modal')).toBeInTheDocument(); + expect(screen.getByTestId('about-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('process-monitor')).toBeInTheDocument(); + }); + + it('does not render closed modals when others are open', () => { + render(); + + expect(screen.getByTestId('about-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('shortcuts-help-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('update-check-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('process-monitor')).not.toBeInTheDocument(); + expect(screen.queryByTestId('usage-dashboard')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx b/src/__tests__/renderer/components/AppModals-selfSourced.test.tsx index 27686728ee..e5876603a8 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/AppSessionModals.test.tsx b/src/__tests__/renderer/components/AppSessionModals.test.tsx new file mode 100644 index 0000000000..a70f988d16 --- /dev/null +++ b/src/__tests__/renderer/components/AppSessionModals.test.tsx @@ -0,0 +1,207 @@ +/** + * Tests for AppSessionModals component + * + * Focuses on modal render gating: + * - NewInstanceModal renders when newInstanceModalOpen is true + * - EditAgentModal renders when editAgentModalOpen is true + * - RenameSessionModal renders when renameSessionModalOpen is true + * - RenameTabModal renders for AI tabs (renameTabId not in terminalTabs) + * - TerminalTabRenameModal renders for terminal tabs (renameTabId in terminalTabs) + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppSessionModals } from '../../../renderer/components/AppModals'; +import type { Theme, Session } from '../../../renderer/types'; + +// Mock all child modal components +vi.mock('../../../renderer/components/NewInstanceModal', () => ({ + NewInstanceModal: (props: any) =>
, + EditAgentModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/RenameSessionModal', () => ({ + RenameSessionModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/RenameTabModal', () => ({ + RenameTabModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/TerminalTabRenameModal', () => ({ + TerminalTabRenameModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/NewAgentChoiceModal', () => ({ + NewAgentChoiceModal: (props: any) =>
, +})); +vi.mock('../../../renderer/utils/terminalTabHelpers', () => ({ + getTerminalTabDisplayName: vi.fn(() => 'Terminal 1'), +})); +vi.mock('../../../renderer/stores/modalStore', () => { + const store = { + getState: () => ({ + modals: new Map(), + openModal: vi.fn(), + closeModal: vi.fn(), + }), + subscribe: vi.fn(() => vi.fn()), + setState: vi.fn(), + destroy: vi.fn(), + }; + return { + useModalStore: Object.assign((selector: any) => selector(store.getState()), store), + selectModalOpen: (id: string) => (state: any) => state.modals.get(id)?.open ?? false, + getModalActions: () => ({ + setNewInstanceModalOpen: vi.fn(), + setDeleteAgentSession: vi.fn(), + }), + }; +}); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + name: 'Agent 1', + state: 'idle', + toolType: 'claude-code', + cwd: '/tmp', + terminalTabs: [], + activeTerminalTabId: null, + aiTabs: [], + ...overrides, + } as Session; +} + +const defaultProps = { + theme: testTheme, + sessions: [] as Session[], + activeSessionId: 'session-1', + activeSession: createMockSession(), + // NewInstanceModal + newInstanceModalOpen: false, + onCloseNewInstanceModal: vi.fn(), + onCreateSession: vi.fn(), + existingSessions: [] as Session[], + // EditAgentModal + editAgentModalOpen: false, + onCloseEditAgentModal: vi.fn(), + onSaveEditAgent: vi.fn(), + editAgentSession: null as Session | null, + // RenameSessionModal + renameSessionModalOpen: false, + renameSessionValue: '', + setRenameSessionValue: vi.fn(), + onCloseRenameSessionModal: vi.fn(), + setSessions: vi.fn(), + renameSessionTargetId: null as string | null, + // RenameTabModal + renameTabModalOpen: false, + renameTabId: null as string | null, + renameTabInitialName: '', + onCloseRenameTabModal: vi.fn(), + onRenameTab: vi.fn(), + // NewAgentChoiceModal + onOpenManualSetup: vi.fn(), + onOpenWizardSetup: vi.fn(), + wizardAvailable: true, +}; + +describe('AppSessionModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not render any modals when all booleans are false', () => { + const { container } = render(); + + expect(screen.queryByTestId('new-instance-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('edit-agent-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-session-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-tab-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('terminal-tab-rename-modal')).not.toBeInTheDocument(); + }); + + it('renders NewInstanceModal when newInstanceModalOpen is true', () => { + render(); + + expect(screen.getByTestId('new-instance-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('edit-agent-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-session-modal')).not.toBeInTheDocument(); + }); + + it('renders EditAgentModal when editAgentModalOpen is true', () => { + render(); + + expect(screen.getByTestId('edit-agent-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('new-instance-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rename-session-modal')).not.toBeInTheDocument(); + }); + + it('renders RenameSessionModal when renameSessionModalOpen is true', () => { + render(); + + expect(screen.getByTestId('rename-session-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('new-instance-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('edit-agent-modal')).not.toBeInTheDocument(); + }); + + it('renders RenameTabModal for AI tabs when renameTabModalOpen and renameTabId set', () => { + const session = createMockSession({ + terminalTabs: [], + aiTabs: [{ id: 'ai-tab-1', agentSessionId: 'agent-session-1' }] as any[], + }); + + render( + + ); + + expect(screen.getByTestId('rename-tab-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('terminal-tab-rename-modal')).not.toBeInTheDocument(); + }); + + it('renders TerminalTabRenameModal when renameTabId matches a terminal tab', () => { + const session = createMockSession({ + terminalTabs: [{ id: 'term-1', shellType: 'bash' }] as any[], + }); + + render( + + ); + + expect(screen.getByTestId('terminal-tab-rename-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('rename-tab-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AppWorktreeModals.test.tsx b/src/__tests__/renderer/components/AppWorktreeModals.test.tsx new file mode 100644 index 0000000000..6c22169189 --- /dev/null +++ b/src/__tests__/renderer/components/AppWorktreeModals.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { AppWorktreeModals } from '../../../renderer/components/AppModals'; +import type { Theme, Session } from '../../../renderer/types'; + +vi.mock('../../../renderer/components/WorktreeConfigModal', () => ({ + WorktreeConfigModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/CreateWorktreeModal', () => ({ + CreateWorktreeModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/CreatePRModal', () => ({ + CreatePRModal: (props: any) =>
, +})); +vi.mock('../../../renderer/components/DeleteWorktreeModal', () => ({ + DeleteWorktreeModal: (props: any) =>
, +})); + +const testTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1e1e1e', + bgSidebar: '#252526', + bgActivity: '#333333', + textMain: '#d4d4d4', + textDim: '#808080', + accent: '#007acc', + accentForeground: '#ffffff', + border: '#404040', + error: '#f14c4c', + warning: '#cca700', + success: '#89d185', + }, +}; + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + cwd: '/test/project', + fullPath: '/test/project', + projectRoot: '/test/project', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + inputMode: 'ai', + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: true, + gitBranches: ['main', 'feature-branch'], + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + ...overrides, + } as Session; +} + +const defaultProps = { + theme: testTheme, + activeSession: null as Session | null, + // WorktreeConfigModal + worktreeConfigModalOpen: false, + onCloseWorktreeConfigModal: vi.fn(), + onSaveWorktreeConfig: vi.fn(), + onCreateWorktreeFromConfig: vi.fn(), + onDisableWorktreeConfig: vi.fn(), + // CreateWorktreeModal + createWorktreeModalOpen: false, + createWorktreeSession: null as Session | null, + onCloseCreateWorktreeModal: vi.fn(), + onCreateWorktree: vi.fn(), + // CreatePRModal + createPRModalOpen: false, + createPRSession: null as Session | null, + onCloseCreatePRModal: vi.fn(), + onPRCreated: vi.fn(), + // DeleteWorktreeModal + deleteWorktreeModalOpen: false, + deleteWorktreeSession: null as Session | null, + onCloseDeleteWorktreeModal: vi.fn(), + onConfirmDeleteWorktree: vi.fn(), + onConfirmAndDeleteWorktreeOnDisk: vi.fn(), +}; + +describe('AppWorktreeModals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render any modals when all booleans are false', () => { + render(); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-worktree-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-pr-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-worktree-modal')).not.toBeInTheDocument(); + }); + + it('renders WorktreeConfigModal when worktreeConfigModalOpen and activeSession exist', () => { + render( + + ); + expect(screen.getByTestId('worktree-config-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('create-worktree-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-pr-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-worktree-modal')).not.toBeInTheDocument(); + }); + + it('does not render WorktreeConfigModal when activeSession is null', () => { + render( + + ); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + }); + + it('renders CreateWorktreeModal when createWorktreeModalOpen and createWorktreeSession are set', () => { + render( + + ); + expect(screen.getByTestId('create-worktree-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-pr-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-worktree-modal')).not.toBeInTheDocument(); + }); + + it('renders CreatePRModal using createPRSession when available', () => { + render( + + ); + expect(screen.getByTestId('create-pr-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-worktree-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-worktree-modal')).not.toBeInTheDocument(); + }); + + it('renders CreatePRModal falling back to activeSession when createPRSession is null', () => { + render( + + ); + expect(screen.getByTestId('create-pr-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-worktree-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('delete-worktree-modal')).not.toBeInTheDocument(); + }); + + it('renders DeleteWorktreeModal when deleteWorktreeModalOpen and deleteWorktreeSession are set', () => { + render( + + ); + expect(screen.getByTestId('delete-worktree-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('worktree-config-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-worktree-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('create-pr-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx b/src/__tests__/renderer/components/AutoRunDocumentSelector.test.tsx index 886d6f6258..68c34e786d 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/AutoRunSessionIsolation.test.tsx b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx index 6cec49599e..a16137b488 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 0000000000..f463182915 --- /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 0000000000..6b4ee2ced2 --- /dev/null +++ b/src/__tests__/renderer/components/CueModal.test.tsx @@ -0,0 +1,708 @@ +/** + * 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, +})); + +// 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 modalStore getModalActions +const mockOpenCueYamlEditor = vi.fn(); +vi.mock('../../../renderer/stores/modalStore', () => ({ + getModalActions: () => ({ + openCueYamlEditor: mockOpenCueYamlEditor, + }), +})); + +// 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 call openCueYamlEditor with sessionId and projectRoot when Edit YAML is clicked', () => { + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: [mockSession], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + fireEvent.click(screen.getByText('Edit YAML')); + + expect(mockOpenCueYamlEditor).toHaveBeenCalledOnce(); + expect(mockOpenCueYamlEditor).toHaveBeenCalledWith('sess-1', '/test/project'); + }); + }); + + 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('edge cases', () => { + it('renders without crash when status has many sessions', () => { + const manySessions = Array.from({ length: 20 }, (_, i) => ({ + ...mockSession, + sessionId: `sess-${i}`, + sessionName: `Session ${i}`, + subscriptionCount: i + 1, + activeRuns: i % 3, + })); + + mockUseCueReturn = { + ...defaultUseCueReturn, + sessions: manySessions, + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + // All 20 sessions should be rendered + for (let i = 0; i < 20; i++) { + expect(screen.getByText(`Session ${i}`)).toBeInTheDocument(); + } + }); + + it('renders activity log entries with long names', () => { + const longName = 'A'.repeat(200); + const longSubName = 'B'.repeat(200); + const longNameRun = { + ...mockCompletedRun, + runId: 'run-long', + sessionName: longName, + subscriptionName: longSubName, + }; + + mockUseCueReturn = { + ...defaultUseCueReturn, + activityLog: [longNameRun], + }; + + render(); + fireEvent.click(screen.getByText('Dashboard')); + + expect(screen.getByText(/completed in 5s/)).toBeInTheDocument(); + }); + }); + + 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/CueModal/StatusDot.test.tsx b/src/__tests__/renderer/components/CueModal/StatusDot.test.tsx new file mode 100644 index 0000000000..e89af32217 --- /dev/null +++ b/src/__tests__/renderer/components/CueModal/StatusDot.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { StatusDot, PipelineDot } from '../../../../renderer/components/CueModal/StatusDot'; +import { THEMES } from '../../../../renderer/constants/themes'; + +const darkTheme = THEMES['dracula']; +const lightTheme = THEMES['github-light']; + +describe('StatusDot', () => { + it('uses theme success color for active status', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot).toHaveStyle({ backgroundColor: darkTheme.colors.success }); + }); + + it('uses theme warning color for paused status', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot).toHaveStyle({ backgroundColor: lightTheme.colors.warning }); + }); + + it('uses theme textDim color for none status', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot).toHaveStyle({ backgroundColor: darkTheme.colors.textDim }); + }); + + it('falls back to hardcoded colors when no theme', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot).toHaveStyle({ backgroundColor: '#22c55e' }); + }); +}); + +describe('PipelineDot', () => { + it('renders with the provided color', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot).toHaveStyle({ backgroundColor: '#ef4444' }); + }); + + it('sets title attribute for tooltip', () => { + const { container } = render(); + const dot = container.firstElementChild as HTMLElement; + expect(dot.title).toBe('My Pipeline'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/PipelineCanvas.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineCanvas.test.tsx new file mode 100644 index 0000000000..d10076dbd1 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineCanvas.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { + PipelineCanvas, + PipelineCanvasProps, +} from '../../../../renderer/components/CuePipelineEditor/PipelineCanvas'; + +vi.mock('reactflow', () => { + const MockReactFlow = (props: any) =>
{props.children}
; + return { + default: MockReactFlow, + Background: () =>
, + Controls: () =>
, + MiniMap: () =>
, + ConnectionMode: { Loose: 'loose' }, + }; +}); + +vi.mock('../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode', () => ({ + TriggerNode: () =>
, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/nodes/AgentNode', () => ({ + AgentNode: () =>
, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/edges/PipelineEdge', () => ({ + edgeTypes: {}, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/drawers/TriggerDrawer', () => ({ + TriggerDrawer: ({ isOpen }: any) => + isOpen ?
TriggerDrawer
: null, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/drawers/AgentDrawer', () => ({ + AgentDrawer: ({ isOpen }: any) => + isOpen ?
AgentDrawer
: null, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/panels/NodeConfigPanel', () => ({ + NodeConfigPanel: () =>
, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/panels/EdgeConfigPanel', () => ({ + EdgeConfigPanel: () =>
, +})); +vi.mock('../../../../renderer/components/CuePipelineEditor/panels/CueSettingsPanel', () => ({ + CueSettingsPanel: () =>
, +})); + +const mockTheme = { + name: 'test', + colors: { + bgMain: '#1a1a2e', + bgActivity: '#16213e', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#06b6d4', + }, +} as any; + +function buildProps(overrides: Partial = {}): PipelineCanvasProps { + return { + theme: mockTheme, + nodes: [], + edges: [], + onNodesChange: vi.fn(), + onEdgesChange: vi.fn(), + onConnect: vi.fn(), + isValidConnection: vi.fn().mockReturnValue(true), + onNodeClick: vi.fn(), + onEdgeClick: vi.fn(), + onPaneClick: vi.fn(), + onNodeContextMenu: vi.fn(), + onDragOver: vi.fn(), + onDrop: vi.fn(), + triggerDrawerOpen: false, + setTriggerDrawerOpen: vi.fn(), + agentDrawerOpen: false, + setAgentDrawerOpen: vi.fn(), + sessions: [], + groups: [], + onCanvasSessionIds: new Set(), + pipelineCount: 1, + createPipeline: vi.fn(), + selectedPipelineId: 'p1', + pipelines: [], + selectPipeline: vi.fn(), + showSettings: false, + cueSettings: { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 3, + queue_size: 10, + }, + setCueSettings: vi.fn(), + setShowSettings: vi.fn(), + setIsDirty: vi.fn(), + selectedNode: null, + selectedEdge: null, + selectedNodeHasOutgoingEdge: false, + hasIncomingAgentEdges: false, + incomingTriggerEdges: [], + onUpdateNode: vi.fn(), + onUpdateEdgePrompt: vi.fn(), + onDeleteNode: vi.fn(), + onSwitchToSession: vi.fn(), + triggerDrawerOpenForConfig: false, + agentDrawerOpenForConfig: false, + edgeSourceNode: null, + edgeTargetNode: null, + selectedEdgePipelineColor: '#06b6d4', + onUpdateEdge: vi.fn(), + onDeleteEdge: vi.fn(), + ...overrides, + }; +} + +describe('PipelineCanvas', () => { + it('renders ReactFlow canvas', () => { + render(); + expect(screen.getByTestId('react-flow')).toBeInTheDocument(); + }); + + it('renders TriggerDrawer when triggerDrawerOpen is true', () => { + render(); + expect(screen.getByTestId('trigger-drawer')).toBeInTheDocument(); + }); + + it('does not render TriggerDrawer when triggerDrawerOpen is false', () => { + render(); + expect(screen.queryByTestId('trigger-drawer')).not.toBeInTheDocument(); + }); + + it('renders AgentDrawer when agentDrawerOpen is true', () => { + render(); + expect(screen.getByTestId('agent-drawer')).toBeInTheDocument(); + }); + + it('shows "Create your first pipeline" when pipelineCount=0 and nodes=[]', () => { + render(); + expect(screen.getByText('Create your first pipeline')).toBeInTheDocument(); + }); + + it('shows drag instruction when pipelineCount>0 and nodes=[]', () => { + render(); + expect( + screen.getByText('Drag a trigger from the left drawer and an agent from the right drawer') + ).toBeInTheDocument(); + }); + + it('does not show empty state when nodes are present', () => { + const nodes = [{ id: 'n1', type: 'trigger', position: { x: 0, y: 0 }, data: {} }] as any[]; + render(); + expect(screen.queryByText('Create your first pipeline')).not.toBeInTheDocument(); + expect( + screen.queryByText('Drag a trigger from the left drawer and an agent from the right drawer') + ).not.toBeInTheDocument(); + }); + + it('shows pipeline legend in All Pipelines view', () => { + const pipelines = [ + { id: 'p1', name: 'Alpha', color: '#06b6d4', nodes: [{ id: 'n1' }], edges: [] }, + { id: 'p2', name: 'Beta', color: '#8b5cf6', nodes: [], edges: [] }, + ] as any[]; + render(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.getByText('(1)')).toBeInTheDocument(); + expect(screen.getByText('(0)')).toBeInTheDocument(); + }); + + it('shows CueSettingsPanel when showSettings is true', () => { + render(); + expect(screen.getByTestId('cue-settings-panel')).toBeInTheDocument(); + }); + + it('shows NodeConfigPanel when selectedNode is set and selectedEdge is null', () => { + const selectedNode = { + id: 'n1', + type: 'trigger' as const, + position: { x: 0, y: 0 }, + data: { eventType: 'file.changed', label: 'File Changed', config: {} }, + }; + render(); + expect(screen.getByTestId('node-config-panel')).toBeInTheDocument(); + }); + + it('shows EdgeConfigPanel when selectedEdge is set and selectedNode is null', () => { + const selectedEdge = { + id: 'e1', + source: 'n1', + target: 'n2', + mode: 'pass' as const, + }; + render(); + expect(screen.getByTestId('edge-config-panel')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/PipelineContextMenu.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineContextMenu.test.tsx new file mode 100644 index 0000000000..185a7a907d --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineContextMenu.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { THEMES } from '../../../../renderer/constants/themes'; +import { + PipelineContextMenu, + type PipelineContextMenuProps, + type ContextMenuState, +} from '../../../../renderer/components/CuePipelineEditor/PipelineContextMenu'; + +vi.mock('../../../../renderer/hooks/ui', async () => { + const actual = await vi.importActual( + '../../../../renderer/hooks/ui' + ); + return { + ...actual, + useClickOutside: vi.fn(), + useContextMenuPosition: vi.fn((_ref, x, y) => ({ left: x, top: y, ready: true })), + }; +}); + +const theme = THEMES['dracula']; + +describe('PipelineContextMenu', () => { + const defaultContextMenu: ContextMenuState = { + x: 200, + y: 150, + nodeId: 'node-1', + pipelineId: 'pipeline-1', + nodeType: 'trigger', + }; + + let onConfigure: ReturnType; + let onDelete: ReturnType; + let onDuplicate: ReturnType; + let onDismiss: ReturnType; + + function renderMenu(overrides: Partial = {}) { + const props: PipelineContextMenuProps = { + contextMenu: defaultContextMenu, + theme, + onConfigure, + onDelete, + onDuplicate, + onDismiss, + ...overrides, + }; + return render(); + } + + beforeEach(() => { + onConfigure = vi.fn(); + onDelete = vi.fn(); + onDuplicate = vi.fn(); + onDismiss = vi.fn(); + }); + + it('renders at the correct position from contextMenu x/y', () => { + const { container } = renderMenu({ + contextMenu: { ...defaultContextMenu, x: 300, y: 400 }, + }); + const outer = container.firstElementChild as HTMLElement; + expect(outer.style.left).toBe('300px'); + expect(outer.style.top).toBe('400px'); + }); + + it('shows a Configure button', () => { + renderMenu(); + expect(screen.getByText('Configure')).toBeInTheDocument(); + }); + + it('calls onConfigure when Configure is clicked', () => { + renderMenu(); + fireEvent.click(screen.getByText('Configure')); + expect(onConfigure).toHaveBeenCalledTimes(1); + }); + + it('shows Duplicate button for trigger nodeType', () => { + renderMenu({ + contextMenu: { ...defaultContextMenu, nodeType: 'trigger' }, + }); + expect(screen.getByText('Duplicate')).toBeInTheDocument(); + }); + + it('does NOT show Duplicate button for agent nodeType', () => { + renderMenu({ + contextMenu: { ...defaultContextMenu, nodeType: 'agent' }, + }); + expect(screen.queryByText('Duplicate')).not.toBeInTheDocument(); + }); + + it('calls onDuplicate when Duplicate is clicked', () => { + renderMenu({ + contextMenu: { ...defaultContextMenu, nodeType: 'trigger' }, + }); + fireEvent.click(screen.getByText('Duplicate')); + expect(onDuplicate).toHaveBeenCalledTimes(1); + }); + + it('shows Delete button styled distinctly from other buttons', () => { + renderMenu(); + const deleteBtn = screen.getByText('Delete'); + expect(deleteBtn).toBeInTheDocument(); + // Color is set to theme.colors.error; browser normalizes hex to rgb + expect(deleteBtn.style.color).toBeTruthy(); + expect(deleteBtn.style.color).not.toBe(theme.colors.textMain); + }); + + it('calls onDelete when Delete is clicked', () => { + renderMenu(); + fireEvent.click(screen.getByText('Delete')); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('calls onDismiss when Escape is pressed', () => { + renderMenu(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); 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 0000000000..d6004d895e --- /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/PipelineToolbar.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/PipelineToolbar.test.tsx new file mode 100644 index 0000000000..0be638bcd1 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/PipelineToolbar.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { + PipelineToolbar, + PipelineToolbarProps, +} from '../../../../renderer/components/CuePipelineEditor/PipelineToolbar'; + +vi.mock('../../../../renderer/components/CuePipelineEditor/PipelineSelector', () => ({ + PipelineSelector: (props: any) =>
, +})); + +const mockTheme = { + name: 'test', + colors: { + bgMain: '#1a1a2e', + bgActivity: '#16213e', + border: '#333', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#06b6d4', + }, +} as any; + +function buildProps(overrides: Partial = {}): PipelineToolbarProps { + return { + theme: mockTheme, + isAllPipelinesView: false, + triggerDrawerOpen: false, + setTriggerDrawerOpen: vi.fn(), + agentDrawerOpen: false, + setAgentDrawerOpen: vi.fn(), + showSettings: false, + setShowSettings: vi.fn(), + pipelines: [], + selectedPipelineId: null, + selectPipeline: vi.fn(), + createPipeline: vi.fn(), + deletePipeline: vi.fn(), + renamePipeline: vi.fn(), + changePipelineColor: vi.fn(), + isDirty: false, + saveStatus: 'idle', + handleSave: vi.fn(), + handleDiscard: vi.fn(), + validationErrors: [], + ...overrides, + }; +} + +describe('PipelineToolbar', () => { + it('disables the Triggers button when isAllPipelinesView is true', () => { + const props = buildProps({ isAllPipelinesView: true }); + render(); + const triggersBtn = screen.getByRole('button', { name: /triggers/i }); + expect(triggersBtn).toBeDisabled(); + }); + + it('disables the Agents button when isAllPipelinesView is true', () => { + const props = buildProps({ isAllPipelinesView: true }); + render(); + const agentsBtn = screen.getByRole('button', { name: /agents/i }); + expect(agentsBtn).toBeDisabled(); + }); + + it('calls setTriggerDrawerOpen when Triggers button is clicked and not disabled', () => { + const setTriggerDrawerOpen = vi.fn(); + const props = buildProps({ isAllPipelinesView: false, setTriggerDrawerOpen }); + render(); + fireEvent.click(screen.getByRole('button', { name: /triggers/i })); + expect(setTriggerDrawerOpen).toHaveBeenCalledTimes(1); + // It's called with a toggling function + expect(typeof setTriggerDrawerOpen.mock.calls[0][0]).toBe('function'); + }); + + it('calls setAgentDrawerOpen when Agents button is clicked and not disabled', () => { + const setAgentDrawerOpen = vi.fn(); + const props = buildProps({ isAllPipelinesView: false, setAgentDrawerOpen }); + render(); + fireEvent.click(screen.getByRole('button', { name: /agents/i })); + expect(setAgentDrawerOpen).toHaveBeenCalledTimes(1); + expect(typeof setAgentDrawerOpen.mock.calls[0][0]).toBe('function'); + }); + + it('calls setShowSettings when Settings button is clicked', () => { + const setShowSettings = vi.fn(); + const props = buildProps({ setShowSettings }); + render(); + const settingsBtn = screen.getByTitle('Global Cue settings'); + fireEvent.click(settingsBtn); + expect(setShowSettings).toHaveBeenCalledTimes(1); + expect(typeof setShowSettings.mock.calls[0][0]).toBe('function'); + }); + + it('renders Discard button when isDirty is true', () => { + const props = buildProps({ isDirty: true }); + render(); + expect(screen.getByRole('button', { name: /discard/i })).toBeInTheDocument(); + }); + + it('does not render Discard button when isDirty is false', () => { + const props = buildProps({ isDirty: false }); + render(); + expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument(); + }); + + it('shows "Save" text when saveStatus is idle', () => { + const props = buildProps({ saveStatus: 'idle' }); + render(); + const saveBtn = screen.getByRole('button', { name: /save/i }); + expect(saveBtn.textContent).toContain('Save'); + expect(saveBtn.textContent).not.toContain('Saving'); + expect(saveBtn.textContent).not.toContain('Saved'); + }); + + it('shows "Saving..." text when saveStatus is saving', () => { + const props = buildProps({ saveStatus: 'saving' }); + render(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it('shows "Saved" text when saveStatus is success', () => { + const props = buildProps({ saveStatus: 'success' }); + render(); + expect(screen.getByText('Saved')).toBeInTheDocument(); + }); + + it('shows dirty indicator dot when isDirty is true and saveStatus is idle', () => { + const props = buildProps({ isDirty: true, saveStatus: 'idle' }); + render(); + expect(screen.getByTestId('dirty-indicator')).toBeInTheDocument(); + }); + + it('does not show dirty indicator dot when isDirty is false', () => { + const props = buildProps({ isDirty: false, saveStatus: 'idle' }); + render(); + expect(screen.queryByTestId('dirty-indicator')).not.toBeInTheDocument(); + }); + + it('shows validation errors bar when validationErrors is non-empty', () => { + const props = buildProps({ validationErrors: ['Missing trigger', 'No agents'] }); + render(); + expect(screen.getByText(/Missing trigger/)).toBeInTheDocument(); + expect(screen.getByText(/No agents/)).toBeInTheDocument(); + }); + + it('does not show validation errors bar when validationErrors is empty', () => { + const props = buildProps({ validationErrors: [] }); + render(); + expect(screen.queryByText(/Missing trigger/)).not.toBeInTheDocument(); + }); +}); 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 0000000000..22a8a6ef56 --- /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 0000000000..a21a292ae8 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -0,0 +1,142 @@ +/// +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.queryByText('Agent Done')).not.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.queryByText('After an agent finishes')).not.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 render exactly 7 trigger types (no agent.completed)', () => { + const { container } = render( + {}} theme={mockTheme} /> + ); + + // Each trigger item is a draggable div; count them + const draggableItems = container.querySelectorAll('[draggable="true"]'); + expect(draggableItems.length).toBe(7); + }); + + it('should not show agent.completed when filtering by "agent"', () => { + render( {}} theme={mockTheme} />); + + const input = screen.getByPlaceholderText('Filter triggers...'); + fireEvent.change(input, { target: { value: 'agent' } }); + + // No trigger items should match since agent.completed was removed + expect(screen.getByText('No triggers match')).toBeInTheDocument(); + }); + + 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 0000000000..e1e7112ea3 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/AgentNode.test.tsx @@ -0,0 +1,137 @@ +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'; +import { THEMES } from '../../../../../renderer/constants/themes'; + +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(); + }); + + it('should use theme colors for background when theme is provided', () => { + const lightTheme = THEMES['github-light']; + const { container } = renderAgentNode({ theme: lightTheme }); + const rootDiv = container.querySelector('div[style*="min-width: 180px"]') as HTMLElement; + expect(rootDiv).toHaveStyle({ backgroundColor: lightTheme.colors.bgMain }); + }); + + it('should use theme colors for text when theme is provided', () => { + const lightTheme = THEMES['github-light']; + const { getByText } = renderAgentNode({ theme: lightTheme }); + expect(getByText('Test Agent')).toHaveStyle({ color: lightTheme.colors.textMain }); + }); + + it('should fall back to hardcoded colors when no theme is provided', () => { + const { container } = renderAgentNode(); + const rootDiv = container.querySelector('div[style*="min-width: 180px"]') as HTMLElement; + // When no theme, falls back to '#1e1e2e' + expect(rootDiv).toHaveStyle({ backgroundColor: '#1e1e2e' }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000000..648995b7bc --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/nodes/TriggerNode.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TriggerNode } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode'; +import { ReactFlowProvider } from 'reactflow'; +import type { NodeProps } from 'reactflow'; +import type { TriggerNodeDataProps } from '../../../../../renderer/components/CuePipelineEditor/nodes/TriggerNode'; +import { THEMES } from '../../../../../renderer/constants/themes'; + +const defaultData: TriggerNodeDataProps = { + compositeId: 'pipeline-1:trigger-0', + eventType: 'time.heartbeat', + label: 'Heartbeat', + configSummary: 'every 5min', +}; + +function renderTriggerNode(overrides: Partial = {}, selected = false) { + const data = { ...defaultData, ...overrides }; + const props = { + id: 'test-trigger', + data, + type: 'trigger', + selected, + isConnectable: true, + xPos: 0, + yPos: 0, + zIndex: 0, + dragging: false, + } as NodeProps; + + return render( + + + + ); +} + +describe('TriggerNode', () => { + it('should render label and config summary', () => { + renderTriggerNode(); + + expect(screen.getByText('Heartbeat')).toBeInTheDocument(); + expect(screen.getByText('every 5min')).toBeInTheDocument(); + }); + + it('should render a drag handle', () => { + const { container } = renderTriggerNode(); + const dragHandle = container.querySelector('.drag-handle'); + expect(dragHandle).not.toBeNull(); + }); + + it('should render a gear icon for configuration', () => { + const { container } = renderTriggerNode(); + const gearButton = container.querySelector('[title="Configure"]'); + expect(gearButton).not.toBeNull(); + }); + + it('should show title tooltip on the label span', () => { + renderTriggerNode({ label: 'My Custom Label' }); + + const labelSpan = screen.getByText('My Custom Label'); + expect(labelSpan).toHaveAttribute('title', 'My Custom Label'); + }); + + it('should show title tooltip on the config summary span', () => { + renderTriggerNode({ configSummary: 'every 10min' }); + + const summarySpan = screen.getByText('every 10min'); + expect(summarySpan).toHaveAttribute('title', 'every 10min'); + }); + + it('should show tooltip with full text for long labels', () => { + const longLabel = 'This is a very long trigger label that will be truncated'; + renderTriggerNode({ label: longLabel }); + + const labelSpan = screen.getByText(longLabel); + expect(labelSpan).toHaveAttribute('title', longLabel); + }); + + it('should show tooltip with full text for long config summaries', () => { + const longSummary = 'Mon, Tue, Wed, Thu, Fri at 09:00, 12:00, 15:00, 18:00'; + renderTriggerNode({ configSummary: longSummary }); + + const summarySpan = screen.getByText(longSummary); + expect(summarySpan).toHaveAttribute('title', longSummary); + }); + + it('should use minWidth and maxWidth instead of fixed width', () => { + const { container } = renderTriggerNode(); + + const rootDiv = container.querySelector('div[style*="min-width: 220px"]') as HTMLElement; + expect(rootDiv).not.toBeNull(); + expect(rootDiv.style.maxWidth).toBe('320px'); + // Ensure no fixed width is set + expect(rootDiv.style.width).toBe(''); + }); + + it('should not render config summary when empty', () => { + renderTriggerNode({ configSummary: '' }); + + // The summary span should not be in the DOM + expect(screen.queryByText('every 5min')).not.toBeInTheDocument(); + }); + + it('should call onConfigure when gear icon is clicked', () => { + const onConfigure = vi.fn(); + const { container } = renderTriggerNode({ + onConfigure, + compositeId: 'pipeline-1:trigger-0', + }); + + const gearButton = container.querySelector('[title="Configure"]') as HTMLElement; + gearButton.click(); + + expect(onConfigure).toHaveBeenCalledWith('pipeline-1:trigger-0'); + }); + + it('should apply selection styling when selected', () => { + const { container: selectedContainer } = renderTriggerNode({}, true); + const { container: unselectedContainer } = renderTriggerNode({}, false); + + const selectedRoot = selectedContainer.querySelector( + 'div[style*="min-width: 220px"]' + ) as HTMLElement; + const unselectedRoot = unselectedContainer.querySelector( + 'div[style*="min-width: 220px"]' + ) as HTMLElement; + + // Selected and unselected should have different border colors + expect(selectedRoot.style.borderColor).not.toBe(unselectedRoot.style.borderColor); + // Selected should have a box shadow, unselected should not + expect(selectedRoot.style.boxShadow).toBeTruthy(); + expect(unselectedRoot.style.boxShadow).toBeFalsy(); + }); + + describe('play button', () => { + it('renders when isSaved, pipelineName, and onTriggerPipeline are provided', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + }); + + const playButton = container.querySelector('[title="Run now"]'); + expect(playButton).not.toBeNull(); + }); + + it('is hidden when isSaved is false', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: false, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('is hidden when onTriggerPipeline is undefined', () => { + const { container } = renderTriggerNode({ + pipelineName: 'my-pipeline', + isSaved: true, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('is hidden when pipelineName is undefined', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + isSaved: true, + }); + + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('calls onTriggerPipeline with pipeline name when clicked', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'test-pipeline', + isSaved: true, + }); + + const playButton = container.querySelector('[title="Run now"]') as HTMLElement; + playButton.click(); + + expect(onTriggerPipeline).toHaveBeenCalledWith('test-pipeline'); + }); + + it('shows "Running…" tooltip when isRunning is true', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + isRunning: true, + }); + + expect(container.querySelector('[title="Running…"]')).not.toBeNull(); + expect(container.querySelector('[title="Run now"]')).toBeNull(); + }); + + it('does not call onTriggerPipeline when isRunning and clicked', () => { + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + isRunning: true, + }); + + const runningButton = container.querySelector('[title="Running…"]') as HTMLElement; + runningButton.click(); + + expect(onTriggerPipeline).not.toHaveBeenCalled(); + }); + + it('gear icon still works alongside play button', () => { + const onConfigure = vi.fn(); + const onTriggerPipeline = vi.fn(); + const { container } = renderTriggerNode({ + onConfigure, + onTriggerPipeline, + pipelineName: 'my-pipeline', + isSaved: true, + compositeId: 'pipeline-1:trigger-0', + }); + + // Both buttons should exist + expect(container.querySelector('[title="Run now"]')).not.toBeNull(); + expect(container.querySelector('[title="Configure"]')).not.toBeNull(); + + // Gear still works + const gearButton = container.querySelector('[title="Configure"]') as HTMLElement; + gearButton.click(); + expect(onConfigure).toHaveBeenCalledWith('pipeline-1:trigger-0'); + }); + }); + + it('should use theme textDim color for config summary when theme provided', () => { + const lightTheme = THEMES['github-light']; + const { container } = renderTriggerNode({ theme: lightTheme, configSummary: 'every 30min' }); + const summarySpan = container.querySelector('span[title="every 30min"]') as HTMLElement; + expect(summarySpan).toBeInTheDocument(); + expect(summarySpan).toHaveStyle({ color: lightTheme.colors.textDim }); + }); + + it('should use theme textDim color for drag handle when theme provided', () => { + const darkTheme = THEMES['dracula']; + const { container } = renderTriggerNode({ theme: darkTheme }); + const dragHandle = container.querySelector('.drag-handle') as HTMLElement; + expect(dragHandle).toBeInTheDocument(); + expect(dragHandle).toHaveStyle({ color: darkTheme.colors.textDim }); + }); + + it('should fall back to hardcoded textDim when no theme provided', () => { + const { container } = renderTriggerNode({ configSummary: 'every 10min' }); + const summarySpan = container.querySelector('span[title="every 10min"]') as HTMLElement; + expect(summarySpan).toBeInTheDocument(); + expect(summarySpan).toHaveStyle({ color: '#9ca3af' }); + }); + + it('should use correct color for each event type', () => { + const eventColors: Record = { + 'time.heartbeat': '#f59e0b', + 'time.scheduled': '#8b5cf6', + 'file.changed': '#3b82f6', + 'agent.completed': '#22c55e', + 'github.pull_request': '#a855f7', + 'github.issue': '#f97316', + 'task.pending': '#06b6d4', + }; + + for (const [eventType, expectedColor] of Object.entries(eventColors)) { + const { unmount } = renderTriggerNode({ + eventType: eventType as TriggerNodeDataProps['eventType'], + label: eventType, + }); + + const labelSpan = screen.getByText(eventType); + expect(labelSpan).toHaveStyle({ color: expectedColor }); + + unmount(); + } + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.test.tsx new file mode 100644 index 0000000000..2ca1431a26 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/panels/CueSettingsPanel.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CueSettingsPanel } from '../../../../../renderer/components/CuePipelineEditor/panels/CueSettingsPanel'; +import { THEMES } from '../../../../../renderer/constants/themes'; +import type { CueSettings } from '../../../../../main/cue/cue-types'; + +vi.mock('../../../../../renderer/hooks/ui', async () => { + const actual = await vi.importActual( + '../../../../../renderer/hooks/ui' + ); + return { + ...actual, + useClickOutside: vi.fn(), + }; +}); + +const darkTheme = THEMES['dracula']; +const lightTheme = THEMES['github-light']; + +const defaultSettings: CueSettings = { + timeout_minutes: 30, + timeout_on_fail: 'break', + max_concurrent: 1, + queue_size: 10, +}; + +describe('CueSettingsPanel', () => { + let onChange: ReturnType; + let onClose: ReturnType; + + beforeEach(() => { + onChange = vi.fn(); + onClose = vi.fn(); + }); + + it('renders with theme background color', () => { + const { container } = render( + + ); + const panel = container.firstElementChild as HTMLElement; + expect(panel).toHaveStyle({ backgroundColor: lightTheme.colors.bgSidebar }); + }); + + it('renders title with theme textMain color', () => { + render( + + ); + const title = screen.getByText('Cue Settings'); + expect(title).toHaveStyle({ color: darkTheme.colors.textMain }); + }); + + it('calls onClose when Escape is pressed', () => { + render( + + ); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders input fields with theme colors', () => { + render( + + ); + const inputs = screen.getAllByRole('spinbutton'); + expect(inputs.length).toBeGreaterThan(0); + const firstInput = inputs[0] as HTMLInputElement; + expect(firstInput).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity }); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.test.tsx new file mode 100644 index 0000000000..e282de0159 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/panels/EdgeConfigPanel.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { EdgeConfigPanel } from '../../../../../renderer/components/CuePipelineEditor/panels/EdgeConfigPanel'; +import { THEMES } from '../../../../../renderer/constants/themes'; +import type { + PipelineEdge, + PipelineNode, + TriggerNodeData, + AgentNodeData, +} from '../../../../../shared/cue-pipeline-types'; + +const darkTheme = THEMES['dracula']; +const lightTheme = THEMES['github-light']; + +const sourceNode: PipelineNode = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { eventType: 'time.heartbeat', label: 'Heartbeat', config: {} } as TriggerNodeData, +}; + +const targetNode: PipelineNode = { + id: 'agent-1', + type: 'agent', + position: { x: 200, y: 0 }, + data: { sessionId: 's1', sessionName: 'Agent 1', toolType: 'claude-code' } as AgentNodeData, +}; + +const edge: PipelineEdge = { + id: 'edge-1', + source: 'trigger-1', + target: 'agent-1', + mode: 'pass', +}; + +describe('EdgeConfigPanel', () => { + it('renders nothing when selectedEdge is null', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders panel with theme background', () => { + const { container } = render( + + ); + const panel = container.firstElementChild as HTMLElement; + expect(panel).toHaveStyle({ backgroundColor: lightTheme.colors.bgMain }); + }); + + it('renders header text with theme colors', () => { + render( + + ); + const title = screen.getByText('Connection Settings'); + expect(title).toHaveStyle({ color: darkTheme.colors.textMain }); + }); + + it('calls onDeleteEdge when delete button clicked', () => { + const onDeleteEdge = vi.fn(); + render( + + ); + const deleteBtn = screen.getByTitle('Delete connection'); + fireEvent.click(deleteBtn); + expect(onDeleteEdge).toHaveBeenCalledWith('edge-1'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.test.tsx new file mode 100644 index 0000000000..e769c742c7 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/panels/NodeConfigPanel.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NodeConfigPanel } from '../../../../../renderer/components/CuePipelineEditor/panels/NodeConfigPanel'; +import { THEMES } from '../../../../../renderer/constants/themes'; +import type { + PipelineNode, + TriggerNodeData, + AgentNodeData, + CuePipeline, +} from '../../../../../shared/cue-pipeline-types'; + +vi.mock('../../../../../renderer/components/CuePipelineEditor/panels/triggers', () => ({ + TriggerConfig: ({ node }: { node: PipelineNode }) => ( +
{node.id}
+ ), +})); + +vi.mock('../../../../../renderer/components/CuePipelineEditor/panels/AgentConfigPanel', () => ({ + AgentConfigPanel: ({ node }: { node: PipelineNode }) => ( +
{node.id}
+ ), +})); + +const darkTheme = THEMES['dracula']; +const lightTheme = THEMES['github-light']; + +const triggerNode: PipelineNode = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + eventType: 'time.heartbeat', + label: 'Heartbeat', + config: { interval_minutes: 30 }, + } as TriggerNodeData, +}; + +const agentNode: PipelineNode = { + id: 'agent-1', + type: 'agent', + position: { x: 0, y: 0 }, + data: { + sessionId: 'sess-1', + sessionName: 'Test Agent', + toolType: 'claude-code', + } as AgentNodeData, +}; + +const defaultPipelines: CuePipeline[] = []; + +describe('NodeConfigPanel', () => { + it('renders nothing when selectedNode is null', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders trigger config header with theme colors', () => { + const { container } = render( + + ); + const panel = container.firstElementChild as HTMLElement; + expect(panel).toHaveStyle({ backgroundColor: lightTheme.colors.bgMain }); + }); + + it('renders agent config header text with theme textMain', () => { + render( + + ); + const nameEl = screen.getByText('Test Agent'); + expect(nameEl).toHaveStyle({ color: darkTheme.colors.textMain }); + }); + + it('uses theme border color for panel borders', () => { + const { container } = render( + + ); + const panel = container.firstElementChild as HTMLElement; + expect(panel).toHaveStyle({ borderTop: `1px solid ${lightTheme.colors.border}` }); + }); + + it('calls onDeleteNode when delete button clicked', () => { + const onDeleteNode = vi.fn(); + render( + + ); + const deleteBtn = screen.getByTitle('Delete node'); + fireEvent.click(deleteBtn); + expect(onDeleteNode).toHaveBeenCalledWith('trigger-1'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineChainIntegration.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineChainIntegration.test.ts new file mode 100644 index 0000000000..fb8035d22b --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineChainIntegration.test.ts @@ -0,0 +1,198 @@ +/** + * Integration tests for pipeline chain output variable substitution. + * + * Verifies that the full flow works end-to-end: + * pipelineToYamlSubscriptions → {{CUE_SOURCE_OUTPUT}} injection → substituteTemplateVariables resolution + */ + +import { describe, it, expect } from 'vitest'; +import { pipelineToYamlSubscriptions } from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineToYaml'; +import { substituteTemplateVariables } from '../../../../../shared/templateVariables'; +import type { CuePipeline } from '../../../../../shared/cue-pipeline-types'; +import type { TemplateContext, TemplateSessionInfo } from '../../../../../shared/templateVariables'; + +function makePipeline(overrides: Partial = {}): CuePipeline { + return { + id: 'p1', + name: 'test-pipeline', + color: '#06b6d4', + nodes: [], + edges: [], + ...overrides, + }; +} + +const stubSession: TemplateSessionInfo = { + id: 'test-session', + name: 'test-agent', + toolType: 'claude-code', + cwd: '/tmp/test', +}; + +describe('pipeline chain output integration', () => { + it('generated chain prompt resolves {{CUE_SOURCE_OUTPUT}} to actual output', () => { + 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: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build the project', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Review the changes', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + const chainSub = subs.find((s) => s.event === 'agent.completed'); + expect(chainSub).toBeDefined(); + expect(chainSub!.prompt).toContain('{{CUE_SOURCE_OUTPUT}}'); + + // Simulate the engine substituting the template variable + const context: TemplateContext = { + session: stubSession, + cue: { + sourceOutput: 'Build completed successfully. 42 files compiled.', + sourceSession: 'builder', + }, + }; + const resolved = substituteTemplateVariables(chainSub!.prompt!, context); + expect(resolved).toContain('Build completed successfully. 42 files compiled.'); + expect(resolved).toContain('Review the changes'); + expect(resolved).not.toContain('{{CUE_SOURCE_OUTPUT}}'); + }); + + it('preserves user prompt content alongside injected source output', () => { + 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: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'reviewer', + toolType: 'claude-code', + inputPrompt: 'Review the code changes and suggest improvements', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + const chainSub = subs.find((s) => s.event === 'agent.completed')!; + + const context: TemplateContext = { + session: stubSession, + cue: { + sourceOutput: 'Diff: +5 -3 lines', + sourceSession: 'builder', + }, + }; + const resolved = substituteTemplateVariables(chainSub.prompt!, context); + + // Both the source output AND user instructions should be present + expect(resolved).toContain('Diff: +5 -3 lines'); + expect(resolved).toContain('Review the code changes and suggest improvements'); + }); + + it('handles empty source output gracefully', () => { + 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: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Run tests', + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + const chainSub = subs.find((s) => s.event === 'agent.completed')!; + + // Simulate empty source output + const context: TemplateContext = { + session: stubSession, + cue: { + sourceOutput: '', + sourceSession: 'builder', + }, + }; + const resolved = substituteTemplateVariables(chainSub.prompt!, context); + expect(resolved).not.toContain('{{CUE_SOURCE_OUTPUT}}'); + expect(resolved).toContain('Run tests'); + }); +}); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts new file mode 100644 index 0000000000..bf602e262f --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineGraph.test.ts @@ -0,0 +1,822 @@ +/** + * Tests for pipelineGraph utilities: getTriggerConfigSummary, + * convertToReactFlowNodes, and convertToReactFlowEdges. + * + * These are pure functions — no React, no DOM. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + getTriggerConfigSummary, + convertToReactFlowNodes, + convertToReactFlowEdges, + computePipelineYOffsets, +} from '../../../../../renderer/components/CuePipelineEditor/utils/pipelineGraph'; +import type { + CuePipeline, + TriggerNodeData, + AgentNodeData, +} from '../../../../../shared/cue-pipeline-types'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeTrigger( + id: string, + eventType: TriggerNodeData['eventType'], + config: TriggerNodeData['config'] = {}, + position = { x: 0, y: 0 } +) { + return { + id, + type: 'trigger' as const, + position, + data: { eventType, label: eventType, config } satisfies TriggerNodeData, + }; +} + +function makeAgent( + id: string, + sessionId: string, + sessionName: string, + overrides: Partial = {}, + position = { x: 200, y: 0 } +) { + return { + id, + type: 'agent' as const, + position, + data: { + sessionId, + sessionName, + toolType: 'claude-code', + ...overrides, + } satisfies AgentNodeData, + }; +} + +function makeEdge(id: string, source: string, target: string, prompt?: string) { + return { id, source, target, mode: 'pass' as const, prompt }; +} + +function makePipeline(id: string, overrides: Partial> = {}): CuePipeline { + return { + id, + name: `Pipeline ${id}`, + color: '#06b6d4', + nodes: [], + edges: [], + ...overrides, + }; +} + +// ─── getTriggerConfigSummary ────────────────────────────────────────────────── + +describe('getTriggerConfigSummary', () => { + it('heartbeat: returns interval when set', () => { + const data: TriggerNodeData = { + eventType: 'time.heartbeat', + label: 'Heartbeat', + config: { interval_minutes: 15 }, + }; + expect(getTriggerConfigSummary(data)).toBe('every 15min'); + }); + + it('heartbeat: returns fallback when no interval', () => { + const data: TriggerNodeData = { + eventType: 'time.heartbeat', + label: 'Heartbeat', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('heartbeat'); + }); + + it('scheduled: returns "scheduled" when no times set', () => { + const data: TriggerNodeData = { + eventType: 'time.scheduled', + label: 'Scheduled', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('scheduled'); + }); + + it('scheduled: shows up to 2 times inline', () => { + const data: TriggerNodeData = { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { schedule_times: ['09:00', '17:00'] }, + }; + expect(getTriggerConfigSummary(data)).toBe('09:00, 17:00'); + }); + + it('scheduled: collapses 3+ times to count', () => { + const data: TriggerNodeData = { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { schedule_times: ['09:00', '12:00', '17:00'] }, + }; + expect(getTriggerConfigSummary(data)).toBe('3 times'); + }); + + it('scheduled: appends day filter when days are a subset of 7', () => { + const data: TriggerNodeData = { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { schedule_times: ['09:00'], schedule_days: ['Mon', 'Fri'] }, + }; + expect(getTriggerConfigSummary(data)).toBe('09:00 (Mon, Fri)'); + }); + + it('scheduled: omits day filter when all 7 days selected', () => { + const data: TriggerNodeData = { + eventType: 'time.scheduled', + label: 'Scheduled', + config: { + schedule_times: ['09:00'], + schedule_days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }, + }; + expect(getTriggerConfigSummary(data)).toBe('09:00'); + }); + + it('file.changed: returns watch pattern when set', () => { + const data: TriggerNodeData = { + eventType: 'file.changed', + label: 'File', + config: { watch: 'src/**/*.ts' }, + }; + expect(getTriggerConfigSummary(data)).toBe('src/**/*.ts'); + }); + + it('file.changed: returns default glob when no watch', () => { + const data: TriggerNodeData = { + eventType: 'file.changed', + label: 'File', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('**/*'); + }); + + it('github.pull_request: returns repo name', () => { + const data: TriggerNodeData = { + eventType: 'github.pull_request', + label: 'PR', + config: { repo: 'org/repo' }, + }; + expect(getTriggerConfigSummary(data)).toBe('org/repo'); + }); + + it('github.issue: returns repo name', () => { + const data: TriggerNodeData = { + eventType: 'github.issue', + label: 'Issue', + config: { repo: 'org/repo' }, + }; + expect(getTriggerConfigSummary(data)).toBe('org/repo'); + }); + + it('github.pull_request: returns fallback when no repo', () => { + const data: TriggerNodeData = { + eventType: 'github.pull_request', + label: 'PR', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('repo'); + }); + + it('task.pending: returns watch pattern', () => { + const data: TriggerNodeData = { + eventType: 'task.pending', + label: 'Task', + config: { watch: 'TODO.md' }, + }; + expect(getTriggerConfigSummary(data)).toBe('TODO.md'); + }); + + it('task.pending: returns fallback when no watch', () => { + const data: TriggerNodeData = { + eventType: 'task.pending', + label: 'Task', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('tasks'); + }); + + it('agent.completed: always returns fixed string', () => { + const data: TriggerNodeData = { + eventType: 'agent.completed', + label: 'Agent Done', + config: {}, + }; + expect(getTriggerConfigSummary(data)).toBe('agent done'); + }); +}); + +// ─── convertToReactFlowNodes ────────────────────────────────────────────────── + +describe('convertToReactFlowNodes', () => { + // ── Basic rendering ────────────────────────────────────────────────────── + + it('returns empty array for empty pipeline list', () => { + const result = convertToReactFlowNodes([], null); + expect(result).toEqual([]); + }); + + it('returns empty array for pipelines with no nodes', () => { + const pipelines = [makePipeline('p1'), makePipeline('p2')]; + expect(convertToReactFlowNodes(pipelines, null)).toEqual([]); + }); + + it('renders trigger node with correct composite id', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect(nodes).toHaveLength(1); + expect(nodes[0].id).toBe('p1:t1'); + expect(nodes[0].type).toBe('trigger'); + }); + + it('renders agent node with correct composite id', () => { + const pipeline = makePipeline('p1', { + nodes: [makeAgent('a1', 'sess-1', 'Pedsidian')], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect(nodes).toHaveLength(1); + expect(nodes[0].id).toBe('p1:a1'); + expect(nodes[0].type).toBe('agent'); + }); + + it('passes customLabel over eventType label for triggers', () => { + const trigger = makeTrigger('t1', 'time.heartbeat'); + (trigger.data as TriggerNodeData).customLabel = 'Morning Check'; + const pipeline = makePipeline('p1', { nodes: [trigger] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect((nodes[0].data as { label: string }).label).toBe('Morning Check'); + }); + + it('uses eventType label when customLabel is absent', () => { + const trigger = makeTrigger('t1', 'time.heartbeat'); + (trigger.data as TriggerNodeData).label = 'Heartbeat'; + const pipeline = makePipeline('p1', { nodes: [trigger] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect((nodes[0].data as { label: string }).label).toBe('Heartbeat'); + }); + + it('calls onConfigureNode callback and passes it to node data', () => { + const callback = vi.fn(); + const pipeline = makePipeline('p1', { nodes: [makeTrigger('t1', 'file.changed')] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1', callback); + expect((nodes[0].data as { onConfigure: typeof callback }).onConfigure).toBe(callback); + }); + + // ── hasPrompt ──────────────────────────────────────────────────────────── + + it('hasPrompt is true when agent has inputPrompt', () => { + const agent = makeAgent('a1', 'sess-1', 'Alice', { inputPrompt: 'Do something' }); + const pipeline = makePipeline('p1', { nodes: [agent] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect((nodes[0].data as { hasPrompt: boolean }).hasPrompt).toBe(true); + }); + + it('hasPrompt is true when agent has outputPrompt', () => { + const agent = makeAgent('a1', 'sess-1', 'Alice', { outputPrompt: 'Summarise' }); + const pipeline = makePipeline('p1', { nodes: [agent] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect((nodes[0].data as { hasPrompt: boolean }).hasPrompt).toBe(true); + }); + + it('hasPrompt is true when an incoming edge has a prompt', () => { + const trigger = makeTrigger('t1', 'time.heartbeat'); + const agent = makeAgent('a1', 'sess-1', 'Alice'); + const pipeline = makePipeline('p1', { + nodes: [trigger, agent], + edges: [{ ...makeEdge('e1', 't1', 'a1'), prompt: 'edge prompt' }], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + const agentNode = nodes.find((n) => n.id === 'p1:a1')!; + expect((agentNode.data as { hasPrompt: boolean }).hasPrompt).toBe(true); + }); + + it('hasPrompt is false when no prompt anywhere', () => { + const agent = makeAgent('a1', 'sess-1', 'Alice'); + const pipeline = makePipeline('p1', { nodes: [agent] }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + expect((nodes[0].data as { hasPrompt: boolean }).hasPrompt).toBe(false); + }); + + it('hasOutgoingEdge is true when agent has an outgoing edge', () => { + const agent1 = makeAgent('a1', 'sess-1', 'Alice'); + const agent2 = makeAgent('a2', 'sess-2', 'Bob', {}, { x: 400, y: 0 }); + const pipeline = makePipeline('p1', { + nodes: [agent1, agent2], + edges: [makeEdge('e1', 'a1', 'a2')], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + const a1Node = nodes.find((n) => n.id === 'p1:a1')!; + const a2Node = nodes.find((n) => n.id === 'p1:a2')!; + expect((a1Node.data as { hasOutgoingEdge: boolean }).hasOutgoingEdge).toBe(true); + expect((a2Node.data as { hasOutgoingEdge: boolean }).hasOutgoingEdge).toBe(false); + }); + + // ── Selected pipeline view ─────────────────────────────────────────────── + + it('only renders nodes from the selected pipeline', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed'), makeAgent('a2', 'sess-2', 'Bob')], + }); + const nodes = convertToReactFlowNodes([p1, p2], 'p1'); + const ids = nodes.map((n) => n.id); + expect(ids).toContain('p1:t1'); + expect(ids).toContain('p1:a1'); + expect(ids).not.toContain('p2:t2'); + expect(ids).not.toContain('p2:a2'); + }); + + it('BUG FIX: does NOT render a ghost copy of a shared agent from another pipeline when one is selected', () => { + // This is the primary regression test for the "second one pops up" bug. + // Pipeline 1 has Pedsidian. Pipeline 2 (selected) also has Pedsidian. + // Before the fix, Pipeline 1's Pedsidian would appear at 40% opacity on the canvas. + // After the fix, only the selected pipeline's copy is visible. + const sharedSessionId = 'sess-pedsidian'; + const p1 = makePipeline('p1', { + color: '#06b6d4', + nodes: [makeAgent('a1', sharedSessionId, 'Pedsidian', {}, { x: 0, y: 0 })], + }); + const p2 = makePipeline('p2', { + color: '#8b5cf6', + nodes: [makeAgent('a2', sharedSessionId, 'Pedsidian', {}, { x: 0, y: 0 })], + }); + // p2 is selected — only p2's Pedsidian should appear + const nodes = convertToReactFlowNodes([p1, p2], 'p2'); + const ids = nodes.map((n) => n.id); + expect(ids).toHaveLength(1); + expect(ids).toContain('p2:a2'); + expect(ids).not.toContain('p1:a1'); + }); + + it('BUG FIX: no ghost agent appears even when the agent is unique to one pipeline and the other is selected', () => { + // Simulates the exact user scenario: existing pipeline has Pedsidian, + // user creates new pipeline (selected), drags Pedsidian in. + const sharedSessionId = 'sess-pedsidian'; + const p1 = makePipeline('p1', { + nodes: [makeAgent('a1', sharedSessionId, 'Pedsidian')], + }); + // New pipeline just got Pedsidian dragged in + const p2 = makePipeline('p2', { + nodes: [makeAgent('a2', sharedSessionId, 'Pedsidian')], + }); + const nodes = convertToReactFlowNodes([p1, p2], 'p2'); + // Should see exactly ONE Pedsidian node (from p2, not a dimmed copy from p1) + const pedsidianNodes = nodes.filter( + (n) => (n.data as { sessionId: string }).sessionId === sharedSessionId + ); + expect(pedsidianNodes).toHaveLength(1); + expect(pedsidianNodes[0].id).toBe('p2:a2'); + // No opacity dimming on any node + expect(nodes.every((n) => n.style === undefined || n.style?.opacity === undefined)).toBe(true); + }); + + // ── All Pipelines view ─────────────────────────────────────────────────── + + it('All Pipelines view renders nodes from all pipelines', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed'), makeAgent('a2', 'sess-2', 'Bob')], + }); + const nodes = convertToReactFlowNodes([p1, p2], null); + const ids = nodes.map((n) => n.id); + expect(ids).toContain('p1:t1'); + expect(ids).toContain('p1:a1'); + expect(ids).toContain('p2:t2'); + expect(ids).toContain('p2:a2'); + }); + + it('All Pipelines view: shared agent appears once per pipeline (both active, no dimming)', () => { + const sharedSessionId = 'sess-shared'; + const p1 = makePipeline('p1', { + nodes: [makeAgent('a1', sharedSessionId, 'Shared', {}, { x: 0, y: 0 })], + }); + const p2 = makePipeline('p2', { + nodes: [makeAgent('a2', sharedSessionId, 'Shared', {}, { x: 0, y: 0 })], + }); + const nodes = convertToReactFlowNodes([p1, p2], null); + // Both copies are visible (one per pipeline) and neither is dimmed + const ids = nodes.map((n) => n.id); + expect(ids).toContain('p1:a1'); + expect(ids).toContain('p2:a2'); + expect(nodes.every((n) => n.style === undefined || n.style?.opacity === undefined)).toBe(true); + }); + + // ── Multi-pipeline color metadata ──────────────────────────────────────── + + it('agent in a single pipeline has pipelineCount=1 and single color', () => { + const pipeline = makePipeline('p1', { + color: '#06b6d4', + nodes: [makeAgent('a1', 'sess-1', 'Solo')], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + const data = nodes[0].data as { pipelineCount: number; pipelineColors: string[] }; + expect(data.pipelineCount).toBe(1); + expect(data.pipelineColors).toEqual(['#06b6d4']); + }); + + it('shared agent in selected pipeline carries multi-pipeline color metadata', () => { + // Even though we only render the active pipeline's node, + // the pipelineCount and pipelineColors should reflect ALL pipelines it appears in. + const p1 = makePipeline('p1', { + color: '#06b6d4', + nodes: [makeAgent('a1', 'sess-shared', 'Pedsidian')], + }); + const p2 = makePipeline('p2', { + color: '#8b5cf6', + nodes: [makeAgent('a2', 'sess-shared', 'Pedsidian')], + }); + const nodes = convertToReactFlowNodes([p1, p2], 'p2'); + const agentNode = nodes.find((n) => n.id === 'p2:a2')!; + const data = agentNode.data as { pipelineCount: number; pipelineColors: string[] }; + // Count = 2 (appears in both p1 and p2) + expect(data.pipelineCount).toBe(2); + // Colors include both pipelines + expect(data.pipelineColors).toContain('#06b6d4'); + expect(data.pipelineColors).toContain('#8b5cf6'); + }); + + it('agent color indicator shows all pipeline colors even in selected view', () => { + // Three pipelines share the same agent + const p1 = makePipeline('p1', { color: '#06b6d4', nodes: [makeAgent('a1', 'sess-x', 'X')] }); + const p2 = makePipeline('p2', { color: '#8b5cf6', nodes: [makeAgent('a2', 'sess-x', 'X')] }); + const p3 = makePipeline('p3', { color: '#f59e0b', nodes: [makeAgent('a3', 'sess-x', 'X')] }); + // Viewing p3 + const nodes = convertToReactFlowNodes([p1, p2, p3], 'p3'); + expect(nodes).toHaveLength(1); + const data = nodes[0].data as { pipelineCount: number; pipelineColors: string[] }; + expect(data.pipelineCount).toBe(3); + expect(data.pipelineColors).toHaveLength(3); + }); + + // ── Y-offset stacking (All Pipelines view) ─────────────────────────────── + + it('applies y-offsets in All Pipelines view to stack pipelines vertically', () => { + // Both pipelines have their single node at y=50. + // The algorithm normalises p1 to start at y=0 (offset = -minY = -50), + // then places p2 after p1 ends (NODE_HEIGHT=100, PIPELINE_GAP=100). + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 50 })], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed', {}, { x: 0, y: 50 })], + }); + const nodes = convertToReactFlowNodes([p1, p2], null); + const t1 = nodes.find((n) => n.id === 'p1:t1')!; + const t2 = nodes.find((n) => n.id === 'p2:t2')!; + // p1 is normalised: y = 50 + (-50) = 0 + expect(t1.position.y).toBe(0); + // p2 comes after: y = 50 + offset, where offset > 50 → rendered y > 100 + expect(t2.position.y).toBeGreaterThan(t1.position.y); + }); + + it('does NOT apply y-offsets when only one pipeline', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 10, y: 30 })], + }); + const nodes = convertToReactFlowNodes([pipeline], null); + expect(nodes[0].position).toEqual({ x: 10, y: 30 }); + }); + + it('does NOT apply y-offsets in selected pipeline view', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 100 })], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed', {}, { x: 0, y: 100 })], + }); + // Select p1 — no offsets should be computed + const nodes = convertToReactFlowNodes([p1, p2], 'p1'); + const t1 = nodes.find((n) => n.id === 'p1:t1')!; + expect(t1.position.y).toBe(100); + }); + + // ── Drag handle ────────────────────────────────────────────────────────── + + it('all rendered nodes have dragHandle set', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + }); + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + for (const node of nodes) { + expect(node.dragHandle).toBe('.drag-handle'); + } + }); +}); + +// ─── convertToReactFlowEdges ────────────────────────────────────────────────── + +describe('convertToReactFlowEdges', () => { + it('returns empty array for pipelines with no edges', () => { + const pipelines = [makePipeline('p1'), makePipeline('p2')]; + expect(convertToReactFlowEdges(pipelines, null)).toEqual([]); + }); + + it('creates edge with composite source/target ids', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1'); + expect(edges).toHaveLength(1); + expect(edges[0].id).toBe('p1:e1'); + expect(edges[0].source).toBe('p1:t1'); + expect(edges[0].target).toBe('p1:a1'); + expect(edges[0].type).toBe('pipeline'); + }); + + it('marks edges from selected pipeline as isActivePipeline=true', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1'); + expect((edges[0].data as { isActivePipeline: boolean }).isActivePipeline).toBe(true); + }); + + it('excludes edges from non-selected pipeline', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed'), makeAgent('a2', 'sess-2', 'Bob')], + edges: [makeEdge('e2', 't2', 'a2')], + }); + const edges = convertToReactFlowEdges([p1, p2], 'p2'); + // Non-active pipeline edges are excluded to prevent orphaned edges + // (their source/target nodes are not rendered by convertToReactFlowNodes) + expect(edges.find((e) => e.id === 'p1:e1')).toBeUndefined(); + const e2 = edges.find((e) => e.id === 'p2:e2')!; + expect((e2.data as { isActivePipeline: boolean }).isActivePipeline).toBe(true); + }); + + it('marks all edges as active in All Pipelines view', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed'), makeAgent('a2', 'sess-2', 'Bob')], + edges: [makeEdge('e2', 't2', 'a2')], + }); + const edges = convertToReactFlowEdges([p1, p2], null); + for (const edge of edges) { + expect((edge.data as { isActivePipeline: boolean }).isActivePipeline).toBe(true); + } + }); + + it('marks edge as selected when its id matches selectedEdgeId', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1', undefined, 'p1:e1'); + expect(edges[0].selected).toBe(true); + }); + + it('does not mark edge as selected when id does not match', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1', undefined, 'p1:e2'); + expect(edges[0].selected).toBe(false); + }); + + it('marks edge data as isRunning when pipeline is in runningPipelineIds', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const running = new Set(['p1']); + const edges = convertToReactFlowEdges([pipeline], 'p1', running); + expect((edges[0].data as { isRunning: boolean }).isRunning).toBe(true); + }); + + it('does not mark edge as running when pipeline is not in runningPipelineIds', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const running = new Set(['p2']); + const edges = convertToReactFlowEdges([pipeline], 'p1', running); + expect((edges[0].data as { isRunning: boolean }).isRunning).toBe(false); + }); + + it('carries pipeline color on edge data', () => { + const pipeline = makePipeline('p1', { + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1'); + expect((edges[0].data as { pipelineColor: string }).pipelineColor).toBe('#ef4444'); + }); + + it('selected edge gets larger marker than unselected', () => { + const pipeline = makePipeline('p1', { + nodes: [ + makeTrigger('t1', 'time.heartbeat'), + makeAgent('a1', 'sess-1', 'Alice'), + makeAgent('a2', 'sess-2', 'Bob'), + ], + edges: [makeEdge('e1', 't1', 'a1'), makeEdge('e2', 'a1', 'a2')], + }); + const edges = convertToReactFlowEdges([pipeline], 'p1', undefined, 'p1:e1'); + const e1 = edges.find((e) => e.id === 'p1:e1')!; + const e2 = edges.find((e) => e.id === 'p1:e2')!; + const e1Marker = e1.markerEnd as { width: number; height: number }; + const e2Marker = e2.markerEnd as { width: number; height: number }; + expect(e1Marker.width).toBeGreaterThan(e2Marker.width); + expect(e1Marker.height).toBeGreaterThan(e2Marker.height); + }); + + it('renders edges from multiple pipelines in All Pipelines view', () => { + const p1 = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat'), makeAgent('a1', 'sess-1', 'Alice')], + edges: [makeEdge('e1', 't1', 'a1')], + }); + const p2 = makePipeline('p2', { + nodes: [makeTrigger('t2', 'file.changed'), makeAgent('a2', 'sess-2', 'Bob')], + edges: [makeEdge('e2', 't2', 'a2')], + }); + const edges = convertToReactFlowEdges([p1, p2], null); + expect(edges).toHaveLength(2); + expect(edges.map((e) => e.id)).toContain('p1:e1'); + expect(edges.map((e) => e.id)).toContain('p2:e2'); + }); +}); + +describe('convertToReactFlowNodes triggerOptions', () => { + it('passes trigger options to trigger node data', () => { + const onTriggerPipeline = vi.fn(); + const runningPipelineIds = new Set(['p1']); + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p1', undefined, { + onTriggerPipeline, + isSaved: true, + runningPipelineIds, + }); + + expect(nodes).toHaveLength(1); + const triggerData = nodes[0].data as any; + expect(triggerData.onTriggerPipeline).toBe(onTriggerPipeline); + expect(triggerData.pipelineName).toBe('Pipeline p1'); + expect(triggerData.isSaved).toBe(true); + expect(triggerData.isRunning).toBe(true); + }); + + it('sets isRunning to false when pipeline is not in runningPipelineIds', () => { + const pipeline = makePipeline('p2', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p2', undefined, { + onTriggerPipeline: vi.fn(), + isSaved: true, + runningPipelineIds: new Set(['other']), + }); + + const triggerData = nodes[0].data as any; + expect(triggerData.isRunning).toBe(false); + }); + + it('does not include trigger options when not provided', () => { + const pipeline = makePipeline('p1', { + nodes: [makeTrigger('t1', 'time.heartbeat')], + }); + + const nodes = convertToReactFlowNodes([pipeline], 'p1'); + const triggerData = nodes[0].data as any; + expect(triggerData.onTriggerPipeline).toBeUndefined(); + expect(triggerData.pipelineName).toBe('Pipeline p1'); + expect(triggerData.isSaved).toBeUndefined(); + expect(triggerData.isRunning).toBeUndefined(); + }); +}); + +// ─── computePipelineYOffsets ──────────────────────────────────────────────── + +describe('computePipelineYOffsets', () => { + it('returns empty map when a pipeline is selected', () => { + const pipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'P1', + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 50 })], + edges: [], + }, + { + id: 'p2', + name: 'P2', + color: '#3b82f6', + nodes: [makeTrigger('t2', 'file.changed', {}, { x: 0, y: 100 })], + edges: [], + }, + ]; + const offsets = computePipelineYOffsets(pipelines, 'p1'); + expect(offsets.size).toBe(0); + }); + + it('returns empty map when there is only one pipeline', () => { + const pipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'P1', + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat')], + edges: [], + }, + ]; + const offsets = computePipelineYOffsets(pipelines, null); + expect(offsets.size).toBe(0); + }); + + it('computes offsets so pipelines stack without overlap', () => { + const pipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'P1', + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 0 })], + edges: [], + }, + { + id: 'p2', + name: 'P2', + color: '#3b82f6', + nodes: [makeTrigger('t2', 'file.changed', {}, { x: 0, y: 0 })], + edges: [], + }, + ]; + const offsets = computePipelineYOffsets(pipelines, null); + expect(offsets.get('p1')).toBe(0); // first pipeline starts at y=0 + expect(offsets.get('p2')).toBeGreaterThan(0); // second pipeline is pushed down + }); + + it('skips empty pipelines', () => { + const pipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'P1', + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 0 })], + edges: [], + }, + { id: 'p2', name: 'P2', color: '#3b82f6', nodes: [], edges: [] }, + { + id: 'p3', + name: 'P3', + color: '#22c55e', + nodes: [makeTrigger('t3', 'file.changed', {}, { x: 0, y: 0 })], + edges: [], + }, + ]; + const offsets = computePipelineYOffsets(pipelines, null); + expect(offsets.has('p2')).toBe(false); + expect(offsets.has('p1')).toBe(true); + expect(offsets.has('p3')).toBe(true); + }); + + it('produces offsets consistent with convertToReactFlowNodes', () => { + const pipelines: CuePipeline[] = [ + { + id: 'p1', + name: 'P1', + color: '#ef4444', + nodes: [makeTrigger('t1', 'time.heartbeat', {}, { x: 0, y: 10 })], + edges: [], + }, + { + id: 'p2', + name: 'P2', + color: '#3b82f6', + nodes: [makeTrigger('t2', 'file.changed', {}, { x: 0, y: 20 })], + edges: [], + }, + ]; + const offsets = computePipelineYOffsets(pipelines, null); + const nodes = convertToReactFlowNodes(pipelines, null); + + // The ReactFlow node position should equal canonical position + offset + const p1Node = nodes.find((n) => n.id === 'p1:t1')!; + const p2Node = nodes.find((n) => n.id === 'p2:t2')!; + expect(p1Node.position.y).toBe(10 + (offsets.get('p1') ?? 0)); + expect(p2Node.position.y).toBe(20 + (offsets.get('p2') ?? 0)); + }); +}); 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 0000000000..b4fb632e46 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineLayout.test.ts @@ -0,0 +1,190 @@ +/** + * 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('restores saved color and name over live-derived values', () => { + // Live pipelines get colors from parse order (the bug scenario) + const livePipelines = [ + makePipeline({ id: 'p1', name: 'Pipeline 1', color: '#06b6d4' }), + makePipeline({ id: 'p2', name: 'Pipeline 2', color: '#8b5cf6' }), + ]; + // Saved layout has different (original) colors and names + const savedLayout: PipelineLayoutState = { + pipelines: [ + makePipeline({ id: 'p1', name: 'My Custom Name', color: '#ef4444' }), + makePipeline({ id: 'p2', name: 'Another Name', color: '#22c55e' }), + ], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + expect(result.pipelines[0].name).toBe('My Custom Name'); + expect(result.pipelines[0].color).toBe('#ef4444'); + expect(result.pipelines[1].name).toBe('Another Name'); + expect(result.pipelines[1].color).toBe('#22c55e'); + }); + + it('keeps live color and name when no saved layout match exists', () => { + const livePipelines = [makePipeline({ id: 'p-new', name: 'Brand New', color: '#3b82f6' })]; + const savedLayout: PipelineLayoutState = { + pipelines: [makePipeline({ id: 'p-old', name: 'Old One', color: '#ef4444' })], + selectedPipelineId: null, + }; + + const result = mergePipelinesWithSavedLayout(livePipelines, savedLayout); + + // No matching ID in saved layout — live values preserved + expect(result.pipelines[0].name).toBe('Brand New'); + expect(result.pipelines[0].color).toBe('#3b82f6'); + }); + + 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 0000000000..d40d00d4d8 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/pipelineToYaml.test.ts @@ -0,0 +1,886 @@ +/** + * 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, + ensureSourceOutputVariable, +} 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('{{CUE_SOURCE_OUTPUT}}\n\nTest 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('{{CUE_SOURCE_OUTPUT}}\n\nCombine'); + }); + + 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 + }); +}); + +describe('includeUpstreamOutput toggle', () => { + it('respects includeUpstreamOutput: false by not injecting variable', () => { + 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: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Test only', + includeUpstreamOutput: false, + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[1].prompt).toBe('Test only'); + }); + + it('injects variable when includeUpstreamOutput is explicitly true', () => { + 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: 's1', + sessionName: 'builder', + toolType: 'claude-code', + inputPrompt: 'Build', + }, + }, + { + id: 'a2', + type: 'agent', + position: { x: 600, y: 0 }, + data: { + sessionId: 's2', + sessionName: 'tester', + toolType: 'claude-code', + inputPrompt: 'Test it', + includeUpstreamOutput: true, + }, + }, + ], + edges: [ + { id: 'e1', source: 't1', target: 'a1', mode: 'pass' }, + { id: 'e2', source: 'a1', target: 'a2', mode: 'pass' }, + ], + }); + + const subs = pipelineToYamlSubscriptions(pipeline); + expect(subs[1].prompt).toBe('{{CUE_SOURCE_OUTPUT}}\n\nTest it'); + }); +}); + +describe('ensureSourceOutputVariable', () => { + it('auto-injects when prompt is missing it', () => { + expect(ensureSourceOutputVariable('Review the code')).toBe( + '{{CUE_SOURCE_OUTPUT}}\n\nReview the code' + ); + }); + + it('preserves existing {{CUE_SOURCE_OUTPUT}} in prompt', () => { + const prompt = 'Here is the output: {{CUE_SOURCE_OUTPUT}}\n\nNow review.'; + expect(ensureSourceOutputVariable(prompt)).toBe(prompt); + }); + + it('returns bare variable for empty prompt', () => { + expect(ensureSourceOutputVariable('')).toBe('{{CUE_SOURCE_OUTPUT}}'); + }); + + it('returns bare variable for whitespace-only prompt', () => { + expect(ensureSourceOutputVariable(' ')).toBe('{{CUE_SOURCE_OUTPUT}}'); + }); + + it('case-insensitive check avoids double injection', () => { + const prompt = 'Use {{cue_source_output}} here'; + expect(ensureSourceOutputVariable(prompt)).toBe(prompt); + }); +}); 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 0000000000..e14cde1b23 --- /dev/null +++ b/src/__tests__/renderer/components/CuePipelineEditor/utils/yamlToPipeline.test.ts @@ -0,0 +1,878 @@ +/** + * 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('creates separate nodes when the same agent appears twice in a chain (A → B → A)', () => { + const subs: CueSubscription[] = [ + { + name: 'loop-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Start', + interval_minutes: 10, + agent_id: 'session-0', + }, + { + name: 'loop-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'Middle step', + source_session: 'alpha', + agent_id: 'session-1', + }, + { + name: 'loop-test-chain-2', + event: 'agent.completed', + enabled: true, + prompt: 'Final step', + source_session: 'beta', + agent_id: 'session-0', + }, + ]; + const sessions = makeSessions('alpha', 'beta'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const alphaNodes = agents.filter( + (a) => (a.data as { sessionName: string }).sessionName === 'alpha' + ); + + // Should have TWO distinct nodes for "alpha", not one + expect(alphaNodes).toHaveLength(2); + expect(alphaNodes[0].id).not.toBe(alphaNodes[1].id); + + // Should have 3 edges: trigger→alpha, alpha→beta, beta→alpha(2nd) + expect(pipelines[0].edges).toHaveLength(3); + + // The last edge should connect beta → alpha(2nd), not create a self-edge + const lastEdge = pipelines[0].edges[2]; + const betaNode = agents.find( + (a) => (a.data as { sessionName: string }).sessionName === 'beta' + )!; + expect(lastEdge.source).toBe(betaNode.id); + expect(lastEdge.target).toBe(alphaNodes[1].id); + expect(lastEdge.source).not.toBe(lastEdge.target); + }); + + it('connects edges correctly when same agent is consecutive (A → B → B)', () => { + const subs: CueSubscription[] = [ + { + name: 'consec-test', + event: 'time.heartbeat', + enabled: true, + prompt: 'Start', + interval_minutes: 10, + agent_id: 'session-0', + }, + { + name: 'consec-test-chain-1', + event: 'agent.completed', + enabled: true, + prompt: 'First pass', + source_session: 'opencode', + agent_id: 'session-1', + }, + { + name: 'consec-test-chain-2', + event: 'agent.completed', + enabled: true, + prompt: 'Second pass', + source_session: 'claude', + agent_id: 'session-1', + }, + ]; + const sessions = makeSessions('opencode', 'claude'); + + const pipelines = subscriptionsToPipelines(subs, sessions); + expect(pipelines).toHaveLength(1); + + const agents = pipelines[0].nodes.filter((n) => n.type === 'agent'); + const claudeNodes = agents.filter( + (a) => (a.data as { sessionName: string }).sessionName === 'claude' + ); + + // Two distinct nodes for "claude" + expect(claudeNodes).toHaveLength(2); + + // 3 edges: trigger→opencode, opencode→claude(1), claude(1)→claude(2) + expect(pipelines[0].edges).toHaveLength(3); + + // Edge from first claude → second claude (not a self-edge) + const lastEdge = pipelines[0].edges[2]; + expect(lastEdge.source).toBe(claudeNodes[0].id); + expect(lastEdge.target).toBe(claudeNodes[1].id); + expect(lastEdge.source).not.toBe(lastEdge.target); + }); + + 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'); + }); +}); + +describe('auto-injected source output prefix stripping', () => { + it('strips auto-injected {{CUE_SOURCE_OUTPUT}} prefix from chain prompt', () => { + const subs: CueSubscription[] = [ + { + name: 'pipe', + event: 'file.changed', + enabled: true, + watch: '**/*', + prompt: 'Build', + agent_id: 's1', + }, + { + name: 'pipe-chain-1', + event: 'agent.completed', + enabled: true, + source_session: 'builder', + prompt: '{{CUE_SOURCE_OUTPUT}}\n\nTest it', + agent_id: 's2', + }, + ]; + const sessions: SessionInfo[] = [ + { id: 's1', name: 'builder', toolType: 'claude-code', workingDirectory: '' }, + { id: 's2', name: 'tester', toolType: 'claude-code', workingDirectory: '' }, + ]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + const testerNode = pipelines[0].nodes.find( + (n) => n.type === 'agent' && (n.data as { sessionName: string }).sessionName === 'tester' + ); + expect(testerNode).toBeDefined(); + expect((testerNode!.data as { inputPrompt?: string }).inputPrompt).toBe('Test it'); + }); + + it('preserves manually placed {{CUE_SOURCE_OUTPUT}} in middle of prompt', () => { + const subs: CueSubscription[] = [ + { + name: 'pipe', + event: 'file.changed', + enabled: true, + watch: '**/*', + prompt: 'Build', + agent_id: 's1', + }, + { + name: 'pipe-chain-1', + event: 'agent.completed', + enabled: true, + source_session: 'builder', + prompt: 'Review this: {{CUE_SOURCE_OUTPUT}} and summarize', + agent_id: 's2', + }, + ]; + const sessions: SessionInfo[] = [ + { id: 's1', name: 'builder', toolType: 'claude-code', workingDirectory: '' }, + { id: 's2', name: 'tester', toolType: 'claude-code', workingDirectory: '' }, + ]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + const testerNode = pipelines[0].nodes.find( + (n) => n.type === 'agent' && (n.data as { sessionName: string }).sessionName === 'tester' + ); + expect((testerNode!.data as { inputPrompt?: string }).inputPrompt).toBe( + 'Review this: {{CUE_SOURCE_OUTPUT}} and summarize' + ); + }); + + it('sets inputPrompt to undefined when prompt is only the auto-injected variable', () => { + const subs: CueSubscription[] = [ + { + name: 'pipe', + event: 'file.changed', + enabled: true, + watch: '**/*', + prompt: 'Build', + agent_id: 's1', + }, + { + name: 'pipe-chain-1', + event: 'agent.completed', + enabled: true, + source_session: 'builder', + prompt: '{{CUE_SOURCE_OUTPUT}}\n\n', + agent_id: 's2', + }, + ]; + const sessions: SessionInfo[] = [ + { id: 's1', name: 'builder', toolType: 'claude-code', workingDirectory: '' }, + { id: 's2', name: 'tester', toolType: 'claude-code', workingDirectory: '' }, + ]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + const testerNode = pipelines[0].nodes.find( + (n) => n.type === 'agent' && (n.data as { sessionName: string }).sessionName === 'tester' + ); + expect((testerNode!.data as { inputPrompt?: string }).inputPrompt).toBeUndefined(); + }); + + it('strips bare {{CUE_SOURCE_OUTPUT}} token without trailing newlines', () => { + const subs: CueSubscription[] = [ + { + name: 'pipe', + event: 'file.changed', + enabled: true, + watch: '**/*', + prompt: 'Build', + agent_id: 's1', + }, + { + name: 'pipe-chain-1', + event: 'agent.completed', + enabled: true, + source_session: 'builder', + prompt: '{{CUE_SOURCE_OUTPUT}}', + agent_id: 's2', + }, + ]; + const sessions: SessionInfo[] = [ + { id: 's1', name: 'builder', toolType: 'claude-code', workingDirectory: '' }, + { id: 's2', name: 'tester', toolType: 'claude-code', workingDirectory: '' }, + ]; + + const pipelines = subscriptionsToPipelines(subs, sessions); + const testerNode = pipelines[0].nodes.find( + (n) => n.type === 'agent' && (n.data as { sessionName: string }).sessionName === 'tester' + ); + expect((testerNode!.data as { inputPrompt?: string }).inputPrompt).toBeUndefined(); + }); +}); diff --git a/src/__tests__/renderer/components/CueYamlEditor.test.tsx b/src/__tests__/renderer/components/CueYamlEditor.test.tsx new file mode 100644 index 0000000000..9a857b7acf --- /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/DirectorNotes/AIOverviewTab.test.tsx b/src/__tests__/renderer/components/DirectorNotes/AIOverviewTab.test.tsx index d55c3c384c..e7d1ce606c 100644 --- a/src/__tests__/renderer/components/DirectorNotes/AIOverviewTab.test.tsx +++ b/src/__tests__/renderer/components/DirectorNotes/AIOverviewTab.test.tsx @@ -79,6 +79,7 @@ beforeEach(() => { (window as any).maestro = { directorNotes: { generateSynopsis: mockGenerateSynopsis, + onSynopsisProgress: () => () => {}, }, }; @@ -101,7 +102,7 @@ describe('AIOverviewTab', () => { // Should show generating state - text appears in both progress bar and spinner await waitFor(() => { - const elements = screen.getAllByText(/Generating synopsis/); + const elements = screen.getAllByText(/Starting/); expect(elements.length).toBeGreaterThan(0); }); }); diff --git a/src/__tests__/renderer/components/DirectorNotes/DirectorNotesModal.test.tsx b/src/__tests__/renderer/components/DirectorNotes/DirectorNotesModal.test.tsx index 141320343f..c453773d23 100644 --- a/src/__tests__/renderer/components/DirectorNotes/DirectorNotesModal.test.tsx +++ b/src/__tests__/renderer/components/DirectorNotes/DirectorNotesModal.test.tsx @@ -51,12 +51,23 @@ vi.mock('../../../../renderer/components/DirectorNotes/UnifiedHistoryTab', () => })); vi.mock('../../../../renderer/components/DirectorNotes/AIOverviewTab', () => ({ - AIOverviewTab: ({ theme, onSynopsisReady }: { theme: Theme; onSynopsisReady?: () => void }) => ( + AIOverviewTab: ({ + theme, + onSynopsisReady, + onProgressChange, + }: { + theme: Theme; + onSynopsisReady?: () => void; + onProgressChange?: (percent: number) => void; + }) => (
AI Overview Content +
), hasCachedSynopsis: () => false, @@ -520,6 +531,22 @@ describe('DirectorNotesModal', () => { expect(screen.queryByText('(generating...)')).not.toBeInTheDocument(); }); + it('shows progress percentage in tab indicator when progress updates', async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText('(generating...)')).toBeInTheDocument(); + }); + + // Trigger progress update + await act(async () => { + fireEvent.click(screen.getByTestId('trigger-progress')); + }); + + expect(screen.getByText('(42%)')).toBeInTheDocument(); + expect(screen.queryByText('(generating...)')).not.toBeInTheDocument(); + }); + it('enables AI Overview tab when synopsis is ready', async () => { renderModal(); diff --git a/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx b/src/__tests__/renderer/components/DirectorNotes/UnifiedHistoryTab.test.tsx index c4c1cf7655..5752e31336 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/FeedbackChatView.test.tsx b/src/__tests__/renderer/components/FeedbackChatView.test.tsx new file mode 100644 index 0000000000..934bc0918d --- /dev/null +++ b/src/__tests__/renderer/components/FeedbackChatView.test.tsx @@ -0,0 +1,153 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { FeedbackChatView } from '../../../renderer/components/FeedbackChatView'; +import type { Theme, Session } from '../../../renderer/types'; + +const theme: Theme = { + id: 'test-dark', + name: 'Test Dark', + mode: 'dark', + colors: { + bgMain: '#101322', + bgSidebar: '#14192d', + bgActivity: '#1b2140', + textMain: '#f5f7ff', + textDim: '#8d96b8', + accent: '#8b5cf6', + accentForeground: '#ffffff', + border: '#2a3154', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, +} as Theme; + +const sessions = [ + { + id: 'session-1', + name: 'Agent 1', + toolType: 'claude-code', + state: 'idle', + cwd: '/tmp', + } as Session, +]; + +describe('FeedbackChatView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows GH CLI error when gh is not available', async () => { + window.maestro.feedback.checkGhAuth.mockResolvedValue({ + authenticated: false, + message: 'GitHub CLI (gh) is not installed.', + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('GitHub CLI Required')).toBeTruthy(); + }); + }); + + it('shows provider selection when gh is authenticated', async () => { + window.maestro.feedback.checkGhAuth.mockResolvedValue({ authenticated: true }); + window.maestro.agents.detect.mockResolvedValue([ + { id: 'claude-code', name: 'Claude Code', available: true }, + ]); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Start')).toBeTruthy(); + }); + }); + + it('shows loading spinner during GH auth check', () => { + window.maestro.feedback.checkGhAuth.mockReturnValue(new Promise(() => {})); // Never resolves + + render( + + ); + + expect(screen.getByText('Checking GitHub CLI...')).toBeTruthy(); + }); + + it('starts chat without a loading spinner when provider is selected', async () => { + window.maestro.feedback.checkGhAuth.mockResolvedValue({ authenticated: true }); + window.maestro.agents.detect.mockResolvedValue([ + { id: 'claude-code', name: 'Claude Code', available: true }, + ]); + window.maestro.feedback.getConversationPrompt.mockResolvedValue({ + prompt: 'system prompt', + environment: '- Maestro version: 1.0.0', + }); + + render( + + ); + + // Wait for provider selection screen + await waitFor(() => { + expect(screen.getByText('Start')).toBeTruthy(); + }); + + // Click Start + screen.getByText('Start').click(); + + // Chat should appear with input but no spinner + await waitFor(() => { + expect(screen.getByPlaceholderText('Describe your issue or idea...')).toBeTruthy(); + }); + + // No loading spinner should be visible + expect(screen.queryByText('Checking GitHub CLI...')).toBeNull(); + }); + + it('calls onCancel when Close button is clicked on GH error', async () => { + const onCancel = vi.fn(); + window.maestro.feedback.checkGhAuth.mockResolvedValue({ + authenticated: false, + message: 'Not installed.', + }); + + render( + + ); + + await waitFor(() => { + screen.getByText('Close').click(); + }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx index 068755bbc3..78c9549109 100644 --- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx +++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx @@ -66,6 +66,11 @@ vi.mock('lucide-react', () => ({ 🔗 ), + FolderOpen: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + 📂 + + ), FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( 📄 @@ -247,6 +252,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 cb4e246773..80aa6330bb 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -26,6 +26,9 @@ vi.mock('lucide-react', () => ({ ExternalLink: () => ExternalLink, RefreshCw: () => RefreshCw, X: () => X, + ZoomIn: () => ZoomIn, + ZoomOut: () => ZoomOut, + Maximize2: () => Maximize2, })); // Mock react-markdown @@ -165,6 +168,7 @@ vi.mock('../../../shared/gitUtils', () => ({ })); const mockTheme = { + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/FilePreview/filePreviewUtils.test.ts b/src/__tests__/renderer/components/FilePreview/filePreviewUtils.test.ts new file mode 100644 index 0000000000..9f607ab4ae --- /dev/null +++ b/src/__tests__/renderer/components/FilePreview/filePreviewUtils.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect } from 'vitest'; +import { + getLanguageFromFilename, + isBinaryContent, + isBinaryExtension, + formatFileSize, + formatDateTime, + countMarkdownTasks, + extractHeadings, + resolveImagePath, + LARGE_FILE_TOKEN_SKIP_THRESHOLD, + LARGE_FILE_PREVIEW_LIMIT, +} from '../../../../renderer/components/FilePreview/filePreviewUtils'; + +describe('filePreviewUtils', () => { + describe('getLanguageFromFilename', () => { + it('returns typescript for .ts files', () => { + expect(getLanguageFromFilename('index.ts')).toBe('typescript'); + }); + + it('returns tsx for .tsx files', () => { + expect(getLanguageFromFilename('App.tsx')).toBe('tsx'); + }); + + it('returns javascript for .js files', () => { + expect(getLanguageFromFilename('main.js')).toBe('javascript'); + }); + + it('returns markdown for .md files', () => { + expect(getLanguageFromFilename('README.md')).toBe('markdown'); + }); + + it('returns python for .py files', () => { + expect(getLanguageFromFilename('script.py')).toBe('python'); + }); + + it('returns yaml for .yml files', () => { + expect(getLanguageFromFilename('config.yml')).toBe('yaml'); + }); + + it('returns csv for .csv files', () => { + expect(getLanguageFromFilename('data.csv')).toBe('csv'); + }); + + it('returns text for unknown extensions', () => { + expect(getLanguageFromFilename('file.xyz')).toBe('text'); + }); + + it('returns text for files with no extension', () => { + expect(getLanguageFromFilename('Makefile')).toBe('text'); + }); + }); + + describe('isBinaryContent', () => { + it('detects null bytes as binary', () => { + expect(isBinaryContent('hello\0world')).toBe(true); + }); + + it('returns false for normal text', () => { + expect(isBinaryContent('Hello, world!\nThis is text.')).toBe(false); + }); + + it('returns false for empty content', () => { + expect(isBinaryContent('')).toBe(false); + }); + + it('allows common whitespace (tab, newline, carriage return)', () => { + expect(isBinaryContent('hello\tworld\r\n')).toBe(false); + }); + + it('detects high non-printable ratio as binary', () => { + // Create content with >10% non-printable characters + const binary = String.fromCharCode(1).repeat(20) + 'a'.repeat(80); + expect(isBinaryContent(binary)).toBe(true); + }); + + it('allows low non-printable ratio as text', () => { + // Less than 10% non-printable + const almostText = String.fromCharCode(1).repeat(5) + 'a'.repeat(100); + expect(isBinaryContent(almostText)).toBe(false); + }); + }); + + describe('isBinaryExtension', () => { + it('returns true for image-related extensions', () => { + expect(isBinaryExtension('icon.icns')).toBe(true); + expect(isBinaryExtension('assets.car')).toBe(true); + }); + + it('returns true for compiled files', () => { + expect(isBinaryExtension('module.o')).toBe(true); + expect(isBinaryExtension('lib.so')).toBe(true); + expect(isBinaryExtension('Main.class')).toBe(true); + expect(isBinaryExtension('module.wasm')).toBe(true); + }); + + it('returns true for archives', () => { + expect(isBinaryExtension('archive.zip')).toBe(true); + expect(isBinaryExtension('backup.tar')).toBe(true); + expect(isBinaryExtension('data.gz')).toBe(true); + }); + + it('returns true for fonts', () => { + expect(isBinaryExtension('font.ttf')).toBe(true); + expect(isBinaryExtension('font.woff2')).toBe(true); + }); + + it('returns false for text files', () => { + expect(isBinaryExtension('index.ts')).toBe(false); + expect(isBinaryExtension('README.md')).toBe(false); + expect(isBinaryExtension('styles.css')).toBe(false); + }); + + it('returns false for files with no extension', () => { + expect(isBinaryExtension('Makefile')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isBinaryExtension('file.ZIP')).toBe(true); + }); + }); + + describe('formatFileSize', () => { + it('formats 0 bytes', () => { + expect(formatFileSize(0)).toBe('0 B'); + }); + + it('formats bytes', () => { + expect(formatFileSize(512)).toBe('512 B'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(1048576)).toBe('1 MB'); + }); + + it('formats gigabytes', () => { + expect(formatFileSize(1073741824)).toBe('1 GB'); + }); + }); + + describe('formatDateTime', () => { + it('formats an ISO date string', () => { + const result = formatDateTime('2024-01-15T10:30:00Z'); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + // The exact format depends on locale, but it should contain the year + expect(result).toContain('2024'); + }); + }); + + describe('countMarkdownTasks', () => { + it('counts open and closed tasks', () => { + const content = ` +- [ ] Todo 1 +- [x] Done 1 +- [ ] Todo 2 +- [X] Done 2 + `; + const result = countMarkdownTasks(content); + expect(result.open).toBe(2); + expect(result.closed).toBe(2); + }); + + it('returns 0 for no tasks', () => { + const result = countMarkdownTasks('Just plain text'); + expect(result.open).toBe(0); + expect(result.closed).toBe(0); + }); + + it('handles asterisk-style tasks', () => { + const content = '* [ ] open\n* [x] closed'; + const result = countMarkdownTasks(content); + expect(result.open).toBe(1); + expect(result.closed).toBe(1); + }); + + it('handles indented tasks', () => { + const content = ' - [ ] indented open\n - [x] indented closed'; + const result = countMarkdownTasks(content); + expect(result.open).toBe(1); + expect(result.closed).toBe(1); + }); + + it('ignores tasks inside backtick code fences', () => { + const content = '- [ ] real\n```\n- [ ] fake\n- [x] also fake\n```\n- [x] also real'; + const result = countMarkdownTasks(content); + expect(result.open).toBe(1); + expect(result.closed).toBe(1); + }); + + it('ignores tasks inside tilde code fences', () => { + const content = '~~~\n- [ ] inside fence\n~~~\n- [ ] outside'; + const result = countMarkdownTasks(content); + expect(result.open).toBe(1); + expect(result.closed).toBe(0); + }); + }); + + describe('extractHeadings', () => { + it('extracts ATX-style headings', () => { + const content = '# H1\n## H2\n### H3'; + const result = extractHeadings(content); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ level: 1, text: 'H1', slug: 'h1' }); + expect(result[1]).toEqual({ level: 2, text: 'H2', slug: 'h2' }); + expect(result[2]).toEqual({ level: 3, text: 'H3', slug: 'h3' }); + }); + + it('ignores headings inside code fences', () => { + const content = '# Real\n```\n# Not a heading\n```\n## Also real'; + const result = extractHeadings(content); + expect(result).toHaveLength(2); + expect(result[0].text).toBe('Real'); + expect(result[1].text).toBe('Also real'); + }); + + it('handles tilde code fences', () => { + const content = '# Before\n~~~\n# Inside\n~~~\n# After'; + const result = extractHeadings(content); + expect(result).toHaveLength(2); + }); + + it('returns empty array for no headings', () => { + expect(extractHeadings('Just text')).toHaveLength(0); + }); + + it('generates unique slugs for duplicate headings', () => { + const content = '# Title\n# Title\n# Title'; + const result = extractHeadings(content); + expect(result).toHaveLength(3); + expect(result[0].slug).toBe('title'); + expect(result[1].slug).toBe('title-1'); + expect(result[2].slug).toBe('title-2'); + }); + }); + + describe('resolveImagePath', () => { + it('returns data URLs as-is', () => { + expect(resolveImagePath('data:image/png;base64,abc', '/docs/readme.md')).toBe( + 'data:image/png;base64,abc' + ); + }); + + it('returns http URLs as-is', () => { + expect(resolveImagePath('https://example.com/img.png', '/docs/readme.md')).toBe( + 'https://example.com/img.png' + ); + }); + + it('returns absolute paths as-is', () => { + expect(resolveImagePath('/absolute/path.png', '/docs/readme.md')).toBe('/absolute/path.png'); + }); + + it('resolves relative paths from markdown directory', () => { + expect(resolveImagePath('images/photo.png', '/project/docs/readme.md')).toBe( + '/project/docs/images/photo.png' + ); + }); + + it('handles ./ prefix', () => { + expect(resolveImagePath('./images/photo.png', '/project/docs/readme.md')).toBe( + '/project/docs/images/photo.png' + ); + }); + + it('resolves ../ paths by normalization', () => { + expect(resolveImagePath('../assets/img.png', '/project/docs/readme.md')).toBe( + '/project/assets/img.png' + ); + }); + + it('resolves deeply nested ../ paths', () => { + expect(resolveImagePath('../../img.png', '/a/b/c/readme.md')).toBe('/a/img.png'); + }); + }); + + describe('constants', () => { + it('LARGE_FILE_TOKEN_SKIP_THRESHOLD is 1MB', () => { + expect(LARGE_FILE_TOKEN_SKIP_THRESHOLD).toBe(1024 * 1024); + }); + + it('LARGE_FILE_PREVIEW_LIMIT is 100KB', () => { + expect(LARGE_FILE_PREVIEW_LIMIT).toBe(100 * 1024); + }); + }); +}); diff --git a/src/__tests__/renderer/components/GroupChatInput.test.tsx b/src/__tests__/renderer/components/GroupChatInput.test.tsx index eff4a1db95..3c5d559d92 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/ActivityGraph.test.tsx b/src/__tests__/renderer/components/History/ActivityGraph.test.tsx index b732dc3a9b..b4c17ca159 100644 --- a/src/__tests__/renderer/components/History/ActivityGraph.test.tsx +++ b/src/__tests__/renderer/components/History/ActivityGraph.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { ActivityGraph } from '../../../../renderer/components/History'; import type { Theme, HistoryEntry, HistoryEntryType } from '../../../../renderer/types'; @@ -325,4 +325,162 @@ describe('ActivityGraph', () => { const graphContainer = screen.getByTitle(/1 auto, 1 user/); expect(graphContainer).toBeInTheDocument(); }); + + it('counts CUE entries in buckets', () => { + const entries = [ + createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 45 * 60 * 1000 }), + createMockEntry({ type: 'AUTO', timestamp: NOW - 35 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + // Hover over the last bucket to see tooltip + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + // Should show Cue row in tooltip with count scoped to the Cue row + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toBeInTheDocument(); + const cueRow = cueLabel.closest('div')!; + expect(within(cueRow).getByText('2')).toBeInTheDocument(); + }); + + it('shows Cue row with zero count in tooltip when bucket has no CUE entries', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'USER', timestamp: NOW - 35 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + // All three rows should appear, Cue with 0 + expect(screen.getByText('Auto')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toBeInTheDocument(); + const cueRow = cueLabel.closest('div')!; + expect(within(cueRow).getByText('0')).toBeInTheDocument(); + }); + + it('includes CUE count in summary title when present', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 2 * 60 * 60 * 1000 }), + ]; + + render( + + ); + + const graphContainer = screen.getByTitle(/1 auto, 0 user, 1 cue/); + expect(graphContainer).toBeInTheDocument(); + }); + + it('excludes CUE count from summary title when zero', () => { + const entries = [createMockEntry({ type: 'AUTO', timestamp: NOW - 1 * 60 * 60 * 1000 })]; + + render( + + ); + + // Title should NOT contain "cue" + const graphContainer = screen.getByTitle(/1 auto, 0 user \(right-click/); + expect(graphContainer).toBeInTheDocument(); + }); + + it('makes CUE-only bars clickable', () => { + const onBarClick = vi.fn(); + const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })]; + + const { container } = render( + + ); + + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.click(lastBar); + + expect(onBarClick).toHaveBeenCalledWith(expect.any(Number), expect.any(Number)); + }); + + it('renders CUE bar segment with correct color in tooltip', () => { + const entries = [createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 })]; + + const { container } = render( + + ); + + // Hover to show tooltip, then verify CUE label uses the cyan color + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + fireEvent.mouseEnter(lastBar); + + const cueLabel = screen.getByText('Cue'); + expect(cueLabel).toHaveStyle({ color: '#06b6d4' }); + }); + + it('scales bar height correctly with mixed AUTO, USER, and CUE entries', () => { + const entries = [ + createMockEntry({ type: 'AUTO', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'USER', timestamp: NOW - 30 * 60 * 1000 }), + createMockEntry({ type: 'CUE', timestamp: NOW - 30 * 60 * 1000 }), + ]; + + const { container } = render( + + ); + + // The bar with all 3 entries should be at 100% height (it's the max) + const bars = container.querySelectorAll('.flex-1.min-w-0.flex.flex-col.justify-end'); + const lastBar = bars[bars.length - 1]; + const barInner = lastBar.querySelector('.w-full.rounded-t-sm') as HTMLElement; + // height should be 100% since this is the max bucket + expect(barInner.style.height).toBe('100%'); + }); }); diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index e10c9715fc..9a4a7476f3 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 0904364954..ebc06b2051 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', () => { diff --git a/src/__tests__/renderer/components/HistoryPanel.test.tsx b/src/__tests__/renderer/components/HistoryPanel.test.tsx index e7d72bb0cd..1447b46782 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 35925c11ba..5a4c39c8da 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 a77a65881a..9e302be645 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 85f2ef46a9..a806ca0174 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 @@ -43,7 +43,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 +68,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 +234,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 +324,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 +1111,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 +1468,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 06834dfae2..765cde0916 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, }); @@ -557,6 +599,20 @@ describe('MainPanel', () => { expect(screen.queryByText('Test Session')).not.toBeInTheDocument(); }); + it('should show bookmark indicator when session is bookmarked', () => { + const session = createSession({ bookmarked: true }); + render(); + + expect(screen.getByTestId('bookmark-icon')).toBeInTheDocument(); + }); + + it('should not show bookmark indicator when session is not bookmarked', () => { + const session = createSession({ bookmarked: false }); + render(); + + expect(screen.queryByTestId('bookmark-icon')).not.toBeInTheDocument(); + }); + it('should show Agent Sessions button in header', () => { render(); @@ -738,12 +794,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 +3354,121 @@ 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 ec2347e248..1ffde58901 100644 --- a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx +++ b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MarkdownRenderer } from '../../../renderer/components/MarkdownRenderer'; // Mock react-syntax-highlighter @@ -19,10 +19,24 @@ vi.mock('lucide-react', () => ({ Clipboard: () => Clipboard, Loader2: () => Loader, ImageOff: () => ImageOff, + Copy: () => Copy, + ExternalLink: () => ExternalLink, + FileText: () => FileText, + Target: () => Target, +})); + +// Mock fileExplorerStore for FileContextMenu's Document Graph action +vi.mock('../../../renderer/stores/fileExplorerStore', () => ({ + useFileExplorerStore: { + getState: () => ({ + focusFileInGraph: vi.fn(), + }), + }, })); const mockTheme = { id: 'test-theme', + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', @@ -130,4 +144,93 @@ describe('MarkdownRenderer', () => { expect(container.innerHTML).not.toContain('javascript:'); }); }); + + describe('file context menu', () => { + // maestro-file:// protocol is stripped by ReactMarkdown — use raw HTML + // with data-maestro-file attribute (the same fallback used in production) + it('renders file context menu on right-click of a file link', () => { + const { container } = render( + + ); + const link = container.querySelector('a[data-maestro-file]'); + expect(link).not.toBeNull(); + + fireEvent.contextMenu(link!, { clientX: 150, clientY: 250 }); + + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Copy Path')).toBeInTheDocument(); + expect(screen.getByText('Open in Default App')).toBeInTheDocument(); + // Should NOT show link menu items + expect(screen.queryByText('Copy Link')).toBeNull(); + expect(screen.queryByText('Open in Browser')).toBeNull(); + }); + + it('shows Document Graph option for markdown file references', () => { + const { container } = render( + + ); + const link = container.querySelector('a[data-maestro-file]'); + expect(link).not.toBeNull(); + fireEvent.contextMenu(link!, { clientX: 150, clientY: 250 }); + + expect(screen.getByText('Document Graph')).toBeInTheDocument(); + }); + + it('does not show Document Graph for non-markdown files', () => { + const { container } = render( + + ); + const link = container.querySelector('a[data-maestro-file]'); + expect(link).not.toBeNull(); + fireEvent.contextMenu(link!, { clientX: 150, clientY: 250 }); + + expect(screen.queryByText('Document Graph')).toBeNull(); + expect(screen.getByText('Copy Path')).toBeInTheDocument(); + }); + }); + + describe('link context menu', () => { + it('renders a context menu with Copy Link and Open in Browser on right-click', () => { + const { container } = render( + + ); + const link = container.querySelector('a[href="https://example.com"]'); + expect(link).not.toBeNull(); + + fireEvent.contextMenu(link!, { clientX: 100, clientY: 200 }); + + expect(screen.getByText('Copy Link')).toBeInTheDocument(); + expect(screen.getByText('Open in Browser')).toBeInTheDocument(); + }); + + it('does not show context menu for links without href', () => { + const { container } = render( + + ); + + // Right-click on the container itself — no link, no menu + fireEvent.contextMenu(container.firstElementChild!, { clientX: 100, clientY: 200 }); + + expect(screen.queryByText('Copy Link')).toBeNull(); + }); + }); }); diff --git a/src/__tests__/renderer/components/MergeSessionModal.test.tsx b/src/__tests__/renderer/components/MergeSessionModal.test.tsx index 0765d5c29e..137b3af717 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 529073e8a6..f027eaf7b9 100644 --- a/src/__tests__/renderer/components/ProcessMonitor.test.tsx +++ b/src/__tests__/renderer/components/ProcessMonitor.test.tsx @@ -46,6 +46,16 @@ vi.mock('lucide-react', () => ({ ⊗ ), + ExternalLink: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + ↗ + + ), + Tag: ({ className, style }: { className?: string; style?: React.CSSProperties }) => ( + + 🏷 + + ), })); // Mock layer stack context @@ -130,6 +140,11 @@ interface ActiveProcess { isTerminal: boolean; isBatchMode: boolean; startTime: number; + isCueRun?: boolean; + cueRunId?: string; + cueSessionName?: string; + cueSubscriptionName?: string; + cueEventType?: string; } const createActiveProcess = (overrides: Partial = {}): ActiveProcess => ({ @@ -143,6 +158,22 @@ const createActiveProcess = (overrides: Partial = {}): ActiveProc ...overrides, }); +const createCueProcess = (overrides: Partial = {}): ActiveProcess => ({ + sessionId: 'cue-run-test-uuid', + toolType: 'claude-code', + pid: 99999, + cwd: '/Users/test/project', + isTerminal: false, + isBatchMode: false, + startTime: Date.now() - 30000, + isCueRun: true, + cueRunId: 'test-uuid', + cueSessionName: 'My Agent', + cueSubscriptionName: 'heartbeat-check', + cueEventType: 'time.heartbeat', + ...overrides, +}); + describe('ProcessMonitor', () => { let theme: Theme; let onClose: ReturnType; @@ -162,6 +193,12 @@ describe('ProcessMonitor', () => { // Reset existing kill mock vi.mocked(window.maestro.process.kill).mockReset().mockResolvedValue(undefined); + // Add cue.stopRun mock + if (!(window as any).maestro.cue) { + (window as any).maestro.cue = {}; + } + (window as any).maestro.cue.stopRun = vi.fn().mockResolvedValue(true); + // Mock scrollIntoView Element.prototype.scrollIntoView = vi.fn(); @@ -511,7 +548,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); }); @@ -699,6 +738,134 @@ 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('SSH/Local indicator', () => { + it('should show "Local" badge on session row for local sessions', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession(); + render(); + + await waitFor(() => { + expect(screen.getByTitle('Running locally')).toBeInTheDocument(); + expect(screen.getByText('Local')).toBeInTheDocument(); + }); + }); + + it('should show SSH badge on session row for SSH sessions', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession({ + sshRemote: { id: 'remote-1', name: 'dev-box', host: '192.168.1.100' }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText('SSH: dev-box')).toBeInTheDocument(); + // Both session row and process row have SSH badges with this title + const sshTitles = screen.getAllByTitle('SSH: dev-box (192.168.1.100)'); + expect(sshTitles.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('should show SSH badge on process row for SSH sessions', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession({ + sshRemote: { id: 'remote-1', name: 'prod-server', host: '10.0.0.5' }, + }); + render(); + + await waitFor(() => { + // Process row should have the SSH badge + const sshBadges = screen.getAllByText('SSH'); + expect(sshBadges.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('should not show SSH badge on process row for local sessions', async () => { + const process = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession(); + render(); + + await waitFor(() => { + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); + }); + + // No SSH badge should appear on process rows + expect(screen.queryByText('SSH')).not.toBeInTheDocument(); + }); }); describe('Expand/collapse', () => { @@ -715,7 +882,9 @@ describe('ProcessMonitor', () => { await waitFor(() => { // All nodes should be expanded, so we should see the process - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); }); @@ -731,7 +900,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Click the group to collapse @@ -739,7 +910,9 @@ describe('ProcessMonitor', () => { // Process should no longer be visible await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); }); @@ -755,7 +928,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Click collapse all button @@ -764,7 +939,9 @@ describe('ProcessMonitor', () => { // Process should no longer be visible await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); }); @@ -780,7 +957,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Collapse first @@ -788,7 +967,9 @@ describe('ProcessMonitor', () => { fireEvent.click(collapseButton); await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); // Then expand @@ -796,7 +977,9 @@ describe('ProcessMonitor', () => { fireEvent.click(expandButton); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); }); }); @@ -856,7 +1039,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Collapse first @@ -864,7 +1049,9 @@ describe('ProcessMonitor', () => { fireEvent.click(collapseButton); await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); const dialog = screen.getByRole('dialog'); @@ -893,7 +1080,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); const dialog = screen.getByRole('dialog'); @@ -906,7 +1095,9 @@ describe('ProcessMonitor', () => { // Should hide children await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); }); @@ -922,7 +1113,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); const dialog = screen.getByRole('dialog'); @@ -935,7 +1128,9 @@ describe('ProcessMonitor', () => { // Should hide children await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); }); @@ -951,7 +1146,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); const dialog = screen.getByRole('dialog'); @@ -964,7 +1161,9 @@ describe('ProcessMonitor', () => { // Should hide children await waitFor(() => { - expect(screen.queryByText('Test Session - AI Agent (claude-code)')).not.toBeInTheDocument(); + expect( + screen.queryByText('Test Session - AI Agent (claude-code) - Tab 1') + ).not.toBeInTheDocument(); }); }); @@ -1060,7 +1259,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Hover over process to show kill button (simulated via click) @@ -1083,7 +1284,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1108,7 +1311,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1133,7 +1338,9 @@ describe('ProcessMonitor', () => { ); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1159,7 +1366,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1183,7 +1392,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1212,7 +1423,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1243,7 +1456,9 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Show kill confirmation @@ -1406,12 +1621,14 @@ describe('ProcessMonitor', () => { render(); await waitFor(() => { - expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + expect( + screen.getByText('Test Session - AI Agent (claude-code) - Tab 1') + ).toBeInTheDocument(); }); // Click the process const processNode = screen - .getByText('Test Session - AI Agent (claude-code)') + .getByText('Test Session - AI Agent (claude-code) - Tab 1') .closest('div[tabindex="0"]'); fireEvent.click(processNode!); @@ -1521,6 +1738,54 @@ describe('ProcessMonitor', () => { }); }); + it('should include tab name in process label when available', async () => { + const process = createActiveProcess(); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession({ + aiTabs: [ + { + id: 'tab-1', + name: 'My Custom Tab', + logs: [], + agentSessionId: 'abc12345-6789-0123-4567-890abcdef012', + isStarred: false, + state: 'idle', + }, + ], + }); + render(); + + await waitFor(() => { + expect( + screen.getByText('Test Session - AI Agent (claude-code) - My Custom Tab') + ).toBeInTheDocument(); + }); + }); + + it('should omit tab name from process label when tab name is null', async () => { + const process = createActiveProcess(); + getActiveProcessesMock().mockResolvedValue([process]); + + const session = createSession({ + aiTabs: [ + { + id: 'tab-1', + name: null, + logs: [], + agentSessionId: 'abc12345-6789-0123-4567-890abcdef012', + isStarred: false, + state: 'idle', + }, + ], + }); + render(); + + await waitFor(() => { + expect(screen.getByText('Test Session - AI Agent (claude-code)')).toBeInTheDocument(); + }); + }); + it('should handle multiple groups with processes', async () => { const processes = [ createActiveProcess({ sessionId: 'session-1-ai-tab-1' }), @@ -1585,4 +1850,152 @@ describe('ProcessMonitor', () => { }); }); }); + + describe('CUE RUNS section', () => { + it('renders CUE RUNS section when cue processes are active', async () => { + const cueProc = createCueProcess(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + }); + + it('does not render CUE RUNS section when no cue processes', async () => { + const regularProc = createActiveProcess(); + const session = createSession(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([regularProc] as any); + + render(); + + // Wait for async process list to load by confirming the tree rendered + await waitFor(() => { + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + }); + + // Only then assert CUE RUNS is absent + expect(screen.queryByText('CUE RUNS')).not.toBeInTheDocument(); + }); + + it('shows subscription name and session name in cue process label', async () => { + const cueProc = createCueProcess({ + cueSubscriptionName: 'daily-review', + cueSessionName: 'Code Agent', + }); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('daily-review → Code Agent')).toBeInTheDocument(); + }); + }); + + it('shows event type badge on cue process', async () => { + const cueProc = createCueProcess({ cueEventType: 'time.heartbeat' }); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('TIME HEARTBEAT')).toBeInTheDocument(); + }); + }); + + it('calls cue.stopRun for cue process kill instead of process.kill', async () => { + const cueProc = createCueProcess({ cueRunId: 'run-to-kill' }); + getActiveProcessesMock().mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + + // Click kill button on the cue process + const killButtons = screen.getAllByTitle('Kill process'); + expect(killButtons.length).toBeGreaterThanOrEqual(1); + fireEvent.click(killButtons[0]); + + // Confirm kill via "Kill Process" button + await waitFor(() => { + expect(screen.getByText('Kill Process?')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Kill Process')); + + await waitFor(() => { + expect((window as any).maestro.cue.stopRun).toHaveBeenCalledWith('run-to-kill'); + expect(killMock()).not.toHaveBeenCalled(); + }); + }); + + it('calls process.kill for regular process kill (not cue.stopRun)', async () => { + const regularProc = createActiveProcess({ sessionId: 'session-1-ai-tab-1' }); + const session = createSession(); + getActiveProcessesMock().mockResolvedValue([regularProc] as any); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + }); + + // Click kill button + const killButtons = screen.getAllByTitle('Kill process'); + expect(killButtons.length).toBeGreaterThanOrEqual(1); + fireEvent.click(killButtons[0]); + + // Confirm kill + await waitFor(() => { + expect(screen.getByText('Kill Process?')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Kill Process')); + + await waitFor(() => { + expect(killMock()).toHaveBeenCalledWith('session-1-ai-tab-1'); + expect((window as any).maestro.cue.stopRun).not.toHaveBeenCalled(); + }); + }); + + it('cue section coexists with other sections', async () => { + const session = createSession(); + const regularProc = createActiveProcess(); + const cueProc = createCueProcess(); + vi.mocked(window.maestro.process.getActiveProcesses).mockResolvedValue([ + regularProc, + cueProc, + ] as any); + + render(); + + await waitFor(() => { + // Both sections should exist + expect(screen.getByText('UNGROUPED AGENTS')).toBeInTheDocument(); + expect(screen.getByText('CUE RUNS')).toBeInTheDocument(); + }); + }); + + it('renders ⚡ emoji for cue section', async () => { + const cueProc = createCueProcess(); + getActiveProcessesMock().mockResolvedValue([cueProc] as any); + + render(); + + await waitFor(() => { + expect(screen.getByText('⚡')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 3401f0252e..2b36ce9022 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,213 @@ 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(); + }); + }); + + describe('Agent switcher mode (Cmd+O)', () => { + it('shows agent-specific placeholder when initialMode is agents', () => { + const props = createDefaultProps({ initialMode: 'agents' }); + render(); + + expect(screen.getByPlaceholderText('Jump to agent...')).toBeInTheDocument(); + }); + + it('shows Switch Agent aria-label when in agents mode', () => { + const props = createDefaultProps({ initialMode: 'agents' }); + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-label', 'Switch Agent'); + }); + + it('shows only raw agent names in agents mode', () => { + const props = createDefaultProps({ + initialMode: 'agents', + sessions: [ + createMockSession({ id: 'session-1', name: 'Agent Alpha' }), + createMockSession({ id: 'session-2', name: 'Agent Beta' }), + ], + }); + render(); + + // Agent names should be shown without "Jump to:" prefix + expect(screen.getByText('Agent Alpha')).toBeInTheDocument(); + expect(screen.getByText('Agent Beta')).toBeInTheDocument(); + expect(screen.queryByText(/Jump to/)).not.toBeInTheDocument(); + + // Non-agent actions should NOT be present + expect(screen.queryByText('Create New Agent')).not.toBeInTheDocument(); + expect(screen.queryByText('Toggle Left Panel')).not.toBeInTheDocument(); + expect(screen.queryByText('Open Settings')).not.toBeInTheDocument(); + }); + + it('filters agents by search text in agents mode', () => { + const props = createDefaultProps({ + initialMode: 'agents', + sessions: [ + createMockSession({ id: 'session-1', name: 'Agent Alpha' }), + createMockSession({ id: 'session-2', name: 'Agent Beta' }), + ], + }); + render(); + + const input = screen.getByPlaceholderText('Jump to agent...'); + fireEvent.change(input, { target: { value: 'alpha' } }); + + expect(screen.getByText('Agent Alpha')).toBeInTheDocument(); + expect(screen.queryByText('Agent Beta')).not.toBeInTheDocument(); + }); + + it('closes modal and switches agent on selection in agents mode', () => { + const props = createDefaultProps({ initialMode: 'agents' }); + render(); + + fireEvent.click(screen.getByText('Test Session')); + + expect(props.setActiveSessionId).toHaveBeenCalledWith('session-1'); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('sorts agents alphabetically with group chats at the bottom', () => { + const props = createDefaultProps({ + initialMode: 'agents', + sessions: [ + createMockSession({ id: 'session-1', name: 'Zulu' }), + createMockSession({ id: 'session-2', name: 'Alpha' }), + createMockSession({ id: 'session-3', name: 'Mike' }), + ], + groupChats: [{ id: 'gc-1', name: 'Design Review', participants: ['a', 'b'] }], + onOpenGroupChat: vi.fn(), + }); + render(); + + const buttons = screen.getAllByRole('button'); + const labels = buttons.map((b) => b.textContent?.replace(/\d/, '').trim() ?? ''); + + // Agents alphabetically first, then group chats + const alphaIdx = labels.findIndex((l) => l.startsWith('Alpha')); + const mikeIdx = labels.findIndex((l) => l.startsWith('Mike')); + const zuluIdx = labels.findIndex((l) => l.startsWith('Zulu')); + const gcIdx = labels.findIndex((l) => l.startsWith('Design Review')); + + expect(alphaIdx).toBeLessThan(mikeIdx); + expect(mikeIdx).toBeLessThan(zuluIdx); + expect(zuluIdx).toBeLessThan(gcIdx); + }); + }); }); diff --git a/src/__tests__/renderer/components/RenameSessionModal.test.tsx b/src/__tests__/renderer/components/RenameSessionModal.test.tsx index b0963b8601..7c4de0a3bc 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 8f76bbf2b7..1dd0bd1982 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -1116,6 +1116,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 1c6d7f3d6c..d1782a0373 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 0000000000..0e0bbff2d3 --- /dev/null +++ b/src/__tests__/renderer/components/SessionItemCue.test.tsx @@ -0,0 +1,227 @@ +/** + * @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('applies animate-pulse animation class when cueActiveRun is true', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).toHaveClass('animate-pulse'); + }); + + it('does not apply animate-pulse class when cueActiveRun is false', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).not.toHaveClass('animate-pulse'); + }); + + it('does not apply animate-pulse class when cueActiveRun is undefined', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + const wrapper = zapIcon.closest('span[title]'); + expect(wrapper).not.toHaveClass('animate-pulse'); + }); + + it('shows "running" in tooltip when cueActiveRun is true', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue running (2 subscriptions)' + ); + }); + + it('shows "active" in tooltip when cueActiveRun is false', () => { + render( + + ); + + const zapIcon = screen.getByTestId('icon-zap'); + expect(zapIcon.closest('span[title]')).toHaveAttribute( + 'title', + 'Maestro Cue active (2 subscriptions)' + ); + }); + + 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 bd9739c825..d29f336acb 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -69,6 +69,10 @@ vi.mock('lucide-react', () => ({ Music: () => , Command: () => , MessageSquare: () => , + MessageSquarePlus: () => , + Zap: ({ title, style }: { title?: string; style?: Record }) => ( + + ), })); // Mock gitService @@ -153,6 +157,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 +179,8 @@ const createMockSession = (overrides: Partial = {}): Session => ({ messageQueue: [], contextUsage: 30, activeTimeMs: 60000, + terminalTabs: [], + activeTerminalTabId: null, ...overrides, }); @@ -3159,4 +3166,97 @@ 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/LiveOverlayPanel.test.tsx b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx index a13b0b1115..b2f99ee637 100644 --- a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx +++ b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx @@ -56,6 +56,8 @@ function createDefaultProps(overrides: Partial { expect(screen.getByText('Remote Control')).toBeTruthy(); }); + it('shows Cloudflare tunnel description under Remote Control', () => { + render(); + expect( + screen.getByText(/Uses Cloudflare tunnel for access outside your network/) + ).toBeTruthy(); + }); + it('calls handleTunnelToggle when toggle button is clicked', () => { const handleTunnelToggle = vi.fn(); render(); diff --git a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx index 4c7d740953..47b0a7d893 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,33 +31,37 @@ 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, }; } describe('SidebarActions', () => { - it('renders collapse button, New Agent, and Wizard when sidebar is open', () => { - render(); + it('renders collapse button, New Agent, and Feedback when sidebar is open', () => { + render(); expect(screen.getByText('New Agent')).toBeTruthy(); - expect(screen.getByText('Wizard')).toBeTruthy(); + expect(screen.getByText('Feedback')).toBeTruthy(); }); - it('hides New Agent and Wizard when sidebar is collapsed', () => { + it('hides New Agent, Feedback, and unread filter when sidebar is collapsed', () => { render(); expect(screen.queryByText('New Agent')).toBeNull(); - expect(screen.queryByText('Wizard')).toBeNull(); + expect(screen.queryByText('Feedback')).toBeNull(); + expect(screen.queryByTitle(/Filter unread agents/)).toBeNull(); + expect(screen.queryByTitle(/Showing unread agents only/)).toBeNull(); }); - it('hides Wizard button when openWizard is undefined', () => { - render(); + it('disables Feedback button when openFeedback is undefined', () => { + render(); expect(screen.getByText('New Agent')).toBeTruthy(); - expect(screen.queryByText('Wizard')).toBeNull(); + const feedbackBtn = screen.getByText('Feedback').closest('button'); + expect(feedbackBtn?.disabled).toBe(true); }); it('calls addNewSession when New Agent is clicked', () => { @@ -67,12 +72,12 @@ describe('SidebarActions', () => { expect(addNewSession).toHaveBeenCalledOnce(); }); - it('calls openWizard when Wizard is clicked', () => { - const openWizard = vi.fn(); - render(); + it('calls openFeedback when Feedback is clicked', () => { + const openFeedback = vi.fn(); + render(); - fireEvent.click(screen.getByText('Wizard')); - expect(openWizard).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByText('Feedback')); + expect(openFeedback).toHaveBeenCalledOnce(); }); it('toggles sidebar open/closed on collapse button click', () => { @@ -110,4 +115,30 @@ 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(); + }); + + it('renders two-column grid layout', () => { + render(); + + const newAgentBtn = screen.getByText('New Agent'); + const grid = newAgentBtn.closest('div[style]'); + expect(grid?.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))'); + }); }); diff --git a/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx b/src/__tests__/renderer/components/Settings/tabs/DisplayTab.test.tsx index 10774557ed..42fa3bdff3 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(); @@ -49,8 +47,6 @@ vi.mock('../../../../../renderer/hooks/settings/useSettings', () => ({ setFontFamily: mockSetFontFamily, fontSize: 14, setFontSize: mockSetFontSize, - terminalWidth: 100, - setTerminalWidth: mockSetTerminalWidth, maxLogBuffer: 5000, setMaxLogBuffer: mockSetMaxLogBuffer, maxOutputLines: 25, @@ -583,82 +579,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 // ========================================================================= @@ -726,7 +646,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(); }); }); @@ -1637,8 +1557,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 681edd5a9c..2d1d09c2b1 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', () => ({ /> {' '} + for methodology-guided planning, delivery, and review workflows. +

+
+ + {metadata && ( +
+
+ Version: + + {metadata.sourceVersion} + + + Updated: + + {formatDate(metadata.lastRefreshed)} + +
+ +
+ )} + +
+ {commands.map((cmd) => ( +
+ {editingCommand?.id === cmd.id ? ( +
+
+ + {cmd.command} + +
+ + +
+
+
+