diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 43ad469..5eb4e1c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,9 +1,9 @@ --- name: Bug Report about: Report a bug with the opencode-pty plugin -title: "[Bug]: " -labels: ["bug"] -assignees: "" +title: '[Bug]: ' +labels: ['bug'] +assignees: '' --- ## Description diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6bfff5c..8d05723 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,9 @@ --- name: Feature Request about: Suggest a new feature or enhancement -title: "[Feature]: " -labels: ["enhancement"] -assignees: "" +title: '[Feature]: ' +labels: ['enhancement'] +assignees: '' --- ## Problem Statement diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dff2a0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + quality: [test, typecheck, lint, format] + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.6 + + - name: Cache Bun dependencies + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install + + - name: Build + if: matrix.quality == 'test' + run: bun run build:all:prod + + - name: Run test + if: matrix.quality == 'test' + run: bun test --concurrent websocket.test.ts web-server.test.ts types.test.ts spawn-repeat.test.ts pty-tools.test.ts pty-integration.test.ts pty-echo.test.ts npm-pack-structure.test.ts + + - name: Type check + if: matrix.quality == 'typecheck' + run: bun run typecheck + + - name: Lint + if: matrix.quality == 'lint' + run: bun run lint + + - name: Check formatting + if: matrix.quality == 'format' + run: bun run format:check + + dependency-review: + runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Dependency Review + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a9d985..8b61704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,11 @@ name: Release on: - push: - branches: - - main + workflow_run: + workflows: ['CI'] + types: + - completed + branches: [main] workflow_dispatch: permissions: @@ -12,6 +14,7 @@ permissions: jobs: publish: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout @@ -19,19 +22,19 @@ jobs: with: fetch-depth: 0 - - name: Use Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: 20 + bun-version: latest - name: Determine release state id: determine run: | set -euo pipefail - CURRENT_VERSION=$(node -p "require('./package.json').version") + CURRENT_VERSION=$(bun -e 'import pkg from "./package.json"; console.log(pkg.version)') echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" if git rev-parse HEAD^ >/dev/null 2>&1; then - PREVIOUS_VERSION=$(node -e "const { execSync } = require('node:child_process'); try { const data = execSync('git show HEAD^:package.json', { stdio: ['ignore', 'pipe', 'ignore'] }); const json = JSON.parse(data.toString()); if (json && typeof json.version === 'string') { process.stdout.write(json.version); } } catch (error) {}") + PREVIOUS_VERSION=$(bun -e "const { execSync } = require('node:child_process'); try { const data = execSync('git show HEAD^:package.json', { stdio: ['ignore', 'pipe', 'ignore'] }); const json = JSON.parse(data.toString()); if (json && typeof json.version === 'string') { process.stdout.write(json.version); } } catch (error) {}") PREVIOUS_VERSION=${PREVIOUS_VERSION//$'\n'/} else PREVIOUS_VERSION="" @@ -51,13 +54,7 @@ jobs: - name: Install dependencies if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' - run: | - npm install -g npm@latest - npm install - - - name: Type check - if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' - run: npx tsc --noEmit + run: bun install - name: Generate release notes if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' @@ -123,15 +120,13 @@ jobs: - name: Create GitHub release if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' - uses: actions/create-release@v1 + run: | + gh release create "v${{ steps.determine.outputs.current_version }}" \ + --title "v${{ steps.determine.outputs.current_version }}" \ + --notes "${{ steps.release_notes.outputs.body }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ steps.determine.outputs.current_version }} - release_name: v${{ steps.determine.outputs.current_version }} - body: ${{ steps.release_notes.outputs.body }} - generate_release_notes: false - name: Publish to npm if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' - run: npm publish --access public --provenance + run: bunx npm publish --access public --provenance diff --git a/.gitignore b/.gitignore index 901d699..f80e0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,17 @@ out dist *.tgz +# local development environment +.opencode/ + # code coverage coverage *.lcov +# test results and reports +playwright-report/ +test-results/ + # logs logs _.log diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fee06d7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ +bun.lock + +# Build outputs +dist/ +*.tgz + +# Test reports +playwright-report/ +test-results/ +coverage/ + +# Logs +*.log +logs/ + +# OS generated files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Git +.git/ + +# Lock files (Bun handles this) +bun.lock \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..03fd2de --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": false, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} diff --git a/AGENTS.md b/AGENTS.md index 009f327..0820d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,213 +1,392 @@ # AGENTS.md -This file contains essential information for agentic coding assistants working in this repository. +This document is the authoritative and up-to-date guide for both agentic coding assistants and developers working with this repository. It contains essential information, conventions, troubleshooting, workflow guidance, and up-to-date instructions reflecting the current codebase and recommended practices. + +**opencode-pty** is an OpenCode/Bun plugin enabling interactive management of PTY (pseudo-terminal) sessions from both APIs and a modern web UI. It supports concurrent shell sessions, interactive input/output, real-time streaming, regex output filtering, buffer management, status/exits, permission-aware process handling, and agent/plugin extensibility. + +## Quickstart + +### For Users (Install / Upgrade) + +- Add `opencode-pty` to your OpenCode config in the `plugin` array: + ```json + { + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-pty"] + } + ``` +- To force an upgrade or clear a corrupted cache: + ```sh + rm -rf ~/.cache/opencode/node_modules/opencode-pty # then rerun opencode + ``` +- OpenCode will install/update plugins on the next run; plugins are not auto-updated. + +### For Plugin/Agent Developers (Local Plugins) + +- Place TypeScript or JavaScript plugins in `.opencode/plugins/` in your project root. +- For dependencies, include a minimal `.opencode/package.json` (see appendix below). +- No extra config is required for plugins in this directory — just restart OpenCode to reload any changes. +- **If you add dependencies:** Run `bun install` in `.opencode/`. Restart OpenCode to reload new modules. +- For multi-file or build-step plugins, output built files to `.opencode/plugins/`. + +### Running the Web UI (PTY sessions) + +- Start the PTY Web UI in dev mode: + ```sh + bun run e2e/test-web-server.ts + ``` +- Open http://localhost:8766 in your browser (shows session management, streaming, and toolkit features). + +--- + +## Project Structure (Directory/Files) + +- `src/plugin/pty/` — Core PTY logic, types, manager, buffer, permissions, and tools +- `src/web/` — React-based web UI, server, live session and terminal interaction +- `test/` — Unit and agent-facing tests +- `e2e/` — End-to-end (Playwright) tests, for web UI/session validation +- Use camelCase for function/variable names; PascalCase for types/classes; UPPER_CASE for constants; kebab-case for directories. + +--- + +## Core Commands & Scripts + +- **Development/Run/Build:** + - `bun run dev` — Start Vite-based development server (frontend only) + - `bun run dev:server` — Start PTY Web UI/API in dev mode (test Web server) + - `bun run build` — Clean, typecheck, and build all assets + - `bun run build:dev` — Build assets in development mode + - `bun run build:prod` — Build assets in production mode + - `bun run build:plugin` — Build plugin for OpenCode consumption + - `bun run install:plugin:dev` — Build + install plugin to local .opencode + - `bun run install:web:dev` — Build web client in dev mode + - `bun run install:all:dev` — Build/install plugin & web client + - `bun run run:all:dev` — Full build/install workflow then run OpenCode (silent) + - `bun run preview` — Preview built UI site +- **Lint/Format/Quality:** + - `bun run lint` — Run ESLint on all source (strict) + - `bun run lint:fix` — ESLint auto-fix + - `bun run format` — Prettier formatting (writes changes) + - `bun run format:check` — Prettier check only + - `bun run quality` — Lint, format check, and typecheck (all code-quality checks) +- **Test & Typecheck:** + - `bun run typecheck` — Typescript strict check (no emit) + - `bun run typecheck:watch` — Typecheck in watch mode + - `bun run test` — Unit tests (Bun test runner, all but e2e/web) + - `bun run test:watch` — Unit tests in watch mode + - `bun run test:e2e` — Playwright end-to-end tests; ensure dev server built, use `PW_DISABLE_TS_ESM=1` for Bun + - `bun run test:all` — All unit + E2E tests + - Run single/filtered unit test: `bun test --match ""` +- **Other:** + - `bun run clean` — Remove build artifacts, test results, etc. + - `bun run ci` — Run quality checks and all tests (used by CI pipeline) + +**Note:** Many scripts have special requirements or additional ENV flags; see inline package.json script comments for platform- or environment-specific details (e.g. Playwright+Bun TS support requires `PW_DISABLE_TS_ESM=1`). + +--- + +## Code Style & Conventions -## Project Overview +### Naming Conventions -**opencode-pty** is an OpenCode plugin that provides interactive PTY (pseudo-terminal) management. It enables AI agents to run background processes, send interactive input, and read output on demand. The plugin supports multiple concurrent PTY sessions with features like output buffering, regex filtering, and permission integration. +- **Functions/Variables**: camelCase (`setOnSessionUpdate`, `rawOutputCallbacks`) +- **Types/Classes**: PascalCase (`PTYManager`, `SessionLifecycleManager`) +- **Constants**: UPPER_CASE (`DEFAULT_READ_LIMIT`, `MAX_LINE_LENGTH`) +- **Directories**: kebab-case (`src/web/client`, `e2e`) +- **Files**: kebab-case for directories, camelCase for components/hooks (`useWebSocket.ts`, `TerminalRenderer.tsx`) -## Build/Lint/Test Commands +### TypeScript Configuration -### Type Checking -```bash -bun run typecheck -``` -Runs TypeScript compiler in no-emit mode to check for type errors. +- Strict TypeScript settings enabled (`strict: true`) +- Module resolution: `"bundler"` with `"moduleResolution": "bundler"` +- Target: ESNext with modern features +- No implicit returns or unused variables/parameters allowed +- Explicit type annotations required where beneficial -### Testing -```bash -bun test -``` -Runs all tests using Bun's test runner. +### Formatting (Prettier) -### Running a Single Test -```bash -bun test --match "test name pattern" -``` -Use the `--match` flag with a regex pattern to run specific tests. For example: -```bash -bun test --match "spawn" -``` +- **Semicolons**: Disabled (`semi: false`) +- **Quotes**: Single quotes preferred (`singleQuote: true`) +- **Trailing commas**: ES5 style (`trailingComma: "es5"`) +- **Print width**: 100 characters (`printWidth: 100`) +- **Indentation**: 2 spaces, no tabs (`tabWidth: 2`, `useTabs: false`) -### Linting -No dedicated linter configured. TypeScript strict mode serves as the primary code quality gate. +### Import/Export Style -## Code Style Guidelines +- Use ES6 imports/exports +- Group imports: external libraries first, then internal modules +- Prefer named exports over default exports for better tree-shaking +- Use absolute imports for internal modules where possible -### Language and Environment -- **Language**: TypeScript 5.x with ESNext target -- **Runtime**: Bun (supports TypeScript directly) -- **Module System**: ES modules with explicit `.ts` extensions in imports -- **JSX**: React JSX syntax (if needed, though this project is primarily backend) +### Documentation -### TypeScript Configuration -- Strict mode enabled (`strict: true`) -- Additional strict flags: `noFallthroughCasesInSwitch`, `noUncheckedIndexedAccess`, `noImplicitOverride` -- Module resolution: bundler mode -- Verbatim module syntax (no semicolons required) +- Use JSDoc comments for public APIs +- Inline comments for complex logic +- No redundant comments on self-explanatory code -### Imports and Dependencies -- Use relative imports with `.ts` extensions: `import { foo } from "../foo.ts"` -- Import types explicitly: `import type { Foo } from "./types.ts"` -- Group imports: external dependencies first, then internal -- Avoid wildcard imports (`import * as foo`) +--- -### Naming Conventions -- **Variables/Functions**: camelCase (`processData`, `spawnSession`) -- **Constants**: UPPER_CASE (`DEFAULT_LIMIT`, `MAX_LINE_LENGTH`) -- **Types/Interfaces**: PascalCase (`PTYSession`, `SpawnOptions`) -- **Classes**: PascalCase (`PTYManager`, `RingBuffer`) -- **Enums**: PascalCase (`PTYStatus`) -- **Files**: kebab-case for directories, camelCase for files (`spawn.ts`, `manager.ts`) - -### Code Structure -- **Functions**: Prefer arrow functions for tools, regular functions for utilities -- **Async/Await**: Use throughout for all async operations -- **Error Handling**: Throw descriptive Error objects, use try/catch for expected failures -- **Logging**: Use `createLogger` from `../logger.ts` for consistent logging -- **Tool Functions**: Use `tool()` wrapper with schema validation for all exported tools - -### Schema Validation -All tool functions must use schema validation: -```typescript -export const myTool = tool({ - description: "Brief description", - args: { - param: tool.schema.string().describe("Parameter description"), - optionalParam: tool.schema.boolean().optional().describe("Optional param"), - }, - async execute(args, ctx) { - // Implementation - }, -}); -``` +## Error Handling & Logging -### Error Messages -- Be descriptive and actionable -- Include context like session IDs or parameter values -- Suggest alternatives when possible (e.g., "Use pty_list to see active sessions") +### Error Handling Patterns -### File Organization -``` -src/ -├── plugin.ts # Main plugin entry point -├── types.ts # Plugin-level types -├── logger.ts # Logging utilities -└── plugin/ # Plugin-specific code - ├── pty/ # PTY-specific code - │ ├── types.ts # PTY types and interfaces - │ ├── manager.ts # PTY session management - │ ├── buffer.ts # Output buffering (RingBuffer) - │ ├── permissions.ts # Permission checking - │ ├── wildcard.ts # Wildcard matching utilities - │ └── tools/ # Tool implementations - │ ├── spawn.ts # pty_spawn tool - │ ├── write.ts # pty_write tool - │ ├── read.ts # pty_read tool - │ ├── list.ts # pty_list tool - │ ├── kill.ts # pty_kill tool - │ └── *.txt # Tool descriptions - └── types.ts # Plugin types -``` +- Use try/catch blocks for operations that may fail +- Throw descriptive `Error` objects with clear messages +- Handle errors gracefully in async operations +- Validate inputs and provide meaningful error messages +- Use custom error builders for common error types (e.g., `buildSessionNotFoundError`) -### Constants and Magic Numbers -- Define constants at the top of files: `const DEFAULT_LIMIT = 500;` -- Use meaningful names instead of magic numbers -- Group related constants together - -### Buffer Management -- Use RingBuffer for output storage (max 50,000 lines by default via `PTY_MAX_BUFFER_LINES`) -- Handle line truncation at 2000 characters -- Implement pagination with offset/limit for large outputs - -### Session Management -- Generate unique IDs using crypto: `pty_${hex}` -- Track session lifecycle: running → exited/killed -- Support cleanup on session deletion events -- Include parent session ID for proper isolation - -### Permission Integration -- Always check command permissions before spawning -- Validate working directory permissions -- Use wildcard matching for flexible permission rules - -### Testing -- Write tests for all public APIs -- Test error conditions and edge cases -- Use Bun's test framework -- Mock external dependencies when necessary +### Logging Approach -### Documentation -- Include `.txt` description files for each tool in `tools/` directory -- Use JSDoc sparingly, prefer `describe()` in schemas -- Keep README.md updated with usage examples - -### Security Considerations -- Never log sensitive information (passwords, tokens) -- Validate all user inputs, especially regex patterns -- Respect permission boundaries set by OpenCode -- Use secure random generation for session IDs - -### Performance -- Use efficient data structures (RingBuffer, Map for sessions) -- Avoid blocking operations in main thread -- Implement pagination for large outputs -- Clean up resources promptly - -### Commit Messages -Follow conventional commit format: -- `feat:` for new features -- `fix:` for bug fixes -- `refactor:` for code restructuring -- `test:` for test additions -- `docs:` for documentation changes - -### Git Workflow -- Use feature branches for development -- Run typecheck and tests before committing -- Use GitHub Actions for automated releases on main branch -- Follow semantic versioning with `v` prefixed tags - -### Dependencies -- **@opencode-ai/plugin**: ^1.1.3 (Core plugin framework) -- **@opencode-ai/sdk**: ^1.1.3 (SDK for client interactions) -- **bun-pty**: ^0.4.2 (PTY implementation) -- **@types/bun**: 1.3.1 (TypeScript definitions for Bun) -- **typescript**: ^5 (peer dependency) - -### Development Setup -- Install Bun: `curl -fsSL https://bun.sh/install | bash` -- Install dependencies: `bun install` -- Run development commands: `bun run + + diff --git a/src/web/client/main.tsx b/src/web/client/main.tsx new file mode 100644 index 0000000..df7a444 --- /dev/null +++ b/src/web/client/main.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './components/App.tsx' +import { ErrorBoundary } from './components/ErrorBoundary.tsx' +import { trackWebVitals, PerformanceMonitor } from './performance.ts' + +// Initialize performance monitoring +trackWebVitals() +PerformanceMonitor.startMark('app-init') + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/src/web/client/performance.ts b/src/web/client/performance.ts new file mode 100644 index 0000000..2ff57e4 --- /dev/null +++ b/src/web/client/performance.ts @@ -0,0 +1,91 @@ +// Performance monitoring utilities + +const PERFORMANCE_MEASURE_LIMIT = 100 + +export class PerformanceMonitor { + private static marks: Map = new Map() + private static measures: Array<{ name: string; duration: number; timestamp: number }> = [] + private static readonly MAX_MEASURES = PERFORMANCE_MEASURE_LIMIT + + static startMark(name: string): void { + this.marks.set(name, performance.now()) + } + + static endMark(name: string): number | null { + const startTime = this.marks.get(name) + if (!startTime) return null + + const duration = performance.now() - startTime + this.measures.push({ + name, + duration, + timestamp: Date.now(), + }) + + // Keep only last N measures + if (this.measures.length > this.MAX_MEASURES) { + this.measures = this.measures.slice(-this.MAX_MEASURES) + } + + this.marks.delete(name) + return duration + } + + static getMetrics(): { + measures: Array<{ name: string; duration: number; timestamp: number }> + memory?: { used: number; total: number; limit: number } + } { + const metrics: { + measures: Array<{ name: string; duration: number; timestamp: number }> + memory?: { used: number; total: number; limit: number } + } = { measures: this.measures } + + // Add memory info if available + if ('memory' in performance) { + const mem = (performance as any).memory + metrics.memory = { + used: mem.usedJSHeapSize, + total: mem.totalJSHeapSize, + limit: mem.jsHeapSizeLimit, + } + } + + return metrics + } + + static clearMetrics(): void { + this.marks.clear() + this.measures.length = 0 + } +} + +// Web Vitals tracking +export function trackWebVitals(): void { + // Track Largest Contentful Paint (LCP) + if ('PerformanceObserver' in window) { + try { + const lcpObserver = new PerformanceObserver((_list) => {}) + lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] }) + + // Track First Input Delay (FID) + const fidObserver = new PerformanceObserver((list) => { + const entries = list.getEntries() + entries.forEach((_entry: any) => {}) + }) + fidObserver.observe({ entryTypes: ['first-input'] }) + + // Track Cumulative Layout Shift (CLS) + let clsValue = 0 + const clsObserver = new PerformanceObserver((list) => { + const entries = list.getEntries() + entries.forEach((entry: any) => { + if (!entry.hadRecentInput) { + clsValue += entry.value + } + }) + }) + clsObserver.observe({ entryTypes: ['layout-shift'] }) + // eslint-disable-next-line no-empty + } catch {} + } +} diff --git a/src/web/server/CallbackManager.ts b/src/web/server/CallbackManager.ts new file mode 100644 index 0000000..eae2a17 --- /dev/null +++ b/src/web/server/CallbackManager.ts @@ -0,0 +1,31 @@ +import { + registerRawOutputCallback, + registerSessionUpdateCallback, + removeRawOutputCallback, + removeSessionUpdateCallback, +} from '../../plugin/pty/manager' +import type { PTYSessionInfo } from '../../plugin/pty/types' +import type { WSMessageServerSessionUpdate, WSMessageServerRawData } from '../shared/types' + +export class CallbackManager implements Disposable { + constructor(private server: Bun.Server) { + this.server = server + registerSessionUpdateCallback(this.sessionUpdateCallback) + registerRawOutputCallback(this.rawOutputCallback) + } + + private sessionUpdateCallback = (session: PTYSessionInfo): void => { + const message: WSMessageServerSessionUpdate = { type: 'session_update', session } + this.server.publish('sessions:update', JSON.stringify(message)) + } + + private rawOutputCallback = (session: PTYSessionInfo, rawData: string): void => { + const message: WSMessageServerRawData = { type: 'raw_data', session, rawData } + this.server.publish(`session:${session.id}`, JSON.stringify(message)) + }; + + [Symbol.dispose]() { + removeSessionUpdateCallback(this.sessionUpdateCallback) + removeRawOutputCallback(this.rawOutputCallback) + } +} diff --git a/src/web/server/handlers/health.ts b/src/web/server/handlers/health.ts new file mode 100644 index 0000000..5951eb5 --- /dev/null +++ b/src/web/server/handlers/health.ts @@ -0,0 +1,38 @@ +import moment from 'moment' +import { manager } from '../../../plugin/pty/manager.ts' +import { JsonResponse } from './responses.ts' + +export function handleHealth(server: Bun.Server) { + const sessions = manager.list() + const activeSessions = sessions.filter((s) => s.status === 'running').length + const totalSessions = sessions.length + + // Calculate response time (rough approximation) + const startTime = Date.now() + + const healthResponse = { + status: 'healthy', + timestamp: moment().toISOString(true), + uptime: process.uptime(), + sessions: { + total: totalSessions, + active: activeSessions, + }, + websocket: { + connections: server.pendingWebSockets, + }, + memory: process.memoryUsage + ? { + rss: process.memoryUsage().rss, + heapUsed: process.memoryUsage().heapUsed, + heapTotal: process.memoryUsage().heapTotal, + } + : undefined, + } + + // Add response time + const responseTime = Date.now() - startTime + ;(healthResponse as any).responseTime = responseTime + + return new JsonResponse(healthResponse) +} diff --git a/src/web/server/handlers/responses.ts b/src/web/server/handlers/responses.ts new file mode 100644 index 0000000..8e95a52 --- /dev/null +++ b/src/web/server/handlers/responses.ts @@ -0,0 +1,27 @@ +/** + * Response helper classes for consistent JSON responses + */ + +export class JsonResponse extends Response { + constructor(data: any, status = 200, headers: Record = {}) { + super(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }) + } +} + +export class ErrorResponse extends Response { + constructor(message: string, status = 500, headers: Record = {}) { + super(JSON.stringify({ error: message }), { + status, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }) + } +} diff --git a/src/web/server/handlers/sessions.ts b/src/web/server/handlers/sessions.ts new file mode 100644 index 0000000..04282fb --- /dev/null +++ b/src/web/server/handlers/sessions.ts @@ -0,0 +1,108 @@ +import { manager } from '../../../plugin/pty/manager.ts' +import type { BunRequest } from 'bun' +import { JsonResponse, ErrorResponse } from './responses.ts' +import { + apiSessionCleanupPath, + apiSessionInputPath, + apiSessionPath, + apiSessionPlainBufferPath, + apiSessionRawBufferPath, +} from '../server.ts' + +export function getSessions() { + const sessions = manager.list() + return new JsonResponse(sessions) +} + +export async function createSession(req: Request) { + try { + const body = (await req.json()) as { + command: string + args?: string[] + description?: string + workdir?: string + } + if (!body.command || typeof body.command !== 'string' || body.command.trim() === '') { + return new ErrorResponse('Command is required', 400) + } + const session = manager.spawn({ + command: body.command, + args: body.args || [], + title: body.description, + description: body.description, + workdir: body.workdir, + parentSessionId: 'web-api', + }) + return new JsonResponse(session) + } catch (err) { + return new ErrorResponse('Invalid JSON in request body', 400) + } +} + +export function clearSessions() { + manager.clearAllSessions() + return new JsonResponse({ success: true }) +} + +export function getSession(req: BunRequest) { + const session = manager.get(req.params.id) + if (!session) { + return new ErrorResponse('Session not found', 404) + } + return new JsonResponse(session) +} + +export async function sendInput(req: BunRequest): Promise { + try { + const body = (await req.json()) as { data: string } + if (!body.data || typeof body.data !== 'string') { + return new ErrorResponse('Data field is required and must be a string', 400) + } + const success = manager.write(req.params.id, body.data) + if (!success) { + return new ErrorResponse('Failed to write to session', 400) + } + return new JsonResponse({ success: true }) + } catch (err) { + return new ErrorResponse('Invalid JSON in request body', 400) + } +} + +export function cleanupSession(req: BunRequest) { + console.log('Cleaning up session', req.params.id) + const success = manager.kill(req.params.id, true) + if (!success) { + return new ErrorResponse('Failed to kill session', 400) + } + return new JsonResponse({ success: true }) +} + +export function killSession(req: BunRequest) { + const success = manager.kill(req.params.id) + if (!success) { + return new ErrorResponse('Failed to kill session', 400) + } + return new JsonResponse({ success: true }) +} + +export function getRawBuffer(req: BunRequest) { + const bufferData = manager.getRawBuffer(req.params.id) + if (!bufferData) { + return new ErrorResponse('Session not found', 404) + } + + return new JsonResponse(bufferData) +} + +export function getPlainBuffer(req: BunRequest) { + const bufferData = manager.getRawBuffer(req.params.id) + if (!bufferData) { + return new ErrorResponse('Session not found', 404) + } + + const plainText = Bun.stripANSI(bufferData.raw) + return new JsonResponse({ + plain: plainText, + byteLength: new TextEncoder().encode(plainText).length, + }) +} diff --git a/src/web/server/handlers/static.ts b/src/web/server/handlers/static.ts new file mode 100644 index 0000000..c62d018 --- /dev/null +++ b/src/web/server/handlers/static.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path' +import { readdirSync, statSync } from 'node:fs' +import { join, extname } from 'node:path' +import { ASSET_CONTENT_TYPES } from '../../shared/constants.ts' + +// ----- MODULE-SCOPE CONSTANTS ----- +const PROJECT_ROOT = resolve(import.meta.dir, '../../../..') +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Content-Security-Policy': + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';", +} as const +const STATIC_DIR = join(PROJECT_ROOT, 'dist/web') + +export async function buildStaticRoutes(): Promise> { + const routes: Record = {} + const files = readdirSync(STATIC_DIR, { recursive: true }) + for (const file of files) { + if (typeof file === 'string' && !statSync(join(STATIC_DIR, file)).isDirectory()) { + const ext = extname(file) + const routeKey = `/${file.replace(/\\/g, '/')}` // e.g., /assets/js/bundle.js + const fullPath = join(STATIC_DIR, file) + const fileObj = Bun.file(fullPath) + const contentType = fileObj.type || ASSET_CONTENT_TYPES[ext] || 'application/octet-stream' + + // Buffer all files in memory + routes[routeKey] = new Response(await fileObj.bytes(), { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + ...SECURITY_HEADERS, + }, + }) + } + } + return routes +} diff --git a/src/web/server/handlers/upgrade.ts b/src/web/server/handlers/upgrade.ts new file mode 100644 index 0000000..77ec180 --- /dev/null +++ b/src/web/server/handlers/upgrade.ts @@ -0,0 +1,10 @@ +export function handleUpgrade(server: Bun.Server, req: Request) { + if (!(req.headers.get('upgrade') === 'websocket')) { + return new Response('WebSocket endpoint - use WebSocket upgrade', { status: 426 }) + } + const success = server.upgrade(req) + if (success) { + return undefined // Upgrade succeeded, Bun sends 101 automatically + } + return new Response('WebSocket upgrade failed', { status: 400 }) +} diff --git a/src/web/server/handlers/websocket.ts b/src/web/server/handlers/websocket.ts new file mode 100644 index 0000000..aef8ab5 --- /dev/null +++ b/src/web/server/handlers/websocket.ts @@ -0,0 +1,163 @@ +import type { ServerWebSocket } from 'bun' +import { manager } from '../../../plugin/pty/manager' +import { + type WSMessageServerSessionList, + type WSMessageClientSubscribeSession, + type WSMessageServerError, + type WSMessageClientUnsubscribeSession, + type WSMessageClientSessionList, + type WSMessageClient, + type WSMessageClientSpawnSession, + type WSMessageClientInput, + type WSMessageClientReadRaw, + type WSMessageServerReadRawResponse, + type WSMessageServerSubscribedSession, + CustomError, + type WSMessageServerUnsubscribedSession, +} from '../../shared/types' + +class WebSocketHandler { + private sendSessionList(ws: ServerWebSocket): void { + const sessions = manager.list() + const message: WSMessageServerSessionList = { type: 'session_list', sessions } + ws.send(JSON.stringify(message)) + } + + private handleSubscribe( + ws: ServerWebSocket, + message: WSMessageClientSubscribeSession + ): void { + const session = manager.get(message.sessionId) + if (!session) { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError(`Session ${message.sessionId} not found`), + } + ws.send(JSON.stringify(error)) + } else { + ws.subscribe(`session:${message.sessionId}`) + const response: WSMessageServerSubscribedSession = { + type: 'subscribed', + sessionId: message.sessionId, + } + ws.send(JSON.stringify(response)) + } + } + + private handleUnsubscribe( + ws: ServerWebSocket, + message: WSMessageClientUnsubscribeSession + ): void { + const topic = `session:${message.sessionId}` + ws.unsubscribe(topic) + const response: WSMessageServerUnsubscribedSession = { + type: 'unsubscribed', + sessionId: message.sessionId, + } + ws.send(JSON.stringify(response)) + } + + private handleSessionListRequest( + ws: ServerWebSocket, + _message: WSMessageClientSessionList + ): void { + this.sendSessionList(ws) + } + + private handleUnknownMessage(ws: ServerWebSocket, message: WSMessageClient): void { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError(`Unknown message type ${message.type}`), + } + ws.send(JSON.stringify(error)) + } + + public handleWebSocketMessage( + ws: ServerWebSocket, + data: string | Buffer + ): void { + if (typeof data !== 'string') { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError('Binary messages are not supported yet. File an issue.'), + } + ws.send(JSON.stringify(error)) + return + } + try { + const message: WSMessageClient = JSON.parse(data) + + switch (message.type) { + case 'subscribe': + this.handleSubscribe(ws, message as WSMessageClientSubscribeSession) + break + + case 'unsubscribe': + this.handleUnsubscribe(ws, message as WSMessageClientUnsubscribeSession) + break + + case 'session_list': + this.handleSessionListRequest(ws, message as WSMessageClientSessionList) + break + + case 'spawn': + this.handleSpawn(ws, message as WSMessageClientSpawnSession) + break + + case 'input': + this.handleInput(message as WSMessageClientInput) + break + + case 'readRaw': + this.handleReadRaw(ws, message as WSMessageClientReadRaw) + break + + default: + this.handleUnknownMessage(ws, message) + } + } catch (err) { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError(Bun.inspect(err)), + } + ws.send(JSON.stringify(error)) + } + } + + private handleSpawn(ws: ServerWebSocket, message: WSMessageClientSpawnSession) { + const sessionInfo = manager.spawn(message) + if (message.subscribe) { + this.handleSubscribe(ws, { type: 'subscribe', sessionId: sessionInfo.id }) + } + } + + private handleInput(message: WSMessageClientInput) { + manager.write(message.sessionId, message.data) + } + + private handleReadRaw(ws: ServerWebSocket, message: WSMessageClientReadRaw) { + const rawData = manager.getRawBuffer(message.sessionId) + if (!rawData) { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError(`Session ${message.sessionId} not found`), + } + ws.send(JSON.stringify(error)) + return + } + const response: WSMessageServerReadRawResponse = { + type: 'readRawResponse', + sessionId: message.sessionId, + rawData: rawData.raw, + } + ws.send(JSON.stringify(response)) + } +} + +export function handleWebSocketMessage( + ws: ServerWebSocket, + data: string | Buffer +): void { + const handler = new WebSocketHandler() + handler.handleWebSocketMessage(ws, data) +} diff --git a/src/web/server/server.ts b/src/web/server/server.ts new file mode 100644 index 0000000..d5302f3 --- /dev/null +++ b/src/web/server/server.ts @@ -0,0 +1,118 @@ +import type { Server } from 'bun' +import { handleHealth } from './handlers/health.ts' +import { + getSessions, + createSession, + clearSessions, + getSession, + sendInput, + killSession, + getRawBuffer, + getPlainBuffer, + cleanupSession, +} from './handlers/sessions.ts' + +import { buildStaticRoutes } from './handlers/static.ts' +import { handleUpgrade } from './handlers/upgrade.ts' +import { handleWebSocketMessage } from './handlers/websocket.ts' +import { CallbackManager } from './CallbackManager.ts' +import { + initManager, + manager, + rawOutputCallbacks, + sessionUpdateCallbacks, +} from '../../plugin/pty/manager.ts' + +export const wsPath = '/ws' +export const healthPath = '/health' +export const apiBasePath = '/api/sessions' +export const apiSessionPath = '/api/sessions/:id' +export const apiSessionCleanupPath = '/api/sessions/:id/cleanup' +export const apiSessionInputPath = '/api/sessions/:id/input' +export const apiSessionRawBufferPath = '/api/sessions/:id/buffer/raw' +export const apiSessionPlainBufferPath = '/api/sessions/:id/buffer/plain' + +export class PTYServer implements Disposable { + public readonly server: Server + private readonly staticRoutes: Record + private readonly stack = new DisposableStack() + + private constructor(staticRoutes: Record) { + this.staticRoutes = staticRoutes + this.server = this.startWebServer() + this.stack.use(this.server) + this.stack.use(new CallbackManager(this.server)) + } + + [Symbol.dispose]() { + this.stack.dispose() + manager.clearAllSessions() + sessionUpdateCallbacks.length = 0 + rawOutputCallbacks.length = 0 + } + + public static async createServer(): Promise { + const fakeClient = { + app: { + log: async (_opts: any) => { + // Mock logger - do nothing + }, + }, + } as any + initManager(fakeClient) + + const staticRoutes = await buildStaticRoutes() + + return new PTYServer(staticRoutes) + } + + private startWebServer(): Server { + return Bun.serve({ + port: 0, + + routes: { + ...this.staticRoutes, + [wsPath]: (req: Request) => handleUpgrade(this.server, req), + [healthPath]: () => handleHealth(this.server), + [apiBasePath]: { + GET: getSessions, + POST: createSession, + DELETE: clearSessions, + }, + [apiSessionPath]: { + GET: getSession, + DELETE: killSession, + }, + [apiSessionCleanupPath]: { + DELETE: cleanupSession, + }, + [apiSessionInputPath]: { + POST: sendInput, + }, + [apiSessionRawBufferPath]: { + GET: getRawBuffer, + }, + [apiSessionPlainBufferPath]: { + GET: getPlainBuffer, + }, + }, + + websocket: { + perMessageDeflate: true, + open: (ws) => ws.subscribe('sessions:update'), + message: handleWebSocketMessage, + close: (ws) => { + ws.subscriptions.forEach((topic) => { + ws.unsubscribe(topic) + }) + }, + }, + + fetch: () => new Response(null, { status: 302, headers: { Location: '/index.html' } }), + }) + } + + public getWsUrl(): string { + return `${this.server.url.origin.replace(/^http/, 'ws')}${wsPath}` + } +} diff --git a/src/web/shared/constants.ts b/src/web/shared/constants.ts new file mode 100644 index 0000000..0b3c6d6 --- /dev/null +++ b/src/web/shared/constants.ts @@ -0,0 +1,25 @@ +// Web-specific constants for the web server and related components + +// WebSocket and session related constants +export const WEBSOCKET_PING_INTERVAL = 30000 +export const WEBSOCKET_RECONNECT_DELAY = 100 +export const RETRY_DELAY = 500 +export const SESSION_LOAD_TIMEOUT = 2000 +export const OUTPUT_LOAD_TIMEOUT = 5000 +export const SKIP_AUTOSELECT_KEY = 'skip-autoselect' + +// Test-related constants +export const TEST_SERVER_PORT_BASE = 8765 +export const TEST_TIMEOUT_BUFFER = 1000 +export const TEST_SESSION_CLEANUP_DELAY = 500 + +// Asset and file serving constants +export const ASSET_CONTENT_TYPES: Record = { + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.html': 'text/html', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', +} diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts new file mode 100644 index 0000000..16a5ebb --- /dev/null +++ b/src/web/shared/types.ts @@ -0,0 +1,124 @@ +import type { PTYSessionInfo, SpawnOptions } from '../../plugin/pty/types' + +export class CustomError extends Error { + constructor(message: string) { + super(message) + } + + override name = 'CustomError' + prettyPrintColor: string = Bun.inspect(this, { colors: true, depth: 10 }) + prettyPrintNoColor: string = Bun.stripANSI(this.prettyPrintColor) + + toJSON() { + const obj: Record = {} + // Include all own properties, including non-enumerable ones like 'message' and 'stack' + // prettyPrintColor and prettyPrintNoColor are now included automatically as strings + Object.getOwnPropertyNames(this).forEach((key) => { + obj[key] = (this as any)[key] + }) + return obj + } +} + +export interface WSMessageClient { + type: 'subscribe' | 'unsubscribe' | 'session_list' | 'spawn' | 'input' | 'readRaw' +} + +export interface WSMessageClientSubscribeSession extends WSMessageClient { + type: 'subscribe' + sessionId: string +} + +export interface WSMessageClientUnsubscribeSession extends WSMessageClient { + type: 'unsubscribe' + sessionId: string +} + +export interface WSMessageClientSessionList extends WSMessageClient { + type: 'session_list' +} + +export interface WSMessageClientSpawnSession extends WSMessageClient, SpawnOptions { + type: 'spawn' + subscribe?: boolean +} + +export interface WSMessageClientInput extends WSMessageClient { + type: 'input' + sessionId: string + data: string +} + +export interface WSMessageClientReadRaw extends WSMessageClient { + type: 'readRaw' + sessionId: string +} + +export interface WSMessageServer { + type: + | 'subscribed' + | 'unsubscribed' + | 'raw_data' + | 'readRawResponse' + | 'session_list' + | 'session_update' + | 'error' +} + +export interface WSMessageServerSubscribedSession extends WSMessageServer { + type: 'subscribed' + sessionId: string +} + +export interface WSMessageServerUnsubscribedSession extends WSMessageServer { + type: 'unsubscribed' + sessionId: string +} + +export interface WSMessageServerRawData extends WSMessageServer { + type: 'raw_data' + session: PTYSessionInfo + rawData: string +} + +export interface WSMessageServerReadRawResponse extends WSMessageServer { + type: 'readRawResponse' + sessionId: string + rawData: string +} + +export interface WSMessageServerSessionList extends WSMessageServer { + type: 'session_list' + sessions: PTYSessionInfo[] +} + +export interface WSMessageServerSessionUpdate extends WSMessageServer { + type: 'session_update' + session: PTYSessionInfo +} + +export interface WSMessageServerError extends WSMessageServer { + type: 'error' + error: CustomError +} + +// React component types +export interface Session { + id: string + title: string + description?: string + command: string + status: 'running' | 'exited' | 'killed' + exitCode?: number + pid: number + lineCount: number + createdAt: string +} + +export interface AppState { + sessions: Session[] + activeSession: Session | null + output: string[] + connected: boolean + inputValue: string +} diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..2cfa393 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { manager } from '../src/plugin/pty/manager.ts' +import { ManagedTestServer } from './utils.ts' +import { PTYServer } from '../src/web/server/server.ts' + +describe('Web Server Integration', () => { + let managedTestServer: ManagedTestServer + let disposableStack: DisposableStack + beforeAll(async () => { + disposableStack = new DisposableStack() + managedTestServer = await ManagedTestServer.create() + disposableStack.use(managedTestServer) + }) + + afterAll(() => { + disposableStack.dispose() + }) + + describe('Full User Workflow', () => { + it('should handle multiple concurrent sessions and clients', async () => { + // Create multiple sessions + const session1 = manager.spawn({ + command: 'echo', + args: ['Session 1'], + description: 'Multi-session test 1', + parentSessionId: 'multi-test', + }) + + // Wait for PTY to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + const session2 = manager.spawn({ + command: 'echo', + args: ['Session 2'], + description: 'Multi-session test 2', + parentSessionId: 'multi-test', + }) + + // Wait for PTY to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Create multiple WebSocket clients + const ws1 = new WebSocket('ws://localhost:8781/ws') + const ws2 = new WebSocket('ws://localhost:8781/ws') + const messages1: any[] = [] + const messages2: any[] = [] + + ws1.onmessage = (event) => messages1.push(JSON.parse(event.data)) + ws2.onmessage = (event) => messages2.push(JSON.parse(event.data)) + + await Promise.all([ + new Promise((resolve) => { + ws1.onopen = resolve + }), + new Promise((resolve) => { + ws2.onopen = resolve + }), + ]) + + // Subscribe clients to different sessions + ws1.send(JSON.stringify({ type: 'subscribe', sessionId: session1.id })) + ws2.send(JSON.stringify({ type: 'subscribe', sessionId: session2.id })) + + // Wait for sessions to complete + await new Promise((resolve) => setTimeout(resolve, 300)) + + // Check that API returns both sessions + const response = await fetch('http://localhost:8781/api/sessions') + const sessions = await response.json() + expect(sessions.length).toBe(2) + + const sessionIds = sessions.map((s: any) => s.id) + expect(sessionIds).toContain(session1.id) + expect(sessionIds).toContain(session2.id) + + // Cleanup + ws1.close() + ws2.close() + }) + + it('should handle error conditions gracefully', async () => { + // Test non-existent session + let response = await fetch('http://localhost:8782/api/sessions/nonexistent') + expect(response.status).toBe(404) + + // Test invalid input to existing session + const session = manager.spawn({ + command: 'echo', + args: ['test'], + description: 'Error test session', + parentSessionId: 'error-test', + }) + + // Wait for PTY to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + response = await fetch(`http://localhost:8782/api/sessions/${session.id}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'test input\n' }), + }) + + // Should handle gracefully even for exited sessions + const result = await response.json() + expect(result).toHaveProperty('success') + + // Test WebSocket error handling + const ws = new WebSocket('ws://localhost:8782/ws') + const wsMessages: any[] = [] + + ws.onmessage = (event) => wsMessages.push(JSON.parse(event.data)) + + await new Promise((resolve) => { + ws.onopen = () => { + // Send invalid message + ws.send('invalid json') + setTimeout(resolve, 100) + } + }) + + const errorMessages = wsMessages.filter((msg) => msg.type === 'error') + expect(errorMessages.length).toBeGreaterThan(0) + + ws.close() + }) + }) + + describe('Performance and Reliability', () => { + it('should handle rapid API requests', async () => { + // Create a session + const session = manager.spawn({ + command: 'echo', + args: ['performance test'], + description: 'Performance test', + parentSessionId: 'perf-test', + }) + + // Wait for PTY to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Make multiple concurrent requests + const promises = [] + for (let i = 0; i < 10; i++) { + promises.push(fetch(`http://localhost:8783/api/sessions/${session.id}`)) + } + + const responses = await Promise.all(promises) + responses.forEach((response) => { + expect(response.status).toBe(200) + }) + }) + + it('should cleanup properly on server stop', async () => { + const ptyServer = await PTYServer.createServer() + // Create session and WebSocket + manager.spawn({ + command: 'echo', + args: ['cleanup test'], + description: 'Cleanup test', + parentSessionId: 'cleanup-test', + }) + + // Wait for PTY to start + await new Promise((resolve) => setTimeout(resolve, 100)) + + const ws = new WebSocket('ws://localhost:8784/ws') + await new Promise((resolve) => { + ws.onopen = resolve + }) + + // Stop server + ptyServer[Symbol.dispose]() + + // Verify server is stopped (should fail to connect) + const response = await fetch('http://localhost:8784/api/sessions').catch(() => null) + expect(response).toBeNull() + + // Note: WebSocket may remain OPEN on client side until connection actually fails + // This is expected behavior - the test focuses on server cleanup + }) + }) +}) diff --git a/test/npm-pack-integration.test.ts b/test/npm-pack-integration.test.ts new file mode 100644 index 0000000..c2e2bf0 --- /dev/null +++ b/test/npm-pack-integration.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, readFileSync, copyFileSync, existsSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +// This test ensures the npm package can be packed, installed, and serves assets correctly + +async function run(cmd: string[], opts: { cwd?: string } = {}) { + const proc = Bun.spawn(cmd, { + cwd: opts.cwd, + stdout: 'pipe', + stderr: 'pipe', + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { code, stdout, stderr } +} + +function findPackFileFromOutput(stdout: string): string { + const lines = stdout.trim().split(/\r?\n/) + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] + if (line && line.trim().endsWith('.tgz')) return line.trim() + } + throw new Error('No .tgz file found in npm pack output') +} + +describe('npm pack integration', () => { + let tempDir: string + let packFile: string | null = null + let serverProcess: ReturnType | null = null + + afterEach(async () => { + // Cleanup server process + if (serverProcess) { + serverProcess.kill() + serverProcess = null + } + + // Cleanup temp directory + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (error) { + // Ignore cleanup errors + } + } + + // Cleanup pack file + if (packFile) { + try { + await run(['rm', '-f', packFile]) + } catch (error) { + // Ignore cleanup errors + } + } + }) + + it('packs, installs, and serves assets correctly', async () => { + // 1) Create temp workspace + tempDir = mkdtempSync(join(tmpdir(), 'opencode-pty-')) + + // 2) Pack the package + const pack = await run(['npm', 'pack']) + expect(pack.code).toBe(0) + const tgz = findPackFileFromOutput(pack.stdout) + packFile = tgz + const tgzPath = join(process.cwd(), tgz) + + // List tarball contents to find an asset + const list = await run(['tar', '-tf', tgzPath]) + expect(list.code).toBe(0) + const files = list.stdout.split(/\r?\n/).filter(Boolean) + const jsAsset = files.find((f) => /package\/dist\/web\/assets\/[^/]+\.js$/.test(f)) + expect(jsAsset).toBeDefined() + const assetName = jsAsset!.replace('package/dist/web/assets/', '') + + // 3) Install in temp workspace + const install = await run(['npm', 'install', tgzPath], { cwd: tempDir }) + expect(install.code).toBe(0) + + // Copy the server script to tempDir + copyFileSync(join(process.cwd(), 'test/start-server.ts'), join(tempDir, 'start-server.ts')) + + // Verify the package structure + const packageDir = join(tempDir, 'node_modules/opencode-pty') + expect(existsSync(join(packageDir, 'src/plugin/pty/manager.ts'))).toBe(true) + expect(existsSync(join(packageDir, 'dist/web/index.html'))).toBe(true) + serverProcess = Bun.spawn(['bun', 'run', 'start-server.ts'], { + cwd: tempDir, + env: { ...process.env, NODE_ENV: 'test' }, + stdout: 'inherit', + stderr: 'inherit', + }) + + // Wait for port file to be written + let port: number | null = null + let retries = 20 // 10 seconds + while (retries > 0) { + try { + const portFile = readFileSync('/tmp/test-server-port-0.txt', 'utf8') + port = parseInt(portFile.trim(), 10) + if (!isNaN(port)) break + } catch (error) { + // File not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)) + retries-- + } + expect(port).not.toBeNull() + + // Wait for server to be ready + retries = 20 // 10 seconds + while (retries > 0) { + try { + const response = await fetch(`http://localhost:${port}/api/sessions`) + if (response.ok) break + } catch (error) { + // Server not ready + } + await new Promise((resolve) => setTimeout(resolve, 500)) + retries-- + } + expect(retries).toBeGreaterThan(0) // Server should be ready + + // 5) Fetch assets + const assetResponse = await fetch(`http://localhost:${port}/assets/${assetName}`) + expect(assetResponse.status).toBe(200) + // Could add more specific checks here, like content-type or specific assets + + // 6) Fetch index.html and verify it's the built version + const indexResponse = await fetch(`http://localhost:${port}/`) + expect(indexResponse.status).toBe(200) + const indexContent = await indexResponse.text() + expect(indexContent).not.toContain('main.tsx') // Fails if raw HTML is served + expect(indexContent).toContain('/assets/') // Confirms built assets are referenced + }, 30000) +}) diff --git a/test/npm-pack-structure.test.ts b/test/npm-pack-structure.test.ts new file mode 100644 index 0000000..98d8207 --- /dev/null +++ b/test/npm-pack-structure.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'bun:test' + +// This test ensures `npm pack` (which triggers the package's `prepack` script) +// produces a tarball that includes the built web UI (`dist/web/**`) and the +// plugin bundle (`dist/opencode-pty.js`). + +async function run(cmd: string[], opts: { cwd?: string } = {}) { + const proc = Bun.spawn(cmd, { + cwd: opts.cwd, + stdout: 'pipe', + stderr: 'pipe', + }) + // Wait for stdout/stderr and for the process to exit. In some Bun + // versions `proc.exitCode` may be null until the process finishes, + // so await `proc.exited` to reliably get the exit code. + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { code, stdout, stderr } +} + +function findPackFileFromOutput(stdout: string): string | null { + // npm prints the created tarball filename on the last line + const lines = stdout.trim().split(/\r?\n/) + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] + if (line && line.trim().endsWith('.tgz')) return line.trim() + } + return null +} + +describe('npm pack structure', () => { + it('includes dist web assets', async () => { + // 1) Create tarball via npm pack (triggers prepack build) + const pack = await run(['npm', 'pack']) + expect(pack.code).toBe(0) + const tgz = findPackFileFromOutput(pack.stdout) + expect(typeof tgz).toBe('string') + + // 2) List tarball contents via tar -tf + const list = await run(['tar', '-tf', tgz as string]) + expect(list.code).toBe(0) + const files = list.stdout.split(/\r?\n/).filter(Boolean) + + // 3) Validate required files exist; NPM tarballs use 'package/' prefix + expect(files).toContain('package/dist/web/index.html') + + // At least one hashed JS and CSS asset + const hasJsAsset = files.some((f) => /package\/dist\/web\/assets\/[^/]+\.js$/.test(f)) + const hasCssAsset = files.some((f) => /package\/dist\/web\/assets\/[^/]+\.css$/.test(f)) + expect(hasJsAsset).toBeTrue() + expect(hasCssAsset).toBeTrue() + + // 4) Cleanup the pack file + await run(['rm', '-f', tgz as string]) + }, 10000) +}) diff --git a/test/pty-echo.test.ts b/test/pty-echo.test.ts new file mode 100644 index 0000000..f90da2d --- /dev/null +++ b/test/pty-echo.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { manager, registerRawOutputCallback } from '../src/plugin/pty/manager.ts' +import { ManagedTestServer } from './utils.ts' + +describe('PTY Echo Behavior', () => { + let managedTestServer: ManagedTestServer + let disposableStack: DisposableStack + beforeAll(async () => { + managedTestServer = await ManagedTestServer.create() + disposableStack = new DisposableStack() + disposableStack.use(managedTestServer) + }) + + afterAll(() => { + disposableStack.dispose() + }) + + it('should echo input characters in non-interactive bash session', async () => { + const title = crypto.randomUUID() + const promise = new Promise((resolve) => { + let receivedOutputs = '' + // Subscribe to raw output events + registerRawOutputCallback((session, rawData) => { + if (session.title !== title) return + receivedOutputs += rawData + if (receivedOutputs.includes('Hello World')) { + resolve(receivedOutputs) + } + }) + setTimeout(() => resolve('Timeout'), 1000) + }).catch((e) => { + console.error(e) + }) + + // Spawn interactive bash session + const session = manager.spawn({ + title, + command: 'echo', + args: ['Hello World'], + description: 'Echo test session', + parentSessionId: 'test', + }) + + const allOutput = await promise + + // Clean up + manager.kill(session.id, true) + + // Verify echo occurred + expect(allOutput).toContain('Hello World') + }) + + it('should echo input characters in interactive bash session', async () => { + const title = crypto.randomUUID() + const promise = new Promise((resolve) => { + let receivedOutputs = '' + // Subscribe to raw output events + registerRawOutputCallback((session, rawData) => { + if (session.title !== title) return + receivedOutputs += rawData + if (receivedOutputs.includes('Hello World')) { + resolve(receivedOutputs) + } + }) + setTimeout(() => resolve('Timeout'), 1000) + }).catch((e) => { + console.error(e) + }) + + // Spawn interactive bash session + const session = manager.spawn({ + title, + command: 'bash', + args: [], + description: 'Echo test session', + parentSessionId: 'test', + }) + + manager.write(session.id, 'echo "Hello World"\nexit\n') + + const allOutput = await promise + + // Clean up + manager.kill(session.id, true) + + // Verify echo occurred + expect(allOutput).toContain('Hello World') + }) +}) diff --git a/test/pty-integration.test.ts b/test/pty-integration.test.ts new file mode 100644 index 0000000..f470a10 --- /dev/null +++ b/test/pty-integration.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { ManagedTestClient, ManagedTestServer } from './utils.ts' +import type { WSMessageServerSessionUpdate } from '../src/web/shared/types.ts' +import type { PTYSessionInfo } from '../src/plugin/pty/types.ts' + +describe('PTY Manager Integration', () => { + let managedTestServer: ManagedTestServer + let disposableStack: DisposableStack + + beforeAll(async () => { + managedTestServer = await ManagedTestServer.create() + disposableStack = new DisposableStack() + disposableStack.use(managedTestServer) + }) + + afterAll(async () => { + disposableStack.dispose() + }) + + describe('Output Broadcasting', () => { + it('should broadcast raw output to subscribed WebSocket clients', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const title = crypto.randomUUID() + const dataReceivedPromise = new Promise((resolve) => { + let dataTotal = '' + managedTestClient.rawDataCallbacks.push((message) => { + if (message.session.title !== title) return + dataTotal += message.rawData + if (dataTotal.includes('test output')) { + resolve(dataTotal) + } + }) + }) + managedTestClient.send({ + type: 'spawn', + title, + command: 'echo', + args: ['test output'], + description: 'Test session', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + + const rawData = await dataReceivedPromise + + expect(rawData).toContain('test output') + }) + + it('should not broadcast to unsubscribed clients', async () => { + await using managedTestClient1 = await ManagedTestClient.create(managedTestServer) + await using managedTestClient2 = await ManagedTestClient.create(managedTestServer) + const title1 = crypto.randomUUID() + const title2 = crypto.randomUUID() + const dataReceivedPromise1 = new Promise((resolve) => { + let dataTotal = '' + managedTestClient1.rawDataCallbacks.push((message) => { + if (message.session.title !== title1) return + dataTotal += message.rawData + if (dataTotal.includes('output from session 1')) { + resolve(dataTotal) + } + }) + }) + const dataReceivedPromise2 = new Promise((resolve) => { + let dataTotal = '' + managedTestClient2.rawDataCallbacks.push((message) => { + if (message.session.title !== title2) return + dataTotal += message.rawData + if (dataTotal.includes('output from session 2')) { + resolve(dataTotal) + } + }) + }) + + // Spawn and subscribe client 1 to session 1 + managedTestClient1.send({ + type: 'spawn', + title: title1, + command: 'echo', + args: ['output from session 1'], + description: 'Session 1', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + + // Spawn and subscribe client 2 to session 2 + managedTestClient2.send({ + type: 'spawn', + title: title2, + command: 'echo', + args: ['output from session 2'], + description: 'Session 2', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + + const rawData1 = await dataReceivedPromise1 + const rawData2 = await dataReceivedPromise2 + + expect(rawData1).toContain('output from session 1') + expect(rawData2).toContain('output from session 2') + + expect(rawData1).not.toContain('output from session 2') + expect(rawData2).not.toContain('output from session 1') + }) + }) + + describe('Session Management Integration', () => { + it('should provide session data in correct format', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const title = crypto.randomUUID() + const sessionInfoPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title && message.session.status === 'exited') { + resolve(message) + } + }) + }) + + let outputTotal = '' + managedTestClient.rawDataCallbacks.push((message) => { + if (message.session.title !== title) return + outputTotal += message.rawData + }) + + // Spawn a session + managedTestClient.send({ + type: 'spawn', + title, + command: 'node', + args: ['-e', "console.log('test')"], + description: 'Test Node.js session', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + + const sessionInfo = await sessionInfoPromise + + const response = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + const sessions = (await response.json()) as PTYSessionInfo[] + + expect(Array.isArray(sessions)).toBe(true) + expect(sessions.length).toBeGreaterThan(0) + + const testSession = sessions.find((s) => s.id === sessionInfo.session.id) + expect(testSession).toBeDefined() + if (!testSession) return + expect(testSession.command).toBe('node') + expect(testSession.args).toEqual(['-e', "console.log('test')"]) + expect(testSession.status).toBeDefined() + expect(typeof testSession.pid).toBe('number') + expect(testSession.lineCount).toBeGreaterThan(0) + expect(outputTotal).toContain('test') + }) + + it('should handle session lifecycle correctly', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const title = crypto.randomUUID() + const sessionExitedPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title && message.session.status === 'exited') { + resolve(message) + } + }) + }) + + // Spawn a session + managedTestClient.send({ + type: 'spawn', + title, + command: 'echo', + args: ['lifecycle test'], + description: 'Lifecycle test session', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + + const sessionExited = await sessionExitedPromise + + expect(sessionExited.session.status).toBe('exited') + expect(sessionExited.session.exitCode).toBe(0) + + // Verify via API + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${sessionExited.session.id}` + ) + const sessionData = (await response.json()) as PTYSessionInfo + + expect(sessionData.status).toBe('exited') + expect(sessionData.exitCode).toBe(0) + }) + + it('should support session cleanup via API', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const title = crypto.randomUUID() + const sessionKilledPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title && message.session.status === 'killed') { + resolve(message) + } + }) + }) + const sessionRunningPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title && message.session.status === 'running') { + resolve(message) + } + }) + }) + + // Spawn a long-running session + managedTestClient.send({ + type: 'spawn', + title, + command: 'sleep', + args: ['10'], + description: 'Kill test session', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + }) + const runningSession = await sessionRunningPromise + + // Kill it via API + const killResponse = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${runningSession.session.id}`, + { + method: 'DELETE', + } + ) + expect(killResponse.status).toBe(200) + + await sessionKilledPromise + + const killResult = await killResponse.json() + expect(killResult.success).toBe(true) + + // Check status + const statusResponse = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${runningSession.session.id}` + ) + const sessionData = await statusResponse.json() + expect(sessionData.status).toBe('killed') + }) + }) +}) diff --git a/test/pty-tools.test.ts b/test/pty-tools.test.ts new file mode 100644 index 0000000..58b4229 --- /dev/null +++ b/test/pty-tools.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from 'bun:test' +import { ptySpawn } from '../src/plugin/pty/tools/spawn.ts' +import { ptyRead } from '../src/plugin/pty/tools/read.ts' +import { ptyList } from '../src/plugin/pty/tools/list.ts' +import { RingBuffer } from '../src/plugin/pty/buffer.ts' +import { manager } from '../src/plugin/pty/manager.ts' +import moment from 'moment' + +describe('PTY Tools', () => { + afterAll(() => { + mock.restore() + }) + describe('ptySpawn', () => { + beforeEach(() => { + spyOn(manager, 'spawn').mockImplementation((opts) => ({ + id: 'test-session-id', + title: opts.title || 'Test Session', + command: opts.command, + args: opts.args || [], + workdir: opts.workdir || '/tmp', + pid: 12345, + status: 'running', + createdAt: moment().toISOString(true), + lineCount: 0, + })) + }) + + it('should spawn a PTY session with minimal args', async () => { + const ctx = { + sessionID: 'parent-session-id', + messageID: 'msg-1', + agent: 'test-agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + const args = { + command: 'echo', + args: ['hello'], + description: 'Test session', + } + + const result = await ptySpawn.execute(args, ctx) + + expect(manager.spawn).toHaveBeenCalledWith({ + command: 'echo', + args: ['hello'], + description: 'Test session', + parentSessionId: 'parent-session-id', + workdir: undefined, + env: undefined, + title: undefined, + notifyOnExit: undefined, + }) + + expect(result).toContain('') + expect(result).toContain('ID: test-session-id') + expect(result).toContain('Command: echo hello') + expect(result).toContain('') + }) + + it('should spawn with all optional args', async () => { + const ctx = { + sessionID: 'parent-session-id', + messageID: 'msg-2', + agent: 'test-agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + const args = { + command: 'node', + args: ['script.js'], + workdir: '/home/user', + env: { NODE_ENV: 'test' }, + title: 'My Node Session', + description: 'Running Node.js script', + notifyOnExit: true, + } + + const result = await ptySpawn.execute(args, ctx) + + expect(manager.spawn).toHaveBeenCalledWith({ + command: 'node', + args: ['script.js'], + workdir: '/home/user', + env: { NODE_ENV: 'test' }, + title: 'My Node Session', + description: 'Running Node.js script', + parentSessionId: 'parent-session-id', + notifyOnExit: true, + }) + + expect(result).toContain('Title: My Node Session') + expect(result).toContain('Workdir: /home/user') + expect(result).toContain('Command: node script.js') + expect(result).toContain('PID: 12345') + expect(result).toContain('Status: running') + }) + }) + + describe('ptyRead', () => { + beforeEach(() => { + spyOn(manager, 'get').mockReturnValue({ + id: 'test-session-id', + title: 'Test Session', + description: 'A session for testing', + command: 'echo', + args: ['hello'], + workdir: '/tmp', + status: 'running', + pid: 12345, + createdAt: moment().toISOString(true), + lineCount: 2, + }) + spyOn(manager, 'read').mockReturnValue({ + lines: ['line 1', 'line 2'], + offset: 0, + hasMore: false, + totalLines: 2, + }) + spyOn(manager, 'search').mockReturnValue({ + matches: [{ lineNumber: 1, text: 'line 1' }], + totalMatches: 1, + totalLines: 2, + hasMore: false, + offset: 0, + }) + }) + + it('should read output without pattern', async () => { + const args = { id: 'test-session-id' } + const ctx = { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + + const result = await ptyRead.execute(args, ctx) + + expect(manager.get).toHaveBeenCalledWith('test-session-id') + expect(manager.read).toHaveBeenCalledWith('test-session-id', 0, 500) + expect(result).toContain('') + expect(result).toContain('00001| line 1') + expect(result).toContain('00002| line 2') + expect(result).toContain('(End of buffer - total 2 lines)') + expect(result).toContain('') + }) + + it('should read with pattern', async () => { + const args = { id: 'test-session-id', pattern: 'line' } + const ctx = { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + + const result = await ptyRead.execute(args, ctx) + + expect(manager.search).toHaveBeenCalledWith('test-session-id', /line/, 0, 500) + expect(result).toContain('') + expect(result).toContain('00001| line 1') + expect(result).toContain('(1 match from 2 total lines)') + }) + + it('should throw for invalid session', async () => { + spyOn(manager, 'get').mockReturnValue(null) + + const args = { id: 'invalid-id' } + const ctx = { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + + expect(ptyRead.execute(args, ctx)).rejects.toThrow("PTY session 'invalid-id' not found") + }) + + it('should throw for invalid regex', async () => { + const args = { id: 'test-session-id', pattern: '[invalid' } + const ctx = { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + + expect(ptyRead.execute(args, ctx)).rejects.toThrow( + 'Potentially dangerous regex pattern rejected' + ) + }) + }) + + describe('ptyList', () => { + it('should list active sessions', async () => { + const mockSessions = [ + { + id: 'pty_123', + title: 'Test Session', + command: 'echo', + args: ['hello'], + status: 'running' as const, + pid: 12345, + lineCount: 10, + workdir: '/tmp', + createdAt: moment('2023-01-01T00:00:00Z').toISOString(true), + }, + ] + spyOn(manager, 'list').mockReturnValue(mockSessions) + + const result = await ptyList.execute( + {}, + { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + ) + + expect(manager.list).toHaveBeenCalled() + expect(result).toContain('') + expect(result).toContain('[pty_123] Test Session') + expect(result).toContain('Command: echo hello') + expect(result).toContain('Status: running') + expect(result).toContain('PID: 12345 | Lines: 10 | Workdir: /tmp') + expect(result).toContain('Total: 1 session(s)') + expect(result).toContain('') + }) + + it('should handle no sessions', async () => { + spyOn(manager, 'list').mockReturnValue([]) + + const result = await ptyList.execute( + {}, + { + sessionID: 'parent', + messageID: 'msg', + agent: 'agent', + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: mock(async () => {}), + } + ) + + expect(result).toBe('\nNo active PTY sessions.\n') + }) + }) + + describe('RingBuffer', () => { + it('should append and read lines', () => { + const buffer = new RingBuffer(100) // Large buffer to avoid truncation + buffer.append('line1\nline2\nline3') + + expect(buffer.length).toBe(3) // Number of lines after splitting + expect(buffer.read()).toEqual(['line1', 'line2', 'line3']) + expect(buffer.readRaw()).toBe('line1\nline2\nline3') // Raw buffer preserves newlines + }) + + it('should handle offset and limit', () => { + const buffer = new RingBuffer(100) + buffer.append('line1\nline2\nline3\nline4') + + expect(buffer.read(1, 2)).toEqual(['line2', 'line3']) + expect(buffer.readRaw()).toBe('line1\nline2\nline3\nline4') + }) + + it('should search with regex', () => { + const buffer = new RingBuffer(100) + buffer.append('hello world\nfoo bar\nhello test') + + const matches = buffer.search(/hello/) + expect(matches).toEqual([ + { lineNumber: 1, text: 'hello world' }, + { lineNumber: 3, text: 'hello test' }, + ]) + }) + + it('should clear buffer', () => { + const buffer = new RingBuffer(100) + buffer.append('line1\nline2') + expect(buffer.length).toBe(2) + + buffer.clear() + expect(buffer.length).toBe(0) + expect(buffer.read()).toEqual([]) + expect(buffer.readRaw()).toBe('') + }) + + it('should truncate buffer at byte level when exceeding max', () => { + const buffer = new RingBuffer(10) // Small buffer for testing + buffer.append('line1\nline2\nline3\nline4') + + // Input is 'line1\nline2\nline3\nline4' (23 chars) + // With buffer size 10, keeps last 10 chars: 'ine3\nline4' + expect(buffer.readRaw()).toBe('ine3\nline4') + expect(buffer.read()).toEqual(['ine3', 'line4']) + expect(buffer.length).toBe(2) + }) + }) +}) diff --git a/test/spawn-repeat.test.ts b/test/spawn-repeat.test.ts new file mode 100644 index 0000000..fcc6400 --- /dev/null +++ b/test/spawn-repeat.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { + initManager, + manager, + rawOutputCallbacks, + registerRawOutputCallback, +} from '../src/plugin/pty/manager.ts' +import { OpencodeClient } from '@opencode-ai/sdk' + +describe('PTY Echo Behavior', () => { + beforeEach(() => { + initManager(new OpencodeClient()) + }) + + afterEach(() => { + // Clean up any sessions + manager.clearAllSessions() + }) + + it('should receive initial data reproducibly', async () => { + const start = Date.now() + const maxRuntime = 4000 + let runnings = 1 + while (Date.now() - start < maxRuntime) { + runnings++ + const { success, stderr } = Bun.spawnSync({ + cmd: [ + 'bun', + 'test', + 'spawn-repeat.test.ts', + '--test-name-pattern', + 'should receive initial data once', + ], + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, SYNC_TESTS: '1' }, + }) + expect(success, `[TEST] Iteration ${runnings}, stderr: ${stderr}`).toBe(true) + } + }) + + it.skipIf(!process.env.SYNC_TESTS)( + 'should receive initial data once', + async () => { + const title = crypto.randomUUID() + // Subscribe to raw output events + const promise = new Promise((resolve) => { + let rawDataTotal = '' + registerRawOutputCallback((session, rawData) => { + if (session.title !== title) return + rawDataTotal += rawData + if (rawData.includes('Hello World')) { + resolve(rawDataTotal) + } + }) + }).catch((e) => { + console.error(e) + }) + + // Spawn interactive bash session + const session = manager.spawn({ + title: title, + command: 'echo', + args: ['Hello World'], + description: 'Echo test session', + parentSessionId: 'test', + }) + + const rawData = await promise + + // Clean up + manager.kill(session.id, true) + rawOutputCallbacks.length = 0 + + // Verify echo occurred + expect(rawData).toContain('Hello World') + }, + 1000 + ) +}) diff --git a/test/start-server.ts b/test/start-server.ts new file mode 100644 index 0000000..9c4b27c --- /dev/null +++ b/test/start-server.ts @@ -0,0 +1,72 @@ +import { initManager, manager } from 'opencode-pty/src/plugin/pty/manager' +import { PTYServer } from '../src/web/server/server' +import { OpencodeClient } from '@opencode-ai/sdk' + +// Set NODE_ENV if not set +if (!process.env.NODE_ENV) { + process.env.NODE_ENV = 'test' +} + +initManager(new OpencodeClient()) + +const server = await PTYServer.createServer() + +// Only log in non-test environments or when explicitly requested + +// Write port to file for tests to read +if (process.env.NODE_ENV === 'test') { + const workerIndex = process.env.TEST_WORKER_INDEX || '0' + if (!server.server.port) { + throw new Error('Unix sockets not supported. File an issue if you need this feature.') + } + await Bun.write(`/tmp/test-server-port-${workerIndex}.txt`, server.server.port.toString()) +} + +// Health check for test mode +if (process.env.NODE_ENV === 'test') { + let retries = 20 // 10 seconds + while (retries > 0) { + try { + const response = await fetch(`${server.server.url}/api/sessions`) + if (response.ok) { + break + } + } catch (error) { + if (!(error instanceof DOMException) || error.name !== 'AbortError') { + throw error + } + } + await new Promise((resolve) => setTimeout(resolve, 500)) + retries-- + } + if (retries === 0) { + console.error('Server failed to start properly after 10 seconds') + process.exit(1) + } +} + +// Create test sessions for manual testing and e2e tests +if (process.env.NODE_ENV === 'test') { + // Create an interactive bash session for e2e tests + manager.spawn({ + command: 'bash', + args: ['-i'], // Interactive bash + description: 'Interactive bash session for e2e tests', + parentSessionId: 'test-session', + }) +} else if (process.env.CI !== 'true') { + manager.spawn({ + command: 'bash', + args: [ + '-c', + "echo 'Welcome to live streaming test'; echo 'Type commands and see real-time output'; for i in {1..100}; do echo \"$(date): Live update $i...\"; sleep 1; done", + ], + description: 'Live streaming test session', + parentSessionId: 'live-test', + }) +} + +// Keep the server running indefinitely +setInterval(() => { + // Keep-alive check - server will continue running +}, 1000) diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..77bbea1 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'bun:test' +import { + CustomError, + type WSMessageClientSubscribeSession, + type WSMessageServerError, + type WSMessageServerSessionList, +} from '../src/web/shared/types.ts' +import type { PTYSessionInfo } from '../src/plugin/pty/types.ts' +import moment from 'moment' + +describe('Web Types', () => { + describe('WSMessage', () => { + it('should validate subscribe message structure', () => { + const message: WSMessageClientSubscribeSession = { + type: 'subscribe', + sessionId: 'pty_12345', + } + + expect(message.type).toBe('subscribe') + expect(message.sessionId).toBe('pty_12345') + }) + + it('should validate session_list message structure', () => { + const sessions: PTYSessionInfo[] = [ + { + id: 'pty_12345', + title: 'Test Session', + command: 'echo', + status: 'running', + pid: 1234, + lineCount: 5, + createdAt: moment().toISOString(true), + args: ['hello'], + workdir: '/home/user', + }, + ] + + const message: WSMessageServerSessionList = { + type: 'session_list', + sessions, + } + + expect(message.type).toBe('session_list') + expect(message.sessions).toEqual(sessions) + }) + + it('should validate error message structure', () => { + const message: WSMessageServerError = { + type: 'error', + error: new CustomError('Session not found'), + } + + expect(message.type).toBe('error') + expect(message.error.message).toBe('Session not found') + }) + }) + + describe('SessionData', () => { + it('should validate complete session data structure', () => { + const session: PTYSessionInfo = { + id: 'pty_12345', + title: 'Test Echo Session', + command: 'echo', + status: 'exited', + exitCode: 0, + pid: 1234, + lineCount: 2, + createdAt: moment().toISOString(true), + args: ['Hello, World!'], + workdir: '/home/user', + } + + expect(session.id).toBe('pty_12345') + expect(session.title).toBe('Test Echo Session') + expect(session.command).toBe('echo') + expect(session.status).toBe('exited') + expect(session.exitCode).toBe(0) + expect(session.pid).toBe(1234) + expect(session.lineCount).toBe(2) + expect(typeof session.createdAt).toBe('string') + }) + + it('should allow optional exitCode', () => { + const session: PTYSessionInfo = { + id: 'pty_67890', + title: 'Running Session', + command: 'sleep', + status: 'running', + pid: 5678, + lineCount: 0, + createdAt: moment('2026-01-21T10:00:00.000Z').toISOString(true), + args: ['Hello, World!'], + workdir: '/home/user', + } + + expect(session.exitCode).toBeUndefined() + expect(session.status).toBe('running') + }) + }) +}) diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..2fcc250 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,146 @@ +import { OpencodeClient } from '@opencode-ai/sdk' +import { + initManager, + manager, + sessionUpdateCallbacks, + rawOutputCallbacks, +} from '../src/plugin/pty/manager' +import { PTYServer } from '../src/web/server/server' +import type { + WSMessageServer, + WSMessageServerSubscribedSession, + WSMessageServerUnsubscribedSession, + WSMessageServerSessionUpdate, + WSMessageServerRawData, + WSMessageServerReadRawResponse, + WSMessageServerSessionList, + WSMessageServerError, + WSMessageClientInput, + WSMessageClientSessionList, + WSMessageClientSpawnSession, + WSMessageClientSubscribeSession, + WSMessageClientUnsubscribeSession, +} from '../src/web/shared/types' + +export class ManagedTestClient implements Disposable { + public readonly ws: WebSocket + private readonly stack = new DisposableStack() + + public readonly messages: WSMessageServer[] = [] + public readonly subscribedCallbacks: Array<(message: WSMessageServerSubscribedSession) => void> = + [] + public readonly unsubscribedCallbacks: Array< + (message: WSMessageServerUnsubscribedSession) => void + > = [] + public readonly sessionUpdateCallbacks: Array<(message: WSMessageServerSessionUpdate) => void> = + [] + public readonly rawDataCallbacks: Array<(message: WSMessageServerRawData) => void> = [] + public readonly readRawResponseCallbacks: Array< + (message: WSMessageServerReadRawResponse) => void + > = [] + public readonly sessionListCallbacks: Array<(message: WSMessageServerSessionList) => void> = [] + public readonly errorCallbacks: Array<(message: WSMessageServerError) => void> = [] + + private constructor(managedTestServer: ManagedTestServer) { + this.ws = new WebSocket(managedTestServer.server.getWsUrl()!) + this.ws.onerror = (error) => { + throw error + } + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data) as WSMessageServer + this.messages.push(message) + switch (message.type) { + case 'subscribed': + this.subscribedCallbacks.forEach((callback) => + callback(message as WSMessageServerSubscribedSession) + ) + break + case 'unsubscribed': + this.unsubscribedCallbacks.forEach((callback) => + callback(message as WSMessageServerUnsubscribedSession) + ) + break + case 'session_update': + this.sessionUpdateCallbacks.forEach((callback) => + callback(message as WSMessageServerSessionUpdate) + ) + break + case 'raw_data': + this.rawDataCallbacks.forEach((callback) => callback(message as WSMessageServerRawData)) + break + case 'readRawResponse': + this.readRawResponseCallbacks.forEach((callback) => + callback(message as WSMessageServerReadRawResponse) + ) + break + case 'session_list': + this.sessionListCallbacks.forEach((callback) => + callback(message as WSMessageServerSessionList) + ) + break + case 'error': + this.errorCallbacks.forEach((callback) => callback(message as WSMessageServerError)) + break + } + } + } + [Symbol.dispose]() { + this.ws.close() + this.stack.dispose() + } + /** + * Waits until the WebSocket connection is open. + * + * The onopen event is broken so we need to wait manually. + * Problem: if onopen is set after the WebSocket is opened, + * it will never be called. So we wait here until the readyState is OPEN. + * This prevents flakiness. + */ + public async waitOpen() { + while (this.ws.readyState !== WebSocket.OPEN) { + await new Promise(setImmediate) + } + } + public static async create(managedTestServer: ManagedTestServer) { + const client = new ManagedTestClient(managedTestServer) + await client.waitOpen() + return client + } + + public send( + message: + | WSMessageClientInput + | WSMessageClientSessionList + | WSMessageClientSpawnSession + | WSMessageClientSubscribeSession + | WSMessageClientUnsubscribeSession + ) { + this.ws.send(JSON.stringify(message)) + } +} + +export class ManagedTestServer implements Disposable { + public readonly server: PTYServer + private readonly stack = new DisposableStack() + public readonly sessionId: string + + public static async create() { + const server = await PTYServer.createServer() + + return new ManagedTestServer(server) + } + + private constructor(server: PTYServer) { + const client = new OpencodeClient() + initManager(client) + this.server = server + this.stack.use(this.server) + this.sessionId = crypto.randomUUID() + } + [Symbol.dispose]() { + this.stack.dispose() + manager.clearAllSessions() + sessionUpdateCallbacks.length = 0 + rawOutputCallbacks.length = 0 + } +} diff --git a/test/web-server.test.ts b/test/web-server.test.ts new file mode 100644 index 0000000..71cb9de --- /dev/null +++ b/test/web-server.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, afterAll, beforeAll } from 'bun:test' +import { + manager, + registerRawOutputCallback, + registerSessionUpdateCallback, +} from '../src/plugin/pty/manager.ts' +import { PTYServer } from '../src/web/server/server.ts' +import type { PTYSessionInfo } from '../src/plugin/pty/types.ts' +import { ManagedTestServer } from './utils.ts' + +describe('Web Server', () => { + describe('Server Lifecycle', () => { + it('should start server successfully', async () => { + await using server = await PTYServer.createServer() + const url = server.server.url + expect(url.hostname).toBe('localhost') + expect(url.protocol).toBe('http:') + expect(url.port).not.toBe(0) + expect(url.port).not.toBe(8080) // Default port should be avoided + }) + + it('should support multiple server instances', async () => { + await using server1 = await PTYServer.createServer() + await using server2 = await PTYServer.createServer() + expect(server1.server.url.port).not.toBe(server2.server.url.port) + }) + + it('should stop server correctly', async () => { + const server = await PTYServer.createServer() + expect(server.server.url).toBeTruthy() + server[Symbol['dispose']]() + }) + }) + + describe('HTTP Endpoints', () => { + let managedTestServer: ManagedTestServer + let disposableStack: DisposableStack + + beforeAll(async () => { + disposableStack = new DisposableStack() + managedTestServer = await ManagedTestServer.create() + disposableStack.use(managedTestServer) + }) + + afterAll(() => { + disposableStack.dispose() + }) + + it('should serve built assets', async () => { + const response = await fetch(managedTestServer.server.server.url) + expect(response.status).toBe(200) + const html = await response.text() + + // Should contain built HTML with assets + expect(html).toContain('') + expect(html).toContain('PTY Sessions Monitor') + expect(html).toContain('/assets/') + expect(html).not.toContain('/main.tsx') + expect(html).toContain('
') + + // Extract asset URLs from HTML + const jsMatch = html.match(/src="\/assets\/([^"]+\.js)"/) + const cssMatch = html.match(/href="\/assets\/([^"]+\.css)"/) + + expect(jsMatch).toBeTruthy() + expect(cssMatch).toBeTruthy() + + if (!jsMatch || !cssMatch) { + throw new Error('Failed to extract asset URLs from HTML') + } + + const jsAsset = jsMatch[1] + const jsResponse = await fetch(`${managedTestServer.server.server.url}/assets/${jsAsset}`) + expect(jsResponse.status).toBe(200) + const ct = jsResponse.headers.get('content-type') + expect((ct || '').toLowerCase()).toMatch(/^(application|text)\/javascript(;.*)?$/) + + const cssAsset = cssMatch[1] + const cssResponse = await fetch(`${managedTestServer.server.server.url}/assets/${cssAsset}`) + expect(cssResponse.status).toBe(200) + expect((cssResponse.headers.get('content-type') || '').toLowerCase()).toMatch( + /^text\/css(;.*)?$/ + ) + }) + + it('should serve HTML on root path', async () => { + const response = await fetch(managedTestServer.server.server.url) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/html') + + const html = await response.text() + expect(html).toContain('') + expect(html).toContain('PTY Sessions Monitor') + }) + + it('should return sessions list', async () => { + const response = await fetch(`${managedTestServer.server.server.url}/api/sessions`) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/json') + + const sessions = await response.json() + expect(Array.isArray(sessions)).toBe(true) + }) + + it('should return individual session', async () => { + // Create a test session first + const session = manager.spawn({ + command: 'bash', + args: [], + description: 'Test session', + parentSessionId: 'test', + }) + const rawDataPromise = new Promise((resolve) => { + let rawDataTotal = '' + registerRawOutputCallback((sessionInfo: PTYSessionInfo, rawData: string) => { + if (sessionInfo.id === session.id) { + rawDataTotal += rawData + if (rawDataTotal.includes('test output')) { + resolve(rawDataTotal) + } + } + }) + }) + + manager.write(session.id, 'echo "test output"\nexit\n') + + await rawDataPromise + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}` + ) + expect(response.status).toBe(200) + + const sessionData = await response.json() + expect(sessionData.id).toBe(session.id) + expect(sessionData.command).toBe('bash') + expect(sessionData.args).toEqual([]) + }, 200) + + it('should return 404 for non-existent session', async () => { + const nonexistentId = crypto.randomUUID() + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${nonexistentId}` + ) + expect(response.status).toBe(404) + }, 200) + + it('should handle input to session', async () => { + const title = crypto.randomUUID() + const sessionUpdatePromise = new Promise((resolve) => { + registerSessionUpdateCallback((sessionInfo: PTYSessionInfo) => { + if (sessionInfo.title === title && sessionInfo.status === 'running') { + resolve(sessionInfo) + } + }) + }) + // Create a session to test input + const session = manager.spawn({ + title: title, + command: 'cat', + args: [], + description: 'Test session', + parentSessionId: 'test', + }) + + // Wait for PTY to start + await sessionUpdatePromise + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}/input`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: 'test input\n' }), + } + ) + + // Should return success + expect(response.status).toBe(200) + const result = await response.json() + expect(result).toHaveProperty('success', true) + }, 200) + + it('should handle kill session', async () => { + const title = crypto.randomUUID() + const sessionRunningPromise = new Promise((resolve) => { + registerSessionUpdateCallback((sessionInfo: PTYSessionInfo) => { + if (sessionInfo.title === title && sessionInfo.status === 'running') { + resolve(sessionInfo) + } + }) + }) + const sessionExitedPromise = new Promise((resolve) => { + registerSessionUpdateCallback((sessionInfo: PTYSessionInfo) => { + if (sessionInfo.title === title && sessionInfo.status === 'killed') { + resolve(sessionInfo) + } + }) + }) + const session = manager.spawn({ + title: title, + command: 'cat', + args: [], + description: 'Test session', + parentSessionId: 'test', + }) + + // Wait for PTY to start + await sessionRunningPromise + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}`, + { + method: 'DELETE', + } + ) + + expect(response.status).toBe(200) + const result = await response.json() + expect(result.success).toBe(true) + + await sessionExitedPromise + }, 1000) + + it('should return session output', async () => { + const title = crypto.randomUUID() + const sessionExitedPromise = new Promise((resolve) => { + registerSessionUpdateCallback((sessionInfo: PTYSessionInfo) => { + if (sessionInfo.title === title && sessionInfo.status === 'exited') { + resolve(sessionInfo) + } + }) + }) + // Create a session that produces output + const session = manager.spawn({ + title, + command: 'echo', + args: ['line1\nline2\nline3'], + description: 'Test session with output', + parentSessionId: 'test-output', + }) + + // Wait a bit for output to be captured + await sessionExitedPromise + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${session.id}/buffer/raw` + ) + expect(response.status).toBe(200) + + const bufferData = await response.json() + expect(bufferData).toHaveProperty('raw') + expect(bufferData).toHaveProperty('byteLength') + expect(typeof bufferData.raw).toBe('string') + expect(typeof bufferData.byteLength).toBe('number') + expect(bufferData.raw.length).toBe(21) + expect(bufferData.raw).toBe('line1\r\nline2\r\nline3\r\n') + }) + + it('should return index.html for non-existent endpoints', async () => { + const response = await fetch(`${managedTestServer.server.server.url}/api/nonexistent`) + expect(response.status).toBe(200) + const text = await response.text() + expect(text).toContain('
') + expect(text).toContain('') + }, 200) + }) +}) diff --git a/test/websocket.test.ts b/test/websocket.test.ts new file mode 100644 index 0000000..82dbd5a --- /dev/null +++ b/test/websocket.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { manager } from '../src/plugin/pty/manager.ts' +import { + CustomError, + type WSMessageServerError, + type WSMessageServerSessionList, + type WSMessageServerSessionUpdate, + type WSMessageServerSubscribedSession, + type WSMessageServerUnsubscribedSession, +} from '../src/web/shared/types.ts' +import { ManagedTestClient, ManagedTestServer } from './utils.ts' + +describe('WebSocket Functionality', () => { + let managedTestServer: ManagedTestServer + let disposableStack: DisposableStack + beforeAll(async () => { + managedTestServer = await ManagedTestServer.create() + disposableStack = new DisposableStack() + disposableStack.use(managedTestServer) + }) + afterAll(() => { + disposableStack.dispose() + }) + + describe('WebSocket Connection', () => { + it('should accept WebSocket connections', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + await managedTestClient.waitOpen() + expect(managedTestClient.ws.readyState).toBe(WebSocket.OPEN) + }, 100) + + it('should not send session list on connection', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + let called = false + managedTestClient.sessionListCallbacks.push((message: WSMessageServerSessionList) => { + expect(message).toBeUndefined() + called = true + }) + + const title = crypto.randomUUID() + const promise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title) { + if (message.session.status === 'exited') { + resolve(message) + } + } + }) + }) + + managedTestClient.send({ + type: 'spawn', + title: title, + subscribe: true, + command: 'echo', + args: ['Hello World'], + description: 'Test session', + parentSessionId: managedTestServer.sessionId, + }) + await promise + expect(called, 'session list has been sent unexpectedly').toBe(false) + }) + }) + + describe('WebSocket Message Handling', () => { + it('should handle subscribe message', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const title = crypto.randomUUID() + const sessionRunningPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.title === title) { + if (message.session.status === 'running') { + resolve(message) + } + } + }) + }) + managedTestClient.send({ + type: 'spawn', + title: title, + subscribe: false, + command: 'bash', + args: [], + description: 'Test session', + parentSessionId: managedTestServer.sessionId, + }) + const runningSession = await sessionRunningPromise + + const subscribedPromise = new Promise((res) => { + managedTestClient.subscribedCallbacks.push((message) => { + if (message.sessionId === runningSession.session.id) { + res(true) + } + }) + }) + + managedTestClient.send({ + type: 'subscribe', + sessionId: runningSession.session.id, + }) + + const subscribed = await subscribedPromise + expect(subscribed).toBe(true) + }, 1000) + + it('should handle subscribe to non-existent session', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const nonexistentSessionId = crypto.randomUUID() + const errorPromise = new Promise((res) => { + managedTestClient.errorCallbacks.push((message) => { + if (message.error.message.includes(nonexistentSessionId)) { + res(message) + } + }) + }) + + managedTestClient.send({ + type: 'subscribe', + sessionId: nonexistentSessionId, + }) + + await errorPromise + }, 100) + + it('should handle unsubscribe message', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const sessionId = crypto.randomUUID() + + const unsubscribedPromise = new Promise((res) => { + managedTestClient.unsubscribedCallbacks.push((message) => { + if (message.sessionId === sessionId) { + res(message) + } + }) + }) + + managedTestClient.send({ + type: 'unsubscribe', + sessionId: sessionId, + }) + + await unsubscribedPromise + expect(managedTestClient.ws.readyState).toBe(WebSocket.OPEN) + }, 100) + + it('should handle session_list request', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const sessionListPromise = new Promise((res) => { + managedTestClient.sessionListCallbacks.push((message) => { + res(message) + }) + }) + + managedTestClient.send({ + type: 'session_list', + }) + + await sessionListPromise + }, 100) + + it('should handle invalid message format', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const errorPromise = new Promise((res) => { + managedTestClient.errorCallbacks.push((message) => { + res(message.error) + }) + }) + + managedTestClient.ws.send('invalid json') + + const customError = await errorPromise + expect(customError.message).toContain('JSON Parse error') + }, 100) + + it('should handle unknown message type', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const errorPromise = new Promise((res) => { + managedTestClient.errorCallbacks.push((message) => { + res(message.error) + }) + }) + managedTestClient.ws.send( + JSON.stringify({ + type: 'unknown_type', + data: 'test', + }) + ) + + const customError = await errorPromise + expect(customError.message).toContain('Unknown message type') + }, 100) + + it('should demonstrate WebSocket subscription logic works correctly', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + const testSession = manager.spawn({ + command: 'bash', + args: [], + description: 'Test session for subscription logic', + parentSessionId: managedTestServer.sessionId, + }) + + // Subscribe to the session + const subscribePromise = new Promise((res) => { + managedTestClient.subscribedCallbacks.push((message) => { + if (message.sessionId === testSession.id) { + res(message) + } + }) + }) + + managedTestClient.send({ + type: 'subscribe', + sessionId: testSession.id, + }) + await subscribePromise + + let rawData = '' + managedTestClient.rawDataCallbacks.push((message) => { + if (message.session.id === testSession.id) { + rawData += message.rawData + } + }) + + const sessionUpdatePromise = new Promise((res) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if (message.session.id === testSession.id) { + if (message.session.status === 'exited') { + res(message) + } + } + }) + }) + + // Send input to the session + managedTestClient.send({ + type: 'input', + sessionId: testSession.id, + data: "echo 'Hello from subscription test'\nexit\n", + }) + + // Wait for session to exit + await sessionUpdatePromise + + // Check that we received the echoed output + expect(rawData).toContain('Hello from subscription test') + + // Unsubscribe + const unsubscribePromise = new Promise((res) => { + managedTestClient.unsubscribedCallbacks.push((message) => { + if (message.sessionId === testSession.id) { + res(message) + } + }) + }) + managedTestClient.send({ + type: 'unsubscribe', + sessionId: testSession.id, + }) + await unsubscribePromise + }, 500) + + it('should handle multiple subscription states correctly', async () => { + await using managedTestClient = await ManagedTestClient.create(managedTestServer) + // Test that demonstrates the subscription system tracks client state properly + // This is important because the UI relies on proper subscription management + const errors: CustomError[] = [] + managedTestClient.errorCallbacks.push((message) => { + errors.push(message.error) + }) + + const session1 = manager.spawn({ + command: 'bash', + args: [], + description: 'Session 1', + parentSessionId: crypto.randomUUID(), + }) + + const session2 = manager.spawn({ + command: 'bash', + args: [], + description: 'Session 2', + parentSessionId: crypto.randomUUID(), + }) + + const subscribePromise1 = new Promise((res) => { + managedTestClient.subscribedCallbacks.push((message) => { + if (message.sessionId === session1.id) { + res(message) + } + }) + }) + + const subscribePromise2 = new Promise((res) => { + managedTestClient.subscribedCallbacks.push((message) => { + if (message.sessionId === session2.id) { + res(message) + } + }) + }) + + // Subscribe to session1 + managedTestClient.send({ + type: 'subscribe', + sessionId: session1.id, + }) + // Subscribe to session2 + managedTestClient.send({ + type: 'subscribe', + sessionId: session2.id, + }) + await Promise.all([subscribePromise1, subscribePromise2]) + + const unsubscribePromise1 = new Promise((res) => { + managedTestClient.unsubscribedCallbacks.push((message) => { + if (message.sessionId === session1.id) { + res(message) + } + }) + }) + + // Unsubscribe from session1 + managedTestClient.send({ + type: 'unsubscribe', + sessionId: session1.id, + }) + await unsubscribePromise1 + + // Check no errors occurred + expect(errors.length).toBe(0) + + // This demonstrates that the WebSocket server correctly manages + // multiple subscriptions per client, which is essential for the UI + // to properly track counter state for different sessions. + // Integration test failures were DOM-related, not subscription logic issues. + }, 200) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index be3d138..0796cde 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { // Environment setup & latest features "lib": ["ESNext", "DOM"], @@ -21,9 +22,9 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + // Stricter flags for better code quality + "noUnusedLocals": true, + "noUnusedParameters": true, "noPropertyAccessFromIndexSignature": false } } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a519508 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + root: 'src/web/client', + build: { + outDir: '../../../dist/web', + emptyOutDir: true, + minify: process.env.NODE_ENV === 'test' ? false : 'esbuild', // Enable minification for production + }, + server: { + port: 3000, + host: true, + }, +})