From ded89618ec85da84b401a272f1f1dd7b8ba8a276 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 05:46:37 +0000 Subject: [PATCH] refactor(tests): reorganize integration tests by execution mode Move non-interactive tests to cli/, interactive tests to interactive/. Add cron-interactive.test.ts wrapping terminal-capture E2E in vitest. Update npm scripts and release workflow for new directory layout. --- .github/workflows/release.yml | 2 + .../{ => cli}/acp-integration.test.ts | 2 +- .../{ => cli}/cron-tools.test.ts | 6 +- integration-tests/{ => cli}/edit.test.ts | 6 +- .../{ => cli}/extensions-install.test.ts | 2 +- .../{ => cli}/file-system.test.ts | 6 +- .../{ => cli}/json-output.test.ts | 2 +- .../{ => cli}/list_directory.test.ts | 6 +- .../mcp_server_cyclic_schema.test.ts | 2 +- .../{ => cli}/read_many_files.test.ts | 6 +- .../{ => cli}/run_shell_command.test.ts | 6 +- .../{ => cli}/save_memory.test.ts | 6 +- .../{ => cli}/settings-migration.test.ts | 4 +- .../{ => cli}/simple-mcp-server.test.ts | 2 +- .../{ => cli}/stdin-context.test.ts | 6 +- integration-tests/{ => cli}/telemetry.test.ts | 2 +- .../{ => cli}/todo_write.test.ts | 6 +- .../{ => cli}/utf-bom-encoding.test.ts | 2 +- .../{ => cli}/web_search.test.ts | 6 +- .../{ => cli}/write_file.test.ts | 2 +- .../context-compress-interactive.test.ts | 2 +- .../interactive/cron-interactive.test.ts | 190 +++++++++++ .../{ => interactive}/ctrl-c-exit.test.ts | 2 +- .../file-system-interactive.test.ts | 2 +- .../{ => interactive}/hooks-command.test.ts | 2 +- .../mixed-input-crash.test.ts | 2 +- .../test-cron-interactive-e2e.ts | 300 ------------------ package.json | 6 +- 28 files changed, 261 insertions(+), 327 deletions(-) rename integration-tests/{ => cli}/acp-integration.test.ts (99%) rename integration-tests/{ => cli}/cron-tools.test.ts (97%) rename integration-tests/{ => cli}/edit.test.ts (98%) rename integration-tests/{ => cli}/extensions-install.test.ts (96%) rename integration-tests/{ => cli}/file-system.test.ts (98%) rename integration-tests/{ => cli}/json-output.test.ts (99%) rename integration-tests/{ => cli}/list_directory.test.ts (95%) rename integration-tests/{ => cli}/mcp_server_cyclic_schema.test.ts (99%) rename integration-tests/{ => cli}/read_many_files.test.ts (94%) rename integration-tests/{ => cli}/run_shell_command.test.ts (97%) rename integration-tests/{ => cli}/save_memory.test.ts (94%) rename integration-tests/{ => cli}/settings-migration.test.ts (99%) rename integration-tests/{ => cli}/simple-mcp-server.test.ts (98%) rename integration-tests/{ => cli}/stdin-context.test.ts (97%) rename integration-tests/{ => cli}/telemetry.test.ts (94%) rename integration-tests/{ => cli}/todo_write.test.ts (95%) rename integration-tests/{ => cli}/utf-bom-encoding.test.ts (99%) rename integration-tests/{ => cli}/web_search.test.ts (97%) rename integration-tests/{ => cli}/write_file.test.ts (98%) rename integration-tests/{ => interactive}/context-compress-interactive.test.ts (98%) create mode 100644 integration-tests/interactive/cron-interactive.test.ts rename integration-tests/{ => interactive}/ctrl-c-exit.test.ts (98%) rename integration-tests/{ => interactive}/file-system-interactive.test.ts (97%) rename integration-tests/{ => interactive}/hooks-command.test.ts (97%) rename integration-tests/{ => interactive}/mixed-input-crash.test.ts (97%) delete mode 100644 integration-tests/terminal-capture/test-cron-interactive-e2e.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617cf9553..b26fdb88d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,9 @@ jobs: run: | npm run preflight npm run test:integration:cli:sandbox:none + npm run test:integration:interactive:sandbox:none npm run test:integration:cli:sandbox:docker + npm run test:integration:interactive:sandbox:docker env: OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts similarity index 99% rename from integration-tests/acp-integration.test.ts rename to integration-tests/cli/acp-integration.test.ts index 0f7770e6c..98a056700 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { setTimeout as delay } from 'node:timers/promises'; import { describe, expect, it } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; const REQUEST_TIMEOUT_MS = 60_000; const INITIAL_PROMPT = 'Create a quick note (smoke test).'; diff --git a/integration-tests/cron-tools.test.ts b/integration-tests/cli/cron-tools.test.ts similarity index 97% rename from integration-tests/cron-tools.test.ts rename to integration-tests/cli/cron-tools.test.ts index 485eec1f2..a6cd1e349 100644 --- a/integration-tests/cron-tools.test.ts +++ b/integration-tests/cli/cron-tools.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('cron-tools', () => { let rig: TestRig; diff --git a/integration-tests/edit.test.ts b/integration-tests/cli/edit.test.ts similarity index 98% rename from integration-tests/edit.test.ts rename to integration-tests/cli/edit.test.ts index 175f0d85a..670d00c17 100644 --- a/integration-tests/edit.test.ts +++ b/integration-tests/cli/edit.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('edit', () => { it('should be able to edit content in a file', async () => { diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/cli/extensions-install.test.ts similarity index 96% rename from integration-tests/extensions-install.test.ts rename to integration-tests/cli/extensions-install.test.ts index 935d0ac54..58afd4024 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/cli/extensions-install.test.ts @@ -5,7 +5,7 @@ */ import { expect, test } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/integration-tests/file-system.test.ts b/integration-tests/cli/file-system.test.ts similarity index 98% rename from integration-tests/file-system.test.ts rename to integration-tests/cli/file-system.test.ts index f4c60edd7..8b564b954 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/cli/file-system.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('file-system', () => { it('should be able to read a file', async () => { diff --git a/integration-tests/json-output.test.ts b/integration-tests/cli/json-output.test.ts similarity index 99% rename from integration-tests/json-output.test.ts rename to integration-tests/cli/json-output.test.ts index 37dca8678..27806f7b8 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/cli/json-output.test.ts @@ -5,7 +5,7 @@ */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('JSON output', () => { let rig: TestRig; diff --git a/integration-tests/list_directory.test.ts b/integration-tests/cli/list_directory.test.ts similarity index 95% rename from integration-tests/list_directory.test.ts rename to integration-tests/cli/list_directory.test.ts index 6d3cc37ad..eaad67d90 100644 --- a/integration-tests/list_directory.test.ts +++ b/integration-tests/cli/list_directory.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; diff --git a/integration-tests/mcp_server_cyclic_schema.test.ts b/integration-tests/cli/mcp_server_cyclic_schema.test.ts similarity index 99% rename from integration-tests/mcp_server_cyclic_schema.test.ts rename to integration-tests/cli/mcp_server_cyclic_schema.test.ts index 40963a240..feed4693b 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.ts +++ b/integration-tests/cli/mcp_server_cyclic_schema.test.ts @@ -24,7 +24,7 @@ import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; // Create a minimal MCP server that doesn't require external dependencies // This implements the MCP protocol directly using Node.js built-ins diff --git a/integration-tests/read_many_files.test.ts b/integration-tests/cli/read_many_files.test.ts similarity index 94% rename from integration-tests/read_many_files.test.ts rename to integration-tests/cli/read_many_files.test.ts index 396732439..23f54f5a8 100644 --- a/integration-tests/read_many_files.test.ts +++ b/integration-tests/cli/read_many_files.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('read_many_files', () => { it.skip('should be able to read multiple files', async () => { diff --git a/integration-tests/run_shell_command.test.ts b/integration-tests/cli/run_shell_command.test.ts similarity index 97% rename from integration-tests/run_shell_command.test.ts rename to integration-tests/cli/run_shell_command.test.ts index 4b0b99677..890ad0082 100644 --- a/integration-tests/run_shell_command.test.ts +++ b/integration-tests/cli/run_shell_command.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('run_shell_command', () => { it('should be able to run a shell command', async () => { diff --git a/integration-tests/save_memory.test.ts b/integration-tests/cli/save_memory.test.ts similarity index 94% rename from integration-tests/save_memory.test.ts rename to integration-tests/cli/save_memory.test.ts index 40ede6835..1ffb6beef 100644 --- a/integration-tests/save_memory.test.ts +++ b/integration-tests/cli/save_memory.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('save_memory', () => { // Skipped due to flaky model behavior - the model sometimes answers the question diff --git a/integration-tests/settings-migration.test.ts b/integration-tests/cli/settings-migration.test.ts similarity index 99% rename from integration-tests/settings-migration.test.ts rename to integration-tests/cli/settings-migration.test.ts index fa5446c17..3be7cee24 100644 --- a/integration-tests/settings-migration.test.ts +++ b/integration-tests/cli/settings-migration.test.ts @@ -5,12 +5,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; import { writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; // Import settings fixtures from unified workspace file -import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' }; +import workspacesSettings from '../fixtures/settings-migration/workspaces.json' with { type: 'json' }; const { v1Settings, diff --git a/integration-tests/simple-mcp-server.test.ts b/integration-tests/cli/simple-mcp-server.test.ts similarity index 98% rename from integration-tests/simple-mcp-server.test.ts rename to integration-tests/cli/simple-mcp-server.test.ts index cdb9ee21a..3e50174d2 100644 --- a/integration-tests/simple-mcp-server.test.ts +++ b/integration-tests/cli/simple-mcp-server.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, beforeAll, expect } from 'vitest'; -import { TestRig, validateModelOutput } from './test-helper.js'; +import { TestRig, validateModelOutput } from '../test-helper.js'; import { join } from 'node:path'; import { writeFileSync } from 'node:fs'; diff --git a/integration-tests/stdin-context.test.ts b/integration-tests/cli/stdin-context.test.ts similarity index 97% rename from integration-tests/stdin-context.test.ts rename to integration-tests/cli/stdin-context.test.ts index 3ec681000..2dd4aca74 100644 --- a/integration-tests/stdin-context.test.ts +++ b/integration-tests/cli/stdin-context.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe.skip('stdin context', () => { it('should be able to use stdin as context for a prompt', async () => { diff --git a/integration-tests/telemetry.test.ts b/integration-tests/cli/telemetry.test.ts similarity index 94% rename from integration-tests/telemetry.test.ts rename to integration-tests/cli/telemetry.test.ts index 111f24c86..ac63e1ca0 100644 --- a/integration-tests/telemetry.test.ts +++ b/integration-tests/cli/telemetry.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('telemetry', () => { it('should emit a metric and a log event', async () => { diff --git a/integration-tests/todo_write.test.ts b/integration-tests/cli/todo_write.test.ts similarity index 95% rename from integration-tests/todo_write.test.ts rename to integration-tests/cli/todo_write.test.ts index 5c63e3c48..5bc28125f 100644 --- a/integration-tests/todo_write.test.ts +++ b/integration-tests/cli/todo_write.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('todo_write', () => { it('should be able to create and manage a todo list', async () => { diff --git a/integration-tests/utf-bom-encoding.test.ts b/integration-tests/cli/utf-bom-encoding.test.ts similarity index 99% rename from integration-tests/utf-bom-encoding.test.ts rename to integration-tests/cli/utf-bom-encoding.test.ts index 31dd41522..be34f8eb0 100644 --- a/integration-tests/utf-bom-encoding.test.ts +++ b/integration-tests/cli/utf-bom-encoding.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; // Windows skip (Option A: avoid infra scope) const d = process.platform === 'win32' ? describe.skip : describe; diff --git a/integration-tests/web_search.test.ts b/integration-tests/cli/web_search.test.ts similarity index 97% rename from integration-tests/web_search.test.ts rename to integration-tests/cli/web_search.test.ts index 680a1ffdf..5ab0b4364 100644 --- a/integration-tests/web_search.test.ts +++ b/integration-tests/cli/web_search.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; +import { + TestRig, + printDebugInfo, + validateModelOutput, +} from '../test-helper.js'; describe('web_search', () => { it('should be able to search the web', async () => { diff --git a/integration-tests/write_file.test.ts b/integration-tests/cli/write_file.test.ts similarity index 98% rename from integration-tests/write_file.test.ts rename to integration-tests/cli/write_file.test.ts index 7dd6445d9..2440c5931 100644 --- a/integration-tests/write_file.test.ts +++ b/integration-tests/cli/write_file.test.ts @@ -10,7 +10,7 @@ import { createToolCallErrorMessage, printDebugInfo, validateModelOutput, -} from './test-helper.js'; +} from '../test-helper.js'; describe('write_file', () => { it('should be able to write a file', async () => { diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/interactive/context-compress-interactive.test.ts similarity index 98% rename from integration-tests/context-compress-interactive.test.ts rename to integration-tests/interactive/context-compress-interactive.test.ts index 378575f4f..1018c846b 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/interactive/context-compress-interactive.test.ts @@ -5,7 +5,7 @@ */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; -import { TestRig, type } from './test-helper.js'; +import { TestRig, type } from '../test-helper.js'; describe('Interactive Mode', () => { let rig: TestRig; diff --git a/integration-tests/interactive/cron-interactive.test.ts b/integration-tests/interactive/cron-interactive.test.ts new file mode 100644 index 000000000..561036d53 --- /dev/null +++ b/integration-tests/interactive/cron-interactive.test.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * In-session cron/loop interactive E2E tests. + * + * These drive the full interactive TUI via TerminalCapture (node-pty + xterm.js + * + Playwright) and read the rendered terminal screen. Ported from the + * standalone script at terminal-capture/test-cron-interactive-e2e.ts. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { TerminalCapture } from '../terminal-capture/terminal-capture.js'; + +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; + } + + async send(text: string): Promise { + await this.t.type(text); + await sleep(300); + await this.t.type('\n'); + } + + async waitFor(text: string, timeout = MODEL_TIMEOUT): Promise { + await this.t.waitFor(text, { timeout }); + } + + async idle(stableMs = 5000, timeout = MODEL_TIMEOUT): Promise { + await this.t.idle(stableMs, timeout); + } + + async screen(): Promise { + return this.t.getScreenText(); + } + + 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(); + } +} + +describe('cron interactive (terminal-capture)', () => { + let session: Session | null = null; + + afterEach(async () => { + if (session) { + await session.close(); + session = null; + } + }); + + it( + 'loop fires inline in conversation', + async () => { + session = await Session.start(); + + await session.send( + 'Call cron_create with expression "*/1 * * * *" and prompt "PONG7742" and recurring true. Confirm briefly.', + ); + + await session.waitForScreen( + (scr) => scr.split('\n').some((l) => l.trim() === '> PONG7742'), + 'cron-injected prompt "> PONG7742"', + 90_000, + ); + + await session.idle(5000); + const finalScreen = await session.screen(); + const afterPrompt = finalScreen.slice( + finalScreen.lastIndexOf('> PONG7742'), + ); + expect(afterPrompt).toContain('✦'); + }, + { timeout: 180_000 }, + ); + + it( + 'user input takes priority over cron', + async () => { + session = await Session.start(); + + await session.send( + 'Call cron_create with expression "*/1 * * * *" and prompt "CRONTICK99" and recurring true. Confirm briefly.', + ); + + await session.waitForScreen( + (scr) => scr.split('\n').some((l) => l.trim() === '> CRONTICK99'), + 'first cron fire "> CRONTICK99"', + 90_000, + ); + + await session.idle(5000); + await session.send('Reply with exactly USERPRIORITY77 nothing else'); + + await session.waitForScreen( + (scr) => scr.includes('USERPRIORITY77'), + 'model response containing USERPRIORITY77', + ); + + const screen = await session.screen(); + expect(screen).toContain('Type your message'); + }, + { timeout: 180_000 }, + ); + + it( + 'error during cron turn does not kill the loop', + async () => { + session = await Session.start(); + + await session.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.', + ); + + await session.waitForScreen( + (scr) => scr.includes('FILEERR88'), + 'model reporting FILEERR88 from cron prompt', + 90_000, + ); + + await session.idle(5000); + await session.send('Reply with exactly ALIVE99 nothing else'); + await session.waitForScreen( + (scr) => scr.includes('ALIVE99'), + 'model response ALIVE99', + ); + + await session.send( + 'Call cron_list and tell me how many jobs exist. Say "COUNT: N"', + ); + await session.idle(8000); + const screen = await session.screen(); + expect( + screen.includes('COUNT: 1') || + screen.includes('1 job') || + screen.includes('Active cron jobs (1)'), + ).toBe(true); + }, + { timeout: 180_000 }, + ); +}); diff --git a/integration-tests/ctrl-c-exit.test.ts b/integration-tests/interactive/ctrl-c-exit.test.ts similarity index 98% rename from integration-tests/ctrl-c-exit.test.ts rename to integration-tests/interactive/ctrl-c-exit.test.ts index 3f5b011fa..e0718b466 100644 --- a/integration-tests/ctrl-c-exit.test.ts +++ b/integration-tests/interactive/ctrl-c-exit.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('Ctrl+C exit', () => { // (#9782) Temporarily disabling on windows because it is failing on main and every diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/interactive/file-system-interactive.test.ts similarity index 97% rename from integration-tests/file-system-interactive.test.ts rename to integration-tests/interactive/file-system-interactive.test.ts index 31a9feb15..a583ce1ae 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/interactive/file-system-interactive.test.ts @@ -5,7 +5,7 @@ */ import { expect, describe, it, beforeEach, afterEach } from 'vitest'; -import { TestRig, type, printDebugInfo } from './test-helper.js'; +import { TestRig, type, printDebugInfo } from '../test-helper.js'; describe('Interactive file system', () => { let rig: TestRig; diff --git a/integration-tests/hooks-command.test.ts b/integration-tests/interactive/hooks-command.test.ts similarity index 97% rename from integration-tests/hooks-command.test.ts rename to integration-tests/interactive/hooks-command.test.ts index 0fb67f00f..9d07d0d22 100644 --- a/integration-tests/hooks-command.test.ts +++ b/integration-tests/interactive/hooks-command.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('/hooks command', () => { let rig: TestRig; diff --git a/integration-tests/mixed-input-crash.test.ts b/integration-tests/interactive/mixed-input-crash.test.ts similarity index 97% rename from integration-tests/mixed-input-crash.test.ts rename to integration-tests/interactive/mixed-input-crash.test.ts index e2db64731..5ac33eddf 100644 --- a/integration-tests/mixed-input-crash.test.ts +++ b/integration-tests/interactive/mixed-input-crash.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { TestRig } from './test-helper.js'; +import { TestRig } from '../test-helper.js'; describe('mixed input crash prevention', () => { it('should not crash when using mixed prompt inputs', async () => { diff --git a/integration-tests/terminal-capture/test-cron-interactive-e2e.ts b/integration-tests/terminal-capture/test-cron-interactive-e2e.ts deleted file mode 100644 index ce3959562..000000000 --- a/integration-tests/terminal-capture/test-cron-interactive-e2e.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * 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/package.json b/package.json index 6ad721f86..477bd4b01 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,10 @@ "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", - "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", - "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", + "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli", + "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli", + "test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive", + "test:integration:interactive:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests interactive", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", "test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'", "test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",