From a3623fd819cb7b173eba1edbc9d74ad21c80835e Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 04:22:16 +0000 Subject: [PATCH] feat(cron): add interactive E2E tests and fix cron trigger reactivity - Add getScreenText() to TerminalCapture for reading rendered xterm.js screen - Add E2E tests for in-session cron: inline firing, user priority, error resilience - Fix cron prompts not processing by adding cronTrigger state dependency This ensures cron-injected prompts are processed immediately when fired, not just when streaming state changes, and provides comprehensive test coverage for the in-session cron feature. Co-authored-by: Qwen-Coder --- .../terminal-capture/terminal-capture.ts | 40 +++ .../test-cron-interactive-e2e.ts | 300 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 4 +- 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 integration-tests/terminal-capture/test-cron-interactive-e2e.ts diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts index ebfddd523..8d427f472 100644 --- a/integration-tests/terminal-capture/terminal-capture.ts +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -640,6 +640,46 @@ export class TerminalCapture { return this.rawOutput; } + /** + * Get the current rendered terminal screen text from xterm.js. + * + * Unlike getOutput() which returns the accumulated raw PTY stream (with + * duplicates from Ink TUI redraws), this returns the actual screen content + * as rendered by xterm.js — what a user would see right now. + * + * Includes scrollback buffer content. + */ + async getScreenText(): Promise { + if (!this.page) throw new Error('Not initialized'); + await this.flush(); + return this.page.evaluate(() => { + const W = window as unknown as Record; + const term = W['term'] as { + buffer: { + active: { + length: number; + getLine: ( + i: number, + ) => + | { translateToString: (trimRight?: boolean) => string } + | undefined; + }; + }; + }; + const buf = term.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buf.length; i++) { + const line = buf.getLine(i); + lines.push(line ? line.translateToString(true) : ''); + } + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + return lines.join('\n'); + }); + } + // ── Cleanup ────────────────────────────── /** diff --git a/integration-tests/terminal-capture/test-cron-interactive-e2e.ts b/integration-tests/terminal-capture/test-cron-interactive-e2e.ts new file mode 100644 index 000000000..ce3959562 --- /dev/null +++ b/integration-tests/terminal-capture/test-cron-interactive-e2e.ts @@ -0,0 +1,300 @@ +/** + * E2E tests for in-session cron/loop in interactive mode. + * + * These correspond to "Part 2: Manual tests" from the testing guide. + * We drive the full interactive TUI via TerminalCapture and read the + * rendered terminal screen from xterm.js. + * + * Usage: + * cd qwen-code && npx tsx integration-tests/terminal-capture/test-cron-interactive-e2e.ts + */ + +import { TerminalCapture } from './terminal-capture.js'; + +// ─── Session helper ───────────────────────────────────────── + +const MODEL_TIMEOUT = 120_000; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function makeEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env['NO_COLOR']; + return { + ...env, + QWEN_CODE_ENABLE_CRON: '1', + FORCE_COLOR: '1', + TERM: 'xterm-256color', + NODE_NO_WARNINGS: '1', + }; +} + +class Session { + private constructor(private t: TerminalCapture) {} + + static async start(): Promise { + const t = await TerminalCapture.create({ + cols: 100, + rows: 40, + chrome: false, + cwd: process.cwd(), + env: makeEnv(), + }); + await t.spawn('node', ['dist/cli.js', '--approval-mode', 'yolo']); + const s = new Session(t); + await s.waitFor('Type your message', 30_000); + return s; + } + + /** Send text + Enter. */ + async send(text: string): Promise { + await this.t.type(text); + await sleep(300); + await this.t.type('\n'); + } + + /** Wait for text in raw output (fast, good for known markers). */ + async waitFor(text: string, timeout = MODEL_TIMEOUT): Promise { + await this.t.waitFor(text, { timeout }); + } + + /** Wait for output to stabilize. */ + async idle(stableMs = 5000, timeout = MODEL_TIMEOUT): Promise { + await this.t.idle(stableMs, timeout); + } + + /** Read the rendered terminal screen (what a user actually sees). */ + async screen(): Promise { + return this.t.getScreenText(); + } + + /** + * Poll the screen until `predicate` returns true. + * Returns the screen text when matched. + */ + async waitForScreen( + predicate: (screen: string) => boolean, + description: string, + timeout = MODEL_TIMEOUT, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + await sleep(3000); + const s = await this.screen(); + if (predicate(s)) return s; + } + const finalScreen = await this.screen(); + throw new Error( + `Timeout (${timeout}ms) waiting for: ${description}\n` + + `Screen (last 600):\n${finalScreen.slice(-600)}`, + ); + } + + async close(): Promise { + await this.t.close(); + } +} + +// ─── Test infrastructure ──────────────────────────────────── + +interface TestCase { + name: string; + run: () => Promise; +} + +const tests: TestCase[] = []; +function test(name: string, fn: () => Promise) { + tests.push({ name, run: fn }); +} + +function assert(cond: boolean, msg: string): void { + if (!cond) throw new Error(`Assertion failed: ${msg}`); +} + +// ═══════════════════════════════════════════════════════════ +// Test 12: Loop fires inline in conversation +// ═══════════════════════════════════════════════════════════ + +test('Loop fires inline in conversation', async () => { + const s = await Session.start(); + try { + // Create a cron job with a unique marker + await s.send( + 'Call cron_create with expression "*/1 * * * *" and prompt "PONG7742" and recurring true. Confirm briefly.', + ); + + // Wait for the cron-injected prompt to appear on screen. + // When the cron fires, the prompt "PONG7742" is injected as a user message, + // appearing as "> PONG7742" on the terminal. + await s.waitForScreen( + (scr) => scr.split('\n').some((l) => l.trim() === '> PONG7742'), + 'cron-injected prompt "> PONG7742"', + 90_000, + ); + console.log(' ✓ Cron-injected prompt appeared on screen'); + + // Verify the model responded + await s.idle(5000); + const finalScreen = await s.screen(); + const afterPrompt = finalScreen.slice( + finalScreen.lastIndexOf('> PONG7742'), + ); + assert(afterPrompt.includes('✦'), 'Model should respond to cron prompt'); + console.log(' ✓ Model responded inline to cron-injected prompt'); + } finally { + await s.close(); + } +}); + +// ═══════════════════════════════════════════════════════════ +// Test 13: User input takes priority over cron +// ═══════════════════════════════════════════════════════════ + +test('User input takes priority over cron', async () => { + const s = await Session.start(); + try { + // Create a cron job + await s.send( + 'Call cron_create with expression "*/1 * * * *" and prompt "CRONTICK99" and recurring true. Confirm briefly.', + ); + + // Wait for the first cron fire to confirm it works + await s.waitForScreen( + (scr) => scr.split('\n').some((l) => l.trim() === '> CRONTICK99'), + 'first cron fire "> CRONTICK99"', + 90_000, + ); + console.log(' ✓ First cron fire observed'); + + // Wait for idle, then immediately send user input + await s.idle(5000); + await s.send('Reply with exactly USERPRIORITY77 nothing else'); + + // The user prompt should be processed and the model should respond + await s.waitForScreen( + (scr) => scr.includes('USERPRIORITY77'), + 'model response containing USERPRIORITY77', + ); + console.log(' ✓ User input processed while cron active'); + + // Verify session is still functional + const screen = await s.screen(); + assert( + screen.includes('Type your message'), + 'Session should still show input prompt', + ); + console.log(' ✓ Session remains functional'); + } finally { + await s.close(); + } +}); + +// ═══════════════════════════════════════════════════════════ +// Test 15: /loop skill — SKIPPED +// The /loop skill definition exists (SKILL.md) but isn't registered as a +// slash command yet ("Unknown command: /loop"). Skipping until implemented. +// ═══════════════════════════════════════════════════════════ + +// ═══════════════════════════════════════════════════════════ +// Test 16: Error during cron turn doesn't kill the loop +// ═══════════════════════════════════════════════════════════ + +test('Error during cron turn does not kill the loop', async () => { + const s = await Session.start(); + try { + // Create a cron job that reads a nonexistent file + await s.send( + 'Call cron_create with expression "*/1 * * * *" and prompt "Read the file /tmp/nonexistent_e2e_99.txt and report its contents. If it does not exist say FILEERR88." and recurring true. Confirm briefly.', + ); + + // Wait for the cron to fire and the model to report the error + await s.waitForScreen( + (scr) => scr.includes('FILEERR88'), + 'model reporting FILEERR88 from cron prompt', + 90_000, + ); + console.log(' ✓ Cron fired, model reported file error'); + + // Verify session is still functional by sending user input + await s.idle(5000); + await s.send('Reply with exactly ALIVE99 nothing else'); + await s.waitForScreen( + (scr) => scr.includes('ALIVE99'), + 'model response ALIVE99', + ); + console.log(' ✓ Session still functional after cron error'); + + // Verify the cron job is still active (the error didn't delete it) + await s.send( + 'Call cron_list and tell me how many jobs exist. Say "COUNT: N"', + ); + await s.idle(8000); + const screen = await s.screen(); + assert( + screen.includes('COUNT: 1') || + screen.includes('1 job') || + screen.includes('Active cron jobs (1)'), + 'Cron job should still be active after error', + ); + console.log(' ✓ Cron job still active (error did not kill the loop)'); + } finally { + await s.close(); + } +}); + +// ─── Runner ───────────────────────────────────────────────── + +async function main() { + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ In-Session Cron — Interactive Mode E2E Tests ║'); + console.log('╚══════════════════════════════════════════════════════╝\n'); + + const results: { + name: string; + passed: boolean; + error?: string; + durationMs: number; + }[] = []; + + for (const t of tests) { + console.log(` ▶ ${t.name}`); + const start = Date.now(); + try { + await t.run(); + const ms = Date.now() - start; + results.push({ name: t.name, passed: true, durationMs: ms }); + console.log(` ✓ PASSED (${(ms / 1000).toFixed(1)}s)\n`); + } catch (err) { + const ms = Date.now() - start; + const message = err instanceof Error ? err.message : String(err); + results.push({ + name: t.name, + passed: false, + error: message, + durationMs: ms, + }); + console.log(` ✗ FAILED (${(ms / 1000).toFixed(1)}s)`); + // Print first 3 lines of error + const errLines = message.split('\n').slice(0, 3).join('\n'); + console.log(` ${errLines}\n`); + } + } + + // Summary + console.log('════════════════════════════════════════════════════════'); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + const total = (r: (typeof results)[0]) => + `${(r.durationMs / 1000).toFixed(1)}s`; + for (const r of results) { + console.log(` ${r.passed ? '✓' : '✗'} ${r.name} (${total(r)})`); + } + console.log(`\n ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════════════════════'); + + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9b4b7553c..188fd91e7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1640,6 +1640,7 @@ export const useGeminiStream = ( // ─── Cron scheduler integration ───────────────────────── const cronQueueRef = useRef([]); + const [cronTrigger, setCronTrigger] = useState(0); // Start the scheduler on mount, stop on unmount useEffect(() => { @@ -1647,6 +1648,7 @@ export const useGeminiStream = ( const scheduler = config.getCronScheduler(); scheduler.start((job: { prompt: string }) => { cronQueueRef.current.push(job.prompt); + setCronTrigger((n) => n + 1); }); return () => { scheduler.stop(); @@ -1662,7 +1664,7 @@ export const useGeminiStream = ( const prompt = cronQueueRef.current.shift()!; submitQuery(prompt, SendMessageType.UserQuery); } - }, [streamingState, submitQuery]); + }, [streamingState, submitQuery, cronTrigger]); return { streamingState,