diff --git a/.gitignore b/.gitignore index a923e9bc1..0e7dc1528 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ packages/core/src/generated/ packages/vscode-ide-companion/*.vsix # Qwen Code Configs -.qwen/ + logs/ # GHA credentials gha-creds-*.json @@ -70,6 +70,8 @@ __pycache__/ integration-tests/concurrent-runner/output/ integration-tests/concurrent-runner/task-* +integration-tests/terminal-capture/scenarios/screenshots/ + # storybook *storybook.log storybook-static diff --git a/.qwen/skills/pr-review/SKILL.md b/.qwen/skills/pr-review/SKILL.md new file mode 100644 index 000000000..52bf75427 --- /dev/null +++ b/.qwen/skills/pr-review/SKILL.md @@ -0,0 +1,104 @@ +--- +name: pr-review +description: Reviews pull requests with code analysis and terminal smoke testing. Applies when examining code changes, running CLI tests, or when 'PR review', 'code review', 'terminal screenshot', 'visual test' is mentioned. +--- + +# PR Review — Code Review + Terminal Smoke Testing + +## Workflow + +### 1. Fetch PR Information + +```bash +# List open PRs +gh pr list + +# View PR details +gh pr view + +# Get diff +gh pr diff +``` + +### 2. Code Review + +Analyze changes across the following dimensions: + +- **Correctness** — Is the logic correct? Are edge cases handled? +- **Code Style** — Does it follow existing code style and conventions? +- **Performance** — Are there any performance concerns? +- **Test Coverage** — Are there corresponding tests for the changes? +- **Security** — Does it introduce any security risks? + +Output format: + +- 🔴 **Critical** — Must fix +- 🟡 **Suggestion** — Suggested improvement +- 🟢 **Nice to have** — Optional optimization + +### 3. Terminal Smoke Testing (Run for Every PR) + +**Run terminal-capture for every PR review**, not just UI changes. Reasons: + +- **Smoke Test** — Verify the CLI starts correctly and responds to user input, ensuring the PR didn't break anything +- **Visual Verification** — If there are UI changes, screenshots provide the most intuitive review evidence +- **Documentation** — Attach screenshots to the PR comments so reviewers can see the results without building locally + +```bash +# Checkout branch & build +gh pr checkout +npm run build +``` + +#### Scenario Selection Strategy + +Choose appropriate scenarios based on the PR's scope of changes: + +| PR Type | Recommended Scenarios | Description | +| ------------------------------------- | ------------------------------------------------------------ | --------------------------------- | +| **Any PR** (default) | smoke test: send `hi`, verify startup & response | Minimal-cost smoke validation | +| Slash command changes | Corresponding command scenarios (`/about`, `/context`, etc.) | Verify command output correctness | +| Ink component / layout changes | Multiple scenarios + full-flow long screenshot | Verify visual effects | +| Large refactors / dependency upgrades | Run `scenarios/all.ts` fully | Full regression | + +#### Running Screenshots + +```bash +# Write scenario config to integration-tests/terminal-capture/scenarios/ +# See terminal-capture skill for FlowStep API reference + +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/.ts + + +# Check output in screenshots/ directory +``` + +#### Minimal Smoke Test Example + +No need to write a new scenario file — just use the existing `about.ts`. It sends "hi" then runs `/about`, covering startup + input + command response: + +```bash +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts +``` + +### 4. Upload Screenshots to PR + +Use Playwright MCP browser to upload screenshots to the PR comments (images hosted at `github.com/user-attachments/assets/`, zero side effects): + +1. Open the PR page with Playwright: `https://github.com//pull/` +2. Click the comment text box and enter a comment title (e.g., `## 📷 Terminal Smoke Test Screenshots`) +3. Click the "Paste, drop, or click to add files" button to trigger the file picker +4. Upload screenshot PNG files via `browser_file_upload` (can upload multiple one by one) +5. Wait for GitHub to process (about 2-3 seconds) — image links auto-insert into the comment box +6. Click the "Comment" button to submit + +> **Prerequisite**: Playwright MCP needs `--user-data-dir` configured to persist GitHub login session. First time use requires manually logging into GitHub in the Playwright browser. + +### 5. Submit Review + +Submit code review comments via `gh pr review`: + +```bash +gh pr review --comment --body "review content" +``` diff --git a/.qwen/skills/terminal-capture/SKILL.md b/.qwen/skills/terminal-capture/SKILL.md new file mode 100644 index 000000000..adf8fff13 --- /dev/null +++ b/.qwen/skills/terminal-capture/SKILL.md @@ -0,0 +1,197 @@ +--- +name: terminal-capture +description: Automates terminal UI screenshot testing for CLI commands. Applies when reviewing PRs that affect CLI output, testing slash commands (/about, /context, /auth, /export), generating visual documentation, or when 'terminal screenshot', 'CLI test', 'visual test', or 'terminal-capture' is mentioned. +--- + +# Terminal Capture — CLI Terminal Screenshot Automation + +Drive terminal interactions and screenshots via TypeScript configuration, used for visual verification during PR reviews. + +## Prerequisites + +Ensure the following dependencies are installed before running: + +```bash +npm install # Install project dependencies (including node-pty, xterm, playwright, etc.) +npx playwright install chromium # Install Playwright browser +``` + +## Architecture + +``` +node-pty (pseudo-terminal) → ANSI byte stream → xterm.js (Playwright headless) → Screenshot +``` + +Core files: + +| File | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | +| `integration-tests/terminal-capture/terminal-capture.ts` | Low-level engine (PTY + xterm.js + Playwright) | +| `integration-tests/terminal-capture/scenario-runner.ts` | Scenario executor (parses config, drives interactions, auto-screenshots) | +| `integration-tests/terminal-capture/run.ts` | CLI entry point (batch run scenarios) | +| `integration-tests/terminal-capture/scenarios/*.ts` | Scenario configuration files | + +## Quick Start + +### 1. Write Scenario Configuration + +Create a `.ts` file under `integration-tests/terminal-capture/scenarios/`: + +```typescript +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, // Relative to this config file's location + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### 2. Run + +```bash +# Single scenario +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + +# Batch (entire directory) +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ +``` + +### 3. Output + +Screenshots are saved to `integration-tests/terminal-capture/scenarios/screenshots/{name}/`: + +| File | Description | +| --------------- | ---------------------------------- | +| `01-01.png` | Step 1 input state | +| `01-02.png` | Step 1 execution result | +| `02-01.png` | Step 2 input state | +| `02-02.png` | Step 2 execution result | +| `full-flow.png` | Final state full-length screenshot | + +## FlowStep API + +Each flow step can contain the following fields: + +### `type: string` — Input Text + +Automatic behavior: Input text → Screenshot (01) → Press Enter → Wait for output to stabilize → Screenshot (02). + +```typescript +{ + type: 'Hello'; +} // Plain text +{ + type: '/about'; +} // Slash command (auto-completion handled automatically) +``` + +**Special rule**: If the next step is `key`, do not auto-press Enter (hand over control to the key sequence). + +### `key: string | string[]` — Send Key Press + +Used for menu selection, Tab completion, and other interactions. Does not auto-press Enter or auto-screenshot. + +Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`, `Tab`, `Escape`, `Backspace`, `Space`, `Home`, `End`, `PageUp`, `PageDown`, `Delete` + +```typescript +{ + key: 'ArrowDown'; +} // Single key +{ + key: ['ArrowDown', 'ArrowDown', 'Enter']; +} // Multiple keys +``` + +Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`). + +### `capture` / `captureFull` — Explicit Screenshot + +Use as a standalone step, or override automatic naming: + +```typescript +{ + capture: 'initial.png'; +} // Screenshot current viewport only +{ + captureFull: 'all-output.png'; +} // Screenshot full scrollback buffer +``` + +## Scenario Examples + +### Basic: Input + Command + +```typescript +flow: [{ type: 'explain this project' }, { type: '/about' }]; +``` + +### Secondary Menu Selection (/auth) + +```typescript +flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key option + { key: 'Enter' }, // Confirm + { type: 'sk-xxx' }, // Input API key +]; +``` + +### Tab Completion Selection (/export) + +```typescript +flow: [ + { type: 'Tell me about yourself' }, + { type: '/export' }, // No auto-Enter (next step is key) + { key: 'Tab' }, // Pop format selection + { key: 'ArrowDown' }, // Select format + { key: 'Enter' }, // Confirm → auto-screenshot +]; +``` + +### Array Batch (Multiple Scenarios in One File) + +```typescript +export default [ + { name: '/about', spawn: [...], flow: [...] }, + { name: '/context', spawn: [...], flow: [...] }, +] satisfies ScenarioConfig[]; +``` + +## Integration with PR Review + +This tool is commonly used for visual verification during PR reviews. For the complete code review + screenshot workflow, see the [pr-review](../pr-review/SKILL.md) skill. + +## Troubleshooting + +| Issue | Cause | Solution | +| ------------------------------------ | ------------------------------------- | ---------------------------------------------------- | +| Playwright error `browser not found` | Browser not installed | `npx playwright install chromium` | +| Blank screenshot | Process starts slowly or build failed | Ensure `npm run build` succeeds, check spawn command | +| PTY-related errors | node-pty native module not compiled | `npm rebuild node-pty` | +| Unstable screenshot output | Terminal output not fully rendered | Check if the scenario needs additional wait time | + +## Full ScenarioConfig Type + +```typescript +interface ScenarioConfig { + name: string; // Scenario name (also used as screenshot subdirectory name) + spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"] + flow: FlowStep[]; // Interaction steps + terminal?: { + // Terminal configuration (all optional) + cols?: number; // Number of columns, default 100 + rows?: number; // Number of rows, default 28 + theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl + chrome?: boolean; // macOS window decorations, default true + title?: string; // Window title, default "Terminal" + fontSize?: number; // Font size + cwd?: string; // Working directory (relative to config file) + }; + outputDir?: string; // Screenshot output directory (relative to config file) +} +``` diff --git a/integration-tests/terminal-capture/motivation.md b/integration-tests/terminal-capture/motivation.md new file mode 100644 index 000000000..388019369 --- /dev/null +++ b/integration-tests/terminal-capture/motivation.md @@ -0,0 +1,117 @@ +# terminal-capture — Motivation and Positioning + +## 1. Overview of Existing Testing System + +| Layer | Tools | Coverage | Status | +| ---------------------- | ----------------------------------------- | --------------------------------------- | --------------------------------------------------------- | +| Unit Tests | Vitest + ink-testing-library | Ink components, Core logic, utilities | Mature, extensive `.test.ts` / `.test.tsx` | +| Integration Tests | Vitest + TestRig / SDKTestHelper | CLI E2E, SDK multi-turn, MCP, auth | Mature, supports none/docker/podman sandboxes | +| Terminal UI Snapshots | `toMatchSnapshot()` + ink-testing-library | Ink component render output (ANSI) | Exists, covers Footer, InputPrompt, MarkdownDisplay, etc. | +| Web UI Regression | Chromatic + Storybook | `packages/webui` components | Exists, but only covers Web UI | +| **Terminal UI Visual** | **terminal-capture** | CLI terminal real rendering screenshots | ✅ Implemented | + +## 2. Problems Solved by terminal-capture + +### Limitations of Existing Ink Text Snapshots + +The project uses `toMatchSnapshot()` to compare Ink component ANSI text output, which validates **text content**, but cannot verify: + +- Whether colors are correct (red separators? green highlights? Logo gradients?) +- Whether layout is aligned (table borders? multi-column layout?) +- Overall visual feel (component spacing? blank areas? overflow?) + +These can only be seen by **actually rendering to a terminal emulator**. + +### Core Architecture + +``` +node-pty (pseudo-terminal) + ↓ raw ANSI byte stream +xterm.js (running inside Playwright headless Chromium) + ↓ perfect rendering: colors, bold, cursor, scrolling +Playwright element screenshot + ↓ pixel-perfect screenshots (optional macOS window decorations) +``` + +### Core Features + +| Feature | Description | +| -------------------- | ----------------------------------------------------------------------------------- | +| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed | +| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) | +| Full-length | `captureFull()` supports capturing scrollback buffer content | +| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison | +| Batch Execution | `run.ts` executes all scenarios in one command | + +## 3. Usage + +### TypeScript Configuration-Driven + +Scenario config files (`scenarios/*.ts`) only need to declare `type` (input) and `key` (keypress), Runner handles automatically: + +- Wait for CLI readiness +- Auto-complete interference handling (/ commands auto-send Escape) +- Auto-screenshot before/after input (01 = input state, 02 = result) +- Auto-capture full-length image at last step (full-flow.png) +- Special key interactions (Arrow keys / Tab / Enter, etc.) + +```typescript +// integration-tests/terminal-capture/scenarios/about.ts +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], +} satisfies ScenarioConfig; +``` + +### Running + +```bash +# From project root +npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + +# Or inside terminal-capture directory +npm run capture +``` + +### Screenshot Output + +``` +scenarios/screenshots/ + about/ + 01-01.png # Step 1 input state + 01-02.png # Step 1 result + 02-01.png # Step 2 input state + 02-02.png # Step 2 result + full-flow.png # Final state full-length image + context/ + ... +``` + +## 4. Position in Testing System + +``` +┌─────────────────────────────────────┐ +│ Existing Testing System │ +├─────────────────────────────────────┤ +│ Unit Tests (Vitest) │ ← Function/Component level +│ Text Snapshots (ink-testing-lib) │ ← ANSI string comparison +│ Integration Tests (TestRig/SDK) │ ← E2E functionality +│ Web UI Regression (Chromatic) │ ← Only covers webui +├─────────────────────────────────────┤ +│ terminal-capture │ ← Terminal UI visual layer +│ (xterm.js + Playwright) │ Fills the gap +└─────────────────────────────────────┘ +``` + +## 5. Future Directions + +1. **Visual Regression** — Integrate Playwright `toHaveScreenshot()` for pixel-level baseline comparison, CI auto-detects terminal UI changes +2. **PR Workflow Integration** — Drive Agent via Cursor Skill to auto-checkout branch → build → screenshot → attach to review comment +3. **Complement to Chromatic** — Chromatic covers Web UI, terminal-capture covers CLI terminal UI diff --git a/integration-tests/terminal-capture/package.json b/integration-tests/terminal-capture/package.json new file mode 100644 index 000000000..ef55e63ef --- /dev/null +++ b/integration-tests/terminal-capture/package.json @@ -0,0 +1,18 @@ +{ + "name": "@qwen-code/terminal-capture", + "version": "0.1.0", + "private": true, + "description": "Terminal UI screenshot automation for CLI visual testing", + "type": "module", + "scripts": { + "capture": "npx tsx run.ts scenarios/", + "capture:about": "npx tsx run.ts scenarios/about.ts", + "capture:all": "npx tsx run.ts scenarios/all.ts" + }, + "dependencies": { + "@lydell/node-pty": "1.1.0", + "@xterm/xterm": "^5.5.0", + "playwright": "^1.50.0", + "strip-ansi": "^7.1.2" + } +} diff --git a/integration-tests/terminal-capture/run.ts b/integration-tests/terminal-capture/run.ts new file mode 100644 index 000000000..59b9ab547 --- /dev/null +++ b/integration-tests/terminal-capture/run.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env npx tsx +/** + * Batch run terminal screenshot scenarios + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ # batch + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/*.ts # glob + */ + +import { + loadScenarios, + runScenario, + type RunResult, +} from './scenario-runner.js'; +import { readdirSync, statSync } from 'node:fs'; +import { resolve, extname, join } from 'node:path'; + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log( + ` +Usage: npx tsx integration-tests/terminal-capture/run.ts ... + +Examples: + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + `.trim(), + ); + process.exit(1); + } + + // Collect all .ts scenario files from arguments + const scenarioFiles: string[] = []; + for (const arg of args) { + const abs = resolve(arg); + try { + const stat = statSync(abs); + if (stat.isDirectory()) { + const files = readdirSync(abs) + .filter((f) => extname(f) === '.ts') + .sort() + .map((f) => join(abs, f)); + scenarioFiles.push(...files); + } else { + scenarioFiles.push(abs); + } + } catch { + console.error(`❌ Not found: ${arg}`); + process.exit(1); + } + } + + if (scenarioFiles.length === 0) { + console.error('❌ No .ts scenario files found'); + process.exit(1); + } + + console.log(`🎬 Running ${scenarioFiles.length} scenario(s)...\n`); + + // Run scenarios sequentially (single file can export an array) + const results: RunResult[] = []; + for (const file of scenarioFiles) { + const { configs, basedir } = await loadScenarios(file); + for (const config of configs) { + const result = await runScenario(config, basedir); + results.push(result); + } + } + + // Summary + console.log(`\n${'═'.repeat(60)}`); + console.log('📊 Summary'); + console.log('═'.repeat(60)); + + const passed = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + const totalScreenshots = results.reduce( + (sum, r) => sum + r.screenshots.length, + 0, + ); + const totalTime = results.reduce((sum, r) => sum + r.durationMs, 0); + + for (const r of results) { + const icon = r.success ? '✅' : '❌'; + const time = (r.durationMs / 1000).toFixed(1); + console.log( + ` ${icon} ${r.name} — ${r.screenshots.length} screenshots, ${time}s`, + ); + if (r.error) console.log(` ${r.error}`); + } + + console.log( + `\n Total: ${passed.length} passed, ${failed.length} failed, ${totalScreenshots} screenshots, ${(totalTime / 1000).toFixed(1)}s`, + ); + + if (failed.length > 0) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/integration-tests/terminal-capture/scenario-runner.ts b/integration-tests/terminal-capture/scenario-runner.ts new file mode 100644 index 000000000..4bd858fd4 --- /dev/null +++ b/integration-tests/terminal-capture/scenario-runner.ts @@ -0,0 +1,304 @@ +/** + * Scenario Runner v3 — TypeScript Configuration-Driven Terminal Screenshots + * + * Configuration has only two core concepts: type (input) and capture (screenshot). + * All intelligent waiting is handled automatically by the Runner. + * + * Usage: + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/about.ts + * npx tsx integration-tests/terminal-capture/run.ts integration-tests/terminal-capture/scenarios/ + */ + +import { TerminalCapture, THEMES } from './terminal-capture.js'; +import { dirname, resolve, isAbsolute } from 'node:path'; + +// ───────────────────────────────────────────── +// Schema — Minimal +// ───────────────────────────────────────────── + +export interface FlowStep { + /** Input text (auto-press Enter, auto-wait for output to stabilize, auto-screenshot before/after) */ + type?: string; + /** + * Send special key presses (no auto-Enter, no auto-screenshot) + * Supported: ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Tab, Escape, Backspace, Space + * Can also pass ANSI escape sequence strings + */ + key?: string | string[]; + /** Explicit screenshot: current viewport (standalone capture when no type) */ + capture?: string; + /** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */ + captureFull?: string; +} + +export interface ScenarioConfig { + /** Scenario name */ + name: string; + /** Launch command, e.g., ["node", "dist/cli.js", "--yolo"] */ + spawn: string[]; + /** Execution flow: array, each item can contain type / capture / captureFull */ + flow: FlowStep[]; + /** Terminal configuration (all optional) */ + terminal?: { + cols?: number; + rows?: number; + theme?: string; + chrome?: boolean; + title?: string; + fontSize?: number; + cwd?: string; + }; + /** Screenshot output directory (relative to config file) */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Runner +// ───────────────────────────────────────────── + +export interface RunResult { + name: string; + screenshots: string[]; + success: boolean; + error?: string; + durationMs: number; +} + +/** Dynamically load configuration from .ts file (supports single object or array) */ +export async function loadScenarios( + tsPath: string, +): Promise<{ configs: ScenarioConfig[]; basedir: string }> { + const absPath = isAbsolute(tsPath) ? tsPath : resolve(tsPath); + const mod = (await import(absPath)) as { + default: ScenarioConfig | ScenarioConfig[]; + }; + const raw = mod.default; + const configs = Array.isArray(raw) ? raw : [raw]; + + for (const config of configs) { + if (!config?.name) throw new Error(`Missing 'name': ${absPath}`); + if (!config.spawn?.length) throw new Error(`Missing 'spawn': ${absPath}`); + if (!config.flow?.length) throw new Error(`Missing 'flow': ${absPath}`); + } + + return { configs, basedir: dirname(absPath) }; +} + +/** Execute a single scenario */ +export async function runScenario( + config: ScenarioConfig, + basedir: string, +): Promise { + const startTime = Date.now(); + const screenshots: string[] = []; + const t = config.terminal ?? {}; + + const cwd = t.cwd ? resolve(basedir, t.cwd) : resolve(basedir, '..'); + // Use scenario name as subdirectory to isolate screenshot outputs from different scenarios + const scenarioDir = + config.name + .replace(/^\//, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'unnamed'; + const outputDir = config.outputDir + ? resolve(basedir, config.outputDir, scenarioDir) + : resolve(basedir, 'screenshots', scenarioDir); + + console.log(`\n${'═'.repeat(60)}`); + console.log(`▶ ${config.name}`); + console.log('═'.repeat(60)); + + const terminal = await TerminalCapture.create({ + cols: t.cols ?? 100, + rows: t.rows ?? 28, + theme: (t.theme ?? 'dracula') as keyof typeof THEMES, + chrome: t.chrome ?? true, + title: t.title ?? 'Terminal', + fontSize: t.fontSize, + cwd, + outputDir, + }); + + try { + // ── Spawn ── + const [command, ...args] = config.spawn; + console.log(` spawn: ${config.spawn.join(' ')}`); + await terminal.spawn(command, args); + + // ── Auto-wait for CLI readiness ── + console.log(' ⏳ waiting for ready...'); + await terminal.idle(1500, 30000); + console.log(' ✅ ready'); + + // ── Execute flow ── + let seq = 0; // Global screenshot sequence number + + for (let i = 0; i < config.flow.length; i++) { + const step = config.flow[i]; + const label = `[${i + 1}/${config.flow.length}]`; + + if (step.type) { + const display = + step.type.length > 60 ? step.type.slice(0, 60) + '...' : step.type; + + // If next step is key, there's more interaction to do, so don't auto-press Enter + const nextStep = config.flow[i + 1]; + const autoEnter = !nextStep?.key; + + console.log( + ` ${label} type: "${display}"${autoEnter ? '' : ' (no auto-enter)'}`, + ); + + const text = step.type.replace(/\n$/, ''); + await terminal.type(text); + await sleep(300); + + // Only send Escape for / commands to close auto-complete, not for regular text + if (text.startsWith('/') && autoEnter) { + await terminal.type('\x1b'); + await sleep(100); + } + + // ── 01: Text input complete ── + seq++; + const inputName = step.capture + ? step.capture.replace(/\.png$/, '-01.png') + : `${pad(seq)}-01.png`; + console.log(` ${label} 📸 input: ${inputName}`); + screenshots.push(await terminal.capture(inputName)); + + if (autoEnter) { + // ── Auto-press Enter → Wait for stabilization → 02 screenshot ── + await terminal.type('\n'); + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = step.capture ?? `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // full-flow: Only the last type step auto-captures full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType || step.captureFull) { + const fullName = step.captureFull ?? 'full-flow.png'; + console.log(` ${label} 📸 full: ${fullName}`); + screenshots.push(await terminal.captureFull(fullName)); + } + } + // When not autoEnter, only captured before state, subsequent key steps take over interaction + } else if (step.key) { + // ── key: Send special key presses (arrow keys, Tab, Enter, etc.) ── + const keys = Array.isArray(step.key) ? step.key : [step.key]; + console.log(` ${label} key: ${keys.join(', ')}`); + + for (const k of keys) { + await terminal.type(resolveKey(k)); + await sleep(150); + } + // Wait for UI response to key press + await terminal.idle(500, 5000); + + // If key step has explicit capture/captureFull + if (step.capture || step.captureFull) { + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + + // After key sequence ends (next step is not key), auto-add result + full screenshots + const nextStep = config.flow[i + 1]; + if (!nextStep?.key) { + console.log(` ⏳ waiting for output to settle...`); + await terminal.idle(2000, 60000); + console.log(` ✅ settled`); + + const resultName = `${pad(seq)}-02.png`; + console.log(` ${label} 📸 result: ${resultName}`); + screenshots.push(await terminal.capture(resultName)); + + // If this is the last interaction step, add full-length image + const isLastType = !config.flow.slice(i + 1).some((s) => s.type); + if (isLastType) { + console.log(` ${label} 📸 full: full-flow.png`); + screenshots.push(await terminal.captureFull('full-flow.png')); + } + } + } else { + // ── Standalone screenshot step (no type/key) ── + seq++; + if (step.capture) { + console.log(` ${label} 📸 capture: ${step.capture}`); + screenshots.push(await terminal.capture(step.capture)); + } + if (step.captureFull) { + console.log(` ${label} 📸 captureFull: ${step.captureFull}`); + screenshots.push(await terminal.captureFull(step.captureFull)); + } + } + } + + const duration = Date.now() - startTime; + console.log( + `\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`, + ); + return { + name: config.name, + screenshots, + success: true, + durationMs: duration, + }; + } catch (err) { + const duration = Date.now() - startTime; + const msg = err instanceof Error ? err.message : String(err); + console.error(`\n ❌ ${config.name} — ${msg}`); + return { + name: config.name, + screenshots, + success: false, + error: msg, + durationMs: duration, + }; + } finally { + await terminal.close(); + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Pad sequence number with zero: 1 → "01" */ +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Key name → PTY escape sequence */ +const KEY_MAP: Record = { + ArrowUp: '\x1b[A', + ArrowDown: '\x1b[B', + ArrowRight: '\x1b[C', + ArrowLeft: '\x1b[D', + Enter: '\r', + Tab: '\t', + Escape: '\x1b', + Backspace: '\x7f', + Space: ' ', + Home: '\x1b[H', + End: '\x1b[F', + PageUp: '\x1b[5~', + PageDown: '\x1b[6~', + Delete: '\x1b[3~', +}; + +/** Parse key name to PTY-recognizable character sequence */ +function resolveKey(key: string): string { + return KEY_MAP[key] ?? key; +} diff --git a/integration-tests/terminal-capture/scenarios/about.ts b/integration-tests/terminal-capture/scenarios/about.ts new file mode 100644 index 000000000..6aae802ff --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/about.ts @@ -0,0 +1,8 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: '/about command', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [{ type: 'hi' }, { type: '/about' }], +} satisfies ScenarioConfig; diff --git a/integration-tests/terminal-capture/scenarios/all.ts b/integration-tests/terminal-capture/scenarios/all.ts new file mode 100644 index 000000000..a8bb8db81 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/all.ts @@ -0,0 +1,46 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default [ + { + name: '/about', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Hi, can you help me understand this codebase?' }, + { type: '/about' }, + ], + }, + { + name: '/context', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'How do you understand this project?' }, + { type: '/context' }, + ], + }, + + { + name: '/export (tab select)', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: 'Please give me a brief introduction about yourself.' }, + { type: '/export' }, + { key: 'Tab' }, // Tab to open format selection + { key: 'ArrowDown' }, // Down arrow to switch options + { key: 'Enter' }, // Confirm selection + ], + }, + { + name: '/auth', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { type: '/auth' }, + { key: 'ArrowDown' }, // Select API Key + { key: 'Enter' }, // Confirm + { type: 'sk-test-key-123' }, + ], + }, +] satisfies ScenarioConfig[]; diff --git a/integration-tests/terminal-capture/terminal-capture.ts b/integration-tests/terminal-capture/terminal-capture.ts new file mode 100644 index 000000000..1a2f27d63 --- /dev/null +++ b/integration-tests/terminal-capture/terminal-capture.ts @@ -0,0 +1,856 @@ +/** + * TerminalCapture - Terminal Screenshot Tool + * + * Terminal screenshot solution based on xterm.js + Playwright + node-pty. + * Core philosophy: WYSIWYG — let xterm.js complete terminal simulation and rendering + * inside the browser. Screenshots always capture the terminal's current real state, + * no manual output cleaning needed. + * + * Architecture: + * node-pty (pseudo-terminal) + * ↓ raw ANSI byte stream + * xterm.js (running inside Playwright headless Chromium) + * ↓ perfect rendering: colors, bold, cursor, scrolling + * Playwright element screenshot + * ↓ pixel-perfect screenshots (optional macOS window decorations) + */ + +import { chromium, type Browser, type Page } from 'playwright'; +import * as pty from '@lydell/node-pty'; +import stripAnsi from 'strip-ansi'; +import { mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +// ───────────────────────────────────────────── +// Theme definitions +// ───────────────────────────────────────────── + +export interface XtermTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent?: string; + selectionBackground?: string; + selectionForeground?: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export const THEMES: Record = { + dracula: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + selectionBackground: '#44475a', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff', + }, + + 'one-dark': { + background: '#282c34', + foreground: '#abb2bf', + cursor: '#528bff', + selectionBackground: '#3e4451', + black: '#545862', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#abb2bf', + brightBlack: '#545862', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#c8ccd4', + }, + + 'github-dark': { + background: '#0d1117', + foreground: '#c9d1d9', + cursor: '#c9d1d9', + selectionBackground: '#264f78', + black: '#484f58', + red: '#ff7b72', + green: '#3fb950', + yellow: '#d29922', + blue: '#58a6ff', + magenta: '#bc8cff', + cyan: '#39c5cf', + white: '#b1bac4', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#e3b341', + brightBlue: '#79c0ff', + brightMagenta: '#d2a8ff', + brightCyan: '#56d4dd', + brightWhite: '#f0f6fc', + }, + + monokai: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + selectionBackground: '#49483e', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5', + }, + + 'night-owl': { + background: '#011627', + foreground: '#d6deeb', + cursor: '#80a4c2', + selectionBackground: '#1d3b53', + black: '#011627', + red: '#ef5350', + green: '#22da6e', + yellow: '#addb67', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#21c7a8', + white: '#d6deeb', + brightBlack: '#575656', + brightRed: '#ef5350', + brightGreen: '#22da6e', + brightYellow: '#ffeb95', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#7fdbca', + brightWhite: '#ffffff', + }, +}; + +// ───────────────────────────────────────────── +// Options +// ───────────────────────────────────────────── + +export interface TerminalCaptureOptions { + /** Number of terminal columns, default 120 */ + cols?: number; + /** Number of terminal rows, default 40 */ + rows?: number; + /** Working directory */ + cwd?: string; + /** Environment variables */ + env?: NodeJS.ProcessEnv; + /** Theme name or custom theme object, default 'dracula' */ + theme?: keyof typeof THEMES | XtermTheme; + /** Whether to show macOS window decorations (traffic lights + title bar), default true */ + chrome?: boolean; + /** Window title (only effective when chrome=true), default 'Terminal' */ + title?: string; + /** Font size, default 14 */ + fontSize?: number; + /** Font family, default system monospace font */ + fontFamily?: string; + /** Default screenshot output directory */ + outputDir?: string; +} + +// ───────────────────────────────────────────── +// Main class +// ───────────────────────────────────────────── + +export class TerminalCapture { + private browser: Browser | null = null; + private page: Page | null = null; + private ptyProcess: pty.IPty | null = null; + private rawOutput = ''; + private lastFlushedLength = 0; + + private readonly cols: number; + private readonly rows: number; + private readonly cwd: string; + private readonly env: NodeJS.ProcessEnv; + private readonly theme: XtermTheme; + private readonly showChrome: boolean; + private readonly windowTitle: string; + private readonly fontSize: number; + private readonly fontFamily: string; + private readonly outputDir: string; + + // ── Factory ────────────────────────────── + + /** + * Create and initialize a TerminalCapture instance + * + * @example + * ```ts + * const t = await TerminalCapture.create({ + * theme: 'dracula', + * chrome: true, + * title: 'qwen-code', + * }); + * ``` + */ + static async create( + options?: TerminalCaptureOptions, + ): Promise { + const instance = new TerminalCapture(options); + await instance.init(); + return instance; + } + + private constructor(options?: TerminalCaptureOptions) { + this.cols = options?.cols ?? 120; + this.rows = options?.rows ?? 40; + this.cwd = options?.cwd ?? process.cwd(); + // Build a clean env for optimal terminal rendering: + // - Remove NO_COLOR (conflicts with FORCE_COLOR, can crash gradient components) + // - Suppress Node.js warnings (noisy in screenshots) + // - Force color output and 256-color terminal + const baseEnv = { ...process.env }; + delete baseEnv['NO_COLOR']; + this.env = options?.env ?? { + ...baseEnv, + FORCE_COLOR: '1', + TERM: 'xterm-256color', + NODE_NO_WARNINGS: '1', + }; + this.showChrome = options?.chrome ?? true; + this.windowTitle = options?.title ?? 'Terminal'; + this.fontSize = options?.fontSize ?? 14; + this.fontFamily = + options?.fontFamily ?? + "'Menlo', 'Monaco', 'Consolas', 'Courier New', monospace"; + this.outputDir = options?.outputDir ?? join(process.cwd(), 'screenshots'); + + // Resolve theme + if (typeof options?.theme === 'string') { + this.theme = THEMES[options.theme] ?? THEMES['dracula']; + } else if (options?.theme && typeof options.theme === 'object') { + this.theme = options.theme; + } else { + this.theme = THEMES['dracula']; + } + } + + // ── Lifecycle ──────────────────────────── + + private async init(): Promise { + // 1. Launch browser + this.browser = await chromium.launch({ headless: true }); + this.page = await this.browser.newPage({ + viewport: { width: 1600, height: 1000 }, + }); + + // 2. Set base HTML (with chrome decoration, container, etc.) + await this.page.setContent(this.buildHTML()); + + // 3. Load xterm.js from node_modules + const xtermDir = this.resolveXtermDir(); + await this.page.addStyleTag({ path: join(xtermDir, 'css', 'xterm.css') }); + await this.page.addScriptTag({ path: join(xtermDir, 'lib', 'xterm.js') }); + + // 4. Create xterm Terminal instance inside the page + + await this.page.evaluate( + ({ cols, rows, theme, fontSize, fontFamily }) => { + const W = window as unknown as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Terminal = W['Terminal'] as new (opts: unknown) => any; + const term = new Terminal({ + cols, + rows, + theme, + fontFamily, + fontSize, + lineHeight: 1.2, + cursorBlink: false, + allowProposedApi: true, + scrollback: 1000, + }); + + const container = document.getElementById('xterm-container')!; + + term.open(container); + + // Expose to outer scope + W['term'] = term; + W['termReady'] = true; + }, + { + cols: this.cols, + rows: this.rows, + theme: this.theme as unknown as Record, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + }, + ); + + // 5. Wait until terminal is ready + await this.page.waitForFunction( + () => + (window as unknown as Record)['termReady'] === true, + ); + } + + /** + * Spawn a command (via pseudo-terminal) + * + * @example + * ```ts + * await terminal.spawn('node', ['dist/cli.js', '--yolo']); + * ``` + */ + async spawn(command: string, args: string[] = []): Promise { + if (!this.page) { + throw new Error( + 'Not initialized. Use TerminalCapture.create() factory method.', + ); + } + + this.ptyProcess = pty.spawn(command, args, { + name: 'xterm-256color', + cols: this.cols, + rows: this.rows, + cwd: this.cwd, + env: this.env, + }); + + this.ptyProcess.onData((data) => { + this.rawOutput += data; + }); + } + + // ── Input ──────────────────────────────── + + /** + * Input text. Supports `\n` as Enter. + * + * @param text Text to input + * @param options.delay Delay after input (ms), default 10 + * @param options.slow Type character by character (simulate real typing), default false + * + * @example + * ```ts + * await terminal.type('Hello world\n'); // Input + Enter + * await terminal.type('ls -la\n', { slow: true, delay: 80 }); + * ``` + */ + async type( + text: string, + options?: { delay?: number; slow?: boolean }, + ): Promise { + if (!this.ptyProcess) { + throw new Error('No process running. Call spawn() first.'); + } + + // Convert \n to \r for PTY + const translated = text.replace(/\n/g, '\r'); + + if (options?.slow) { + for (const char of translated) { + this.ptyProcess.write(char); + await this.sleep(options.delay ?? 50); + } + } else { + this.ptyProcess.write(translated); + await this.sleep(options?.delay ?? 10); + } + } + + // ── Wait ───────────────────────────────── + + /** + * Wait for specific text to appear in terminal output + * + * @throws Error on timeout + * + * @example + * ```ts + * await terminal.waitFor('Type your message'); + * await terminal.waitFor('tokens', { timeout: 30000 }); + * ``` + */ + async waitFor(text: string, options?: { timeout?: number }): Promise { + const timeout = options?.timeout ?? 15000; + const start = Date.now(); + + while (Date.now() - start < timeout) { + if ( + stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase()) + ) { + return; + } + await this.sleep(200); + } + + throw new Error( + `Timeout (${timeout}ms) waiting for text: "${text}"\n` + + `Last 500 chars of output: ${stripAnsi(this.rawOutput).slice(-500)}`, + ); + } + + /** + * Wait for output to stabilize (no new output within specified time) + * + * @param stableMs Stability detection duration (ms), default 500 + * @param timeout Maximum wait time (ms), default 30000 + * + * @example + * ```ts + * await terminal.idle(); // Default: 500ms with no new output considered stable + * await terminal.idle(2000); // 2s with no new output + * ``` + */ + async idle(stableMs: number = 500, timeout: number = 30000): Promise { + const start = Date.now(); + let lastLength = this.rawOutput.length; + let lastChangeTime = Date.now(); + + while (Date.now() - start < timeout) { + await this.sleep(100); + if (this.rawOutput.length !== lastLength) { + lastLength = this.rawOutput.length; + lastChangeTime = Date.now(); + } else if (Date.now() - lastChangeTime >= stableMs) { + return; + } + } + // Timeout for idle() is not an error — just means output kept coming + } + + /** + * Wait for text to appear, then wait for output to stabilize (common combination) + */ + async waitForAndIdle( + text: string, + options?: { timeout?: number; stableMs?: number }, + ): Promise { + await this.waitFor(text, { timeout: options?.timeout }); + await this.idle(options?.stableMs ?? 300, 5000); + } + + // ── Capture ────────────────────────────── + + /** + * Capture and save a screenshot. Filenames are deterministic (no timestamps) for easy regression comparison. + * + * @param filename Filename, e.g., 'initial.png' + * @param outputDir Output directory, defaults to the outputDir from construction + * @returns Full path to the screenshot file + * + * @example + * ```ts + * await terminal.capture('01-initial.png'); + * await terminal.capture('02-output.png', '/tmp/screenshots'); + * ``` + */ + async capture(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + + // 2. Wait for xterm.js rendering to complete + await this.sleep(150); + + // 3. Prepare output directory + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + // 4. Screenshot the capture root (terminal + optional chrome) + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath }); + } + + console.log(`📸 Captured: ${filepath}`); + return filepath; + } + + /** + * Capture full terminal output (including scrollback buffer) as a long image. + * Suitable for scenarios where output exceeds the visible area, e.g., detailed token lists from /context. + * + * Principle: Temporarily expand xterm.js rows to show complete scrollback, then restore original dimensions after screenshot. + * Note: Only resizes xterm.js inside the browser, not the PTY dimensions, so it won't trigger CLI re-render. + * + * @param filename Filename + * @param outputDir Output directory + * @returns Full path to the screenshot file + * + * @example + * ```ts + * // Regular screenshot (only current viewport) + * await terminal.capture('output.png'); + * // Full-length image (including scrollback buffer) + * await terminal.captureFull('output-full.png'); + * ``` + */ + async captureFull(filename: string, outputDir?: string): Promise { + if (!this.page) { + throw new Error('Not initialized'); + } + + // 1. Flush all accumulated PTY data to xterm.js + await this.flush(); + await this.sleep(150); + + // 2. Query xterm.js for the actual content height (skip trailing empty lines) + const contentLines = await 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; + let lastNonEmpty = 0; + for (let i = buf.length - 1; i >= 0; i--) { + const line = buf.getLine(i); + if (line && line.translateToString(true).trim().length > 0) { + lastNonEmpty = i; + break; + } + } + return lastNonEmpty + 1; + }); + + const expandedRows = Math.max(contentLines + 2, this.rows); + + // 3. Temporarily resize xterm.js only (NOT the PTY) to show all content + // This avoids sending SIGWINCH to the child process, so the CLI won't re-render + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { + resize: (c: number, r: number) => void; + scrollToTop: () => void; + }; + term.resize(cols, rows); + // Scroll to top to ensure rendering starts from scrollback beginning position + term.scrollToTop(); + }, + { cols: this.cols, rows: expandedRows }, + ); + + // 4. Expand viewport to accommodate the taller terminal + await this.page.setViewportSize({ + width: 1600, + height: Math.max(expandedRows * 22, 1000), // ~22px per row (fontSize 14 * lineHeight 1.2 + padding) + }); + + await this.sleep(300); + + // 5. Screenshot the full content + const dir = outputDir ?? this.outputDir; + mkdirSync(dir, { recursive: true }); + const filepath = join(dir, filename); + + const element = await this.page.$('#capture-root'); + if (element) { + await element.screenshot({ path: filepath }); + } else { + await this.page.screenshot({ path: filepath, fullPage: true }); + } + + // 6. Restore original xterm.js dimensions and viewport + await this.page.evaluate( + ({ cols, rows }: { cols: number; rows: number }) => { + const W = window as unknown as Record; + const term = W['term'] as { resize: (c: number, r: number) => void }; + term.resize(cols, rows); + }, + { cols: this.cols, rows: this.rows }, + ); + + await this.page.setViewportSize({ width: 1600, height: 1000 }); + + console.log(`📸 Captured (full): ${filepath}`); + return filepath; + } + + // ── Output access ──────────────────────── + + /** + * Get cleaned terminal output (without ANSI escape sequences) + */ + getOutput(): string { + return stripAnsi(this.rawOutput); + } + + /** + * Get raw terminal output (with ANSI escape sequences) + */ + getRawOutput(): string { + return this.rawOutput; + } + + // ── Cleanup ────────────────────────────── + + /** + * Release all resources (PTY process, browser) + */ + async close(): Promise { + if (this.ptyProcess) { + try { + this.ptyProcess.kill(); + } catch { + // Process may have already exited + } + this.ptyProcess = null; + } + + if (this.browser) { + await this.browser.close(); + this.browser = null; + this.page = null; + } + } + + // ── Internal: flush PTY → xterm.js ────── + + /** + * Flush accumulated PTY raw output to xterm.js inside the browser. + * Uses xterm.js's write callback to ensure data is fully parsed, + * then waits one requestAnimationFrame to ensure rendering is complete. + */ + private async flush(): Promise { + if (!this.page || this.rawOutput.length <= this.lastFlushedLength) { + return; + } + + const newData = this.rawOutput.slice(this.lastFlushedLength); + this.lastFlushedLength = this.rawOutput.length; + + // Send data in chunks to avoid hitting string size limits + const CHUNK_SIZE = 64 * 1024; + for (let i = 0; i < newData.length; i += CHUNK_SIZE) { + const chunk = newData.slice(i, i + CHUNK_SIZE); + await this.page.evaluate((data: string) => { + return new Promise((resolve) => { + const W = window as unknown as Record; + const term = W['term'] as { + write: (d: string, cb: () => void) => void; + }; + term.write(data, () => { + // Data parsed → wait one frame for rendering + requestAnimationFrame(() => resolve()); + }); + }); + }, chunk); + } + } + + // ── Internal: resolve xterm.js path ───── + + private resolveXtermDir(): string { + try { + const pkgJsonPath = _require.resolve('@xterm/xterm/package.json'); + return dirname(pkgJsonPath); + } catch { + throw new Error( + '@xterm/xterm is not installed.\n' + + 'Run: npm install --save-dev @xterm/xterm', + ); + } + } + + // ── Internal: build HTML ──────────────── + + private buildHTML(): string { + const bg = this.theme.background; + + // Title bar color: slightly lighter than background + // Use a manual approximation instead of color-mix for compatibility + const titleBarBg = this.lighten(bg, 0.08); + + const chromeHTML = this.showChrome + ? ` +
+
+ + + +
+ ${this.escapeHtml(this.windowTitle)} +
+
` + : ''; + + return ` + + + + + + +
+ ${chromeHTML} +
+
+ +`; + } + + // ── Internal: utils ───────────────────── + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Lighten a hex color by a factor (0-1) + */ + private lighten(hex: string, factor: number): string { + const h = hex.replace('#', ''); + const r = Math.min( + 255, + parseInt(h.slice(0, 2), 16) + Math.round(255 * factor), + ); + const g = Math.min( + 255, + parseInt(h.slice(2, 4), 16) + Math.round(255 * factor), + ); + const b = Math.min( + 255, + parseInt(h.slice(4, 6), 16) + Math.round(255 * factor), + ); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +}