mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
feat: add terminal-capture for CLI screenshot automation
- Add terminal-capture engine using node-pty + xterm.js + Playwright - Add scenario runner with TypeScript configuration - Add pre-built scenarios (/about, /context, /export, /auth) - Add Cursor skills for terminal-capture and pr-review workflow - Add motivation documentation Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
03c3889991
commit
997fcbfaed
10 changed files with 1758 additions and 1 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
104
.qwen/skills/pr-review/SKILL.md
Normal file
104
.qwen/skills/pr-review/SKILL.md
Normal file
|
|
@ -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 <number>
|
||||
|
||||
# Get diff
|
||||
gh pr diff <number>
|
||||
```
|
||||
|
||||
### 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 <number>
|
||||
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/<scenario>.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/<repo>/pull/<number>`
|
||||
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 <number> --comment --body "review content"
|
||||
```
|
||||
197
.qwen/skills/terminal-capture/SKILL.md
Normal file
197
.qwen/skills/terminal-capture/SKILL.md
Normal file
|
|
@ -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)
|
||||
}
|
||||
```
|
||||
117
integration-tests/terminal-capture/motivation.md
Normal file
117
integration-tests/terminal-capture/motivation.md
Normal file
|
|
@ -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
|
||||
18
integration-tests/terminal-capture/package.json
Normal file
18
integration-tests/terminal-capture/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
105
integration-tests/terminal-capture/run.ts
Normal file
105
integration-tests/terminal-capture/run.ts
Normal file
|
|
@ -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 <scenario.ts | directory>...
|
||||
|
||||
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);
|
||||
});
|
||||
304
integration-tests/terminal-capture/scenario-runner.ts
Normal file
304
integration-tests/terminal-capture/scenario-runner.ts
Normal file
|
|
@ -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<RunResult> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
8
integration-tests/terminal-capture/scenarios/about.ts
Normal file
8
integration-tests/terminal-capture/scenarios/about.ts
Normal file
|
|
@ -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;
|
||||
46
integration-tests/terminal-capture/scenarios/all.ts
Normal file
46
integration-tests/terminal-capture/scenarios/all.ts
Normal file
|
|
@ -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[];
|
||||
856
integration-tests/terminal-capture/terminal-capture.ts
Normal file
856
integration-tests/terminal-capture/terminal-capture.ts
Normal file
|
|
@ -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<string, XtermTheme> = {
|
||||
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<TerminalCapture> {
|
||||
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<void> {
|
||||
// 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<string, unknown>;
|
||||
// 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<string, string>,
|
||||
fontSize: this.fontSize,
|
||||
fontFamily: this.fontFamily,
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Wait until terminal is ready
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
(window as unknown as Record<string, unknown>)['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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((resolve) => {
|
||||
const W = window as unknown as Record<string, unknown>;
|
||||
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
|
||||
? `
|
||||
<div class="title-bar" style="background: ${titleBarBg};">
|
||||
<div class="traffic-lights">
|
||||
<span class="tl tl-close"></span>
|
||||
<span class="tl tl-minimize"></span>
|
||||
<span class="tl tl-maximize"></span>
|
||||
</div>
|
||||
<span class="title-text">${this.escapeHtml(this.windowTitle)}</span>
|
||||
<div class="traffic-lights-spacer"></div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: #0e0e1a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#capture-root {
|
||||
display: inline-block;
|
||||
border-radius: ${this.showChrome ? '10px' : '6px'};
|
||||
overflow: hidden;
|
||||
background: ${bg};
|
||||
box-shadow:
|
||||
0 25px 70px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Title bar (macOS chrome) ── */
|
||||
.title-bar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.traffic-lights-spacer {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.tl {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tl-close { background: #ff5f57; }
|
||||
.tl-minimize { background: #ffbd2e; }
|
||||
.tl-maximize { background: #28c840; }
|
||||
|
||||
.title-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Terminal container ── */
|
||||
#xterm-container {
|
||||
padding: 4px 8px 8px 8px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar in xterm */
|
||||
.xterm-viewport::-webkit-scrollbar { display: none; }
|
||||
.xterm-viewport { scrollbar-width: none; }
|
||||
|
||||
/* Ensure xterm canvas renders sharply */
|
||||
.xterm canvas { image-rendering: pixelated; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="capture-root">
|
||||
${chromeHTML}
|
||||
<div id="xterm-container"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Internal: utils ─────────────────────
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue