diff --git a/apps/cli/test/e2e/interactive-menu.test.ts b/apps/cli/test/e2e/interactive-menu.test.ts index fd10ee04..561ad599 100644 --- a/apps/cli/test/e2e/interactive-menu.test.ts +++ b/apps/cli/test/e2e/interactive-menu.test.ts @@ -452,6 +452,146 @@ const skipSuite = !hasTmux() || !hasPrlt(); }); }); + // =========================================================================== + // PRLT-1233 ticket-required interactive flows + // =========================================================================== + describe('Work start interactive flow (prlt work start)', () => { + it('should show a ticket dropdown or external-source picker when run without args', () => { + session.sendCommand('prlt work start'); + // Two possible entry points depending on provider config: + // 1. "Select ticket to work on:" — when a provider returns ready tickets + // 2. "Select external issue source:" — when no provider is configured + session.waitForOutput( + /Select ticket to work on|Select external issue source|No tickets available|❯/, + MENU_TIMEOUT + ); + + const screen = session.getScreen(); + expect(screen.raw).to.match( + /Select ticket to work on|Select external issue source|No tickets available/i + ); + }); + }); + + describe('Ticket move interactive flow (prlt ticket move)', () => { + it('should show the ticket picker when run without args', () => { + session.sendCommand('prlt ticket move'); + session.waitForOutput( + /Select ticket to move|No tickets|TKT-|PRLT-/, + MENU_TIMEOUT + ); + + const screen = session.getScreen(); + // Either the ticket picker rendered, or we hit a no-data path that + // still proves the command did not silently exit. + expect(screen.raw).to.match(/Select ticket to move|No tickets|TKT-|PRLT-/i); + }); + + it('should advance to the column picker after selecting a ticket', function (this: Mocha.Context) { + session.sendCommand('prlt ticket move'); + // If there are no tickets, the picker won't render — skip that case. + try { + session.waitForOutput('Select ticket to move', MENU_TIMEOUT); + } catch { + this.skip(); + return; + } + + // Pick the first ticket + session.send('Space'); // checkbox prompt requires Space to mark + session.waitForStable(300); + session.send('Enter'); + + // After selecting we expect either a column picker or a destination prompt + session.waitForOutput(/Move to|column|Move selected tickets/i, MENU_TIMEOUT); + const screen = session.getScreen(); + expect(screen.raw).to.match(/Move to|column|Move selected tickets/i); + }); + }); + + describe('Session attach interactive flow (prlt session attach)', () => { + it('should show a session list or a clear empty-state message', () => { + session.sendCommand('prlt session attach'); + // Either we get a session selector, or a graceful empty-state message — + // both prove the command rendered output without hanging. + session.waitForOutput( + /Select a session to attach to|No sessions|No active sessions|sessions found/i, + MENU_TIMEOUT + ); + + const screen = session.getScreen(); + expect(screen.raw).to.match(/session/i); + }); + }); + + describe('Orchestrator menu (prlt orchestrator)', () => { + it('should show orchestrator action choices', () => { + session.sendCommand('prlt orchestrator'); + session.waitForOutput(/Orchestrator.*What would you like to do|Start orchestrator|Attach to/, MENU_TIMEOUT); + + const screen = session.getScreen(); + // The menu lists at least one of the documented actions + expect(screen.raw).to.match(/Start orchestrator|Attach|Check orchestrator status|List registered threads/i); + }); + + it('should render the inquirer ❯ selection indicator', () => { + session.sendCommand('prlt orchestrator'); + session.waitForOutput(/Orchestrator.*What would you like to do|Start orchestrator|Attach to/, MENU_TIMEOUT); + + const screen = session.getScreen(); + expect(screen.raw).to.include('❯'); + }); + }); + + // =========================================================================== + // PRLT-1233: --json flag must bypass all interactive prompts + // =========================================================================== + describe('Non-interactive bypass (--json)', () => { + /** + * In --json mode, prlt should never render an inquirer menu — it should + * either complete the command, emit a JSON prompt config (the JSON-mode + * fallback for required input), or exit cleanly with a JSON error. + * In none of those cases should the ❯ selection indicator appear. + */ + function assertNoInteractiveMenu(screen: { raw: string; lines: string[] }): void { + // No inquirer-style selection arrows + const inquirerLines = screen.lines.filter((line) => line.includes('❯')); + expect( + inquirerLines, + `Expected no inquirer ❯ selection in --json output. Screen:\n${screen.raw}` + ).to.have.length(0); + + // No "Use arrow keys" hint, which only appears in interactive list prompts + expect(screen.raw, `--json should not render arrow-key hint. Screen:\n${screen.raw}`) + .to.not.match(/Use arrow keys to navigate/i); + } + + it('prlt work start --json — does not render an interactive menu', () => { + session.sendCommand('prlt work start --json'); + // Wait for any output to settle + session.waitForStable(1500, MENU_TIMEOUT); + assertNoInteractiveMenu(session.getScreen()); + }); + + it('prlt ticket move --json — does not render an interactive menu', () => { + session.sendCommand('prlt ticket move --json'); + session.waitForStable(1500, MENU_TIMEOUT); + assertNoInteractiveMenu(session.getScreen()); + }); + + it('prlt session attach --json — does not render an interactive menu', () => { + session.sendCommand('prlt session attach --json'); + session.waitForStable(1500, MENU_TIMEOUT); + assertNoInteractiveMenu(session.getScreen()); + }); + + it('prlt orchestrator --json — does not render an interactive menu', () => { + session.sendCommand('prlt orchestrator --json'); + session.waitForStable(1500, MENU_TIMEOUT); + assertNoInteractiveMenu(session.getScreen()); + }); + }); + // =========================================================================== // Output formatting & rendering // =========================================================================== diff --git a/apps/cli/test/e2e/interactive-test-session.ts b/apps/cli/test/e2e/interactive-test-session.ts index 61c293c0..354dc7e7 100644 --- a/apps/cli/test/e2e/interactive-test-session.ts +++ b/apps/cli/test/e2e/interactive-test-session.ts @@ -21,6 +21,34 @@ import { execSync } from 'node:child_process'; import * as crypto from 'node:crypto'; +/** + * Pattern matcher used by waitForOutput. + * Exported for unit testing. + */ +export function matchesPattern(text: string, pattern: string | RegExp): boolean { + if (pattern instanceof RegExp) { + return pattern.test(text); + } + return text.includes(pattern); +} + +/** + * Returns true when the given string is a tmux special-key name + * (e.g. "Up", "Enter", "C-c") rather than literal text input. + * Exported for unit testing. + */ +export function isTmuxSpecialKey(keys: string): boolean { + return /^(Up|Down|Left|Right|Enter|Escape|Tab|Space|BSpace|C-[a-z]|M-[a-z])$/.test(keys); +} + +/** + * POSIX single-quote shell escape suitable for `sh -c`. + * Exported for unit testing. + */ +export function shellEscape(str: string): string { + return `'${str.replace(/'/g, "'\\''")}'`; +} + /** * Configuration for an interactive test session. */ @@ -375,9 +403,7 @@ export class InteractiveTestSession { private tmuxSendKeys(keys: string): void { // For special keys and Ctrl combos, don't quote them // For text input, quote it to preserve spaces - const isSpecialKey = /^(Up|Down|Left|Right|Enter|Escape|Tab|Space|BSpace|C-[a-z]|M-[a-z])$/.test(keys); - - if (isSpecialKey) { + if (isTmuxSpecialKey(keys)) { this.tmuxExec(`send-keys -t ${this.sessionName} ${keys}`); } else { // Send as literal text to avoid key interpretation issues @@ -390,15 +416,11 @@ export class InteractiveTestSession { } private matchesPattern(text: string, pattern: string | RegExp): boolean { - if (pattern instanceof RegExp) { - return pattern.test(text); - } - return text.includes(pattern); + return matchesPattern(text, pattern); } private shellEscape(str: string): string { - // Use single quotes for shell escaping, handling embedded single quotes - return `'${str.replace(/'/g, "'\\''")}'`; + return shellEscape(str); } private sleep(ms: number): void { diff --git a/apps/cli/test/unit/interactive-test-session.test.ts b/apps/cli/test/unit/interactive-test-session.test.ts new file mode 100644 index 00000000..e129420a --- /dev/null +++ b/apps/cli/test/unit/interactive-test-session.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for the InteractiveTestSession helpers (PRLT-1233). + * + * The InteractiveTestSession class itself spins up a real tmux session, so + * direct unit-testing it is impractical. Instead we cover the pure helpers + * that drive its behavior: pattern matching for waitForOutput, special-key + * detection for tmux send-keys, and POSIX shell escaping. + */ + +import { expect } from 'chai'; +import { + isTmuxSpecialKey, + matchesPattern, + shellEscape, +} from '../e2e/interactive-test-session.js'; + +describe('InteractiveTestSession helpers', () => { + describe('matchesPattern', () => { + it('matches a substring with a string pattern', () => { + expect(matchesPattern('Ticket Operations menu', 'Operations')).to.equal(true); + }); + + it('returns false when the substring is missing', () => { + expect(matchesPattern('Ticket Operations menu', 'Project')).to.equal(false); + }); + + it('matches against a RegExp pattern', () => { + expect(matchesPattern('TKT-1234 some title', /TKT-\d+/)).to.equal(true); + }); + + it('returns false when RegExp does not match', () => { + expect(matchesPattern('no ticket here', /TKT-\d+/)).to.equal(false); + }); + + it('treats string patterns as literal — no regex meta-character interpretation', () => { + // Without regex semantics, '.+' is matched literally + expect(matchesPattern('hello world', '.+')).to.equal(false); + expect(matchesPattern('hello.+world', '.+')).to.equal(true); + }); + }); + + describe('isTmuxSpecialKey', () => { + const specialKeys = [ + 'Up', + 'Down', + 'Left', + 'Right', + 'Enter', + 'Escape', + 'Tab', + 'Space', + 'BSpace', + 'C-c', + 'C-d', + 'M-x', + ]; + + for (const key of specialKeys) { + it(`recognises "${key}" as a special key`, () => { + expect(isTmuxSpecialKey(key)).to.equal(true); + }); + } + + it('treats arbitrary text as non-special', () => { + expect(isTmuxSpecialKey('prlt ticket')).to.equal(false); + expect(isTmuxSpecialKey('hello world')).to.equal(false); + expect(isTmuxSpecialKey('')).to.equal(false); + }); + + it('does not match uppercase ctrl combos (tmux requires lowercase)', () => { + expect(isTmuxSpecialKey('C-C')).to.equal(false); + }); + + it('does not match multi-key sequences', () => { + expect(isTmuxSpecialKey('Up Down')).to.equal(false); + expect(isTmuxSpecialKey('Enter Enter')).to.equal(false); + }); + }); + + describe('shellEscape', () => { + it('wraps a plain value in single quotes', () => { + expect(shellEscape('hello')).to.equal("'hello'"); + }); + + it('preserves spaces inside the quoted value', () => { + expect(shellEscape('hello world')).to.equal("'hello world'"); + }); + + it('escapes embedded single quotes using the POSIX dance', () => { + // ' becomes '\'' → close, escaped quote, reopen + expect(shellEscape("it's")).to.equal("'it'\\''s'"); + }); + + it('handles empty strings', () => { + expect(shellEscape('')).to.equal("''"); + }); + + it('passes shell metacharacters through literally inside the quotes', () => { + expect(shellEscape('$(rm -rf /)')).to.equal("'$(rm -rf /)'"); + expect(shellEscape('a;b|c&d')).to.equal("'a;b|c&d'"); + }); + }); +});