Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions apps/cli/test/e2e/interactive-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ===========================================================================
Expand Down
40 changes: 31 additions & 9 deletions apps/cli/test/e2e/interactive-test-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions apps/cli/test/unit/interactive-test-session.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
});
Loading