From 2816d4cf0071a09cfc864310c103ae8fdde5b618 Mon Sep 17 00:00:00 2001 From: Petr Brzek Date: Wed, 11 Feb 2026 22:06:54 +0100 Subject: [PATCH 1/4] Add npm run support, vitest testing, xterm.js terminal, and watch mode - container.run() API for executing shell commands (npm run, npm test, etc.) - Real vitest test execution using @vitest/expect assertions - Vitest watch mode with VFS file watchers and auto-rerun - Streaming container.run() with onStdout/onStderr/signal options - xterm.js terminal with ANSI color rendering in vitest demo - Save button and Cmd+S/Ctrl+S support in editor - npm scripts demo and vitest testing demo pages - Docs updates for streaming API and watch mode - E2E tests for npm scripts and vitest demos - Unit tests for npm run, vitest run, and vitest command Co-Authored-By: Claude Opus 4.6 --- README.md | 28 ++ docs/core-concepts.html | 57 ++- e2e/npm-command.spec.ts | 196 ++++++++++ e2e/npm-scripts-demo.spec.ts | 93 +++++ e2e/vitest-demo.spec.ts | 198 ++++++++++ examples/npm-scripts-demo.html | 127 +++++++ examples/vitest-demo.html | 97 +++++ index.html | 12 + package-lock.json | 21 +- package.json | 2 + src/index.ts | 45 +++ src/npm-scripts-demo-entry.ts | 120 +++++++ src/shims/child_process.ts | 634 ++++++++++++++++++++++++++++++++- src/types/package-json.ts | 1 + src/vitest-demo-entry.ts | 362 +++++++++++++++++++ tests/child_process.test.ts | 395 ++++++++++++++++++++ 16 files changed, 2384 insertions(+), 4 deletions(-) create mode 100644 e2e/npm-command.spec.ts create mode 100644 e2e/npm-scripts-demo.spec.ts create mode 100644 e2e/vitest-demo.spec.ts create mode 100644 examples/npm-scripts-demo.html create mode 100644 examples/vitest-demo.html create mode 100644 src/npm-scripts-demo-entry.ts create mode 100644 src/vitest-demo-entry.ts diff --git a/README.md b/README.md index cb8237e..fa205c8 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,34 @@ container.execute(` // Output: Hello world ``` +### Running Shell Commands + +```typescript +import { createContainer } from 'almostnode'; + +const container = createContainer(); + +// Write a package.json with scripts +container.vfs.writeFileSync('/package.json', JSON.stringify({ + name: 'my-app', + scripts: { + build: 'echo Building...', + test: 'echo Tests passed!' + } +})); + +// Run shell commands directly +const result = await container.run('npm run build'); +console.log(result.stdout); // "Building..." + +await container.run('npm test'); +await container.run('echo hello && echo world'); +await container.run('ls /'); +``` + +Supported npm commands: `npm run + + diff --git a/examples/vitest-demo.html b/examples/vitest-demo.html new file mode 100644 index 0000000..0ae7135 --- /dev/null +++ b/examples/vitest-demo.html @@ -0,0 +1,97 @@ + + + + + + Vitest Testing Demo — almostnode + + + + +
+ ← demos + / + + / + Vitest Testing + vitest · npm run test +
+ +
+
+
+ Editor +
+ +
+ Initializing... +
+
+
+
utils.js
+
utils.test.js
+
package.json
+
+ +
+ +
+
+ Terminal + +
+
+
+
+ + + + diff --git a/index.html b/index.html index 57e623e..0cd0179 100644 --- a/index.html +++ b/index.html @@ -1350,6 +1350,18 @@

Express Server

express · npm install + +

npm Scripts

+

Interactive terminal to run package.json scripts with npm run, lifecycle hooks, and bash.

+ npm run · bash · terminal +
+ + +

Vitest Testing

+

Run real vitest unit tests in the browser with npm run test — using @vitest/expect assertions.

+ vitest · npm run test +
+ diff --git a/package-lock.json b/package-lock.json index f8e6d90..0e0ae43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "almostnode", - "version": "0.2.6", + "version": "0.2.11", "license": "MIT", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "brotli": "^1.3.3", @@ -1467,6 +1469,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", diff --git a/package.json b/package.json index 900923c..3c3c165 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "prepublishOnly": "npm run test:run && npm run build:publish" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "brotli": "^1.3.3", diff --git a/src/index.ts b/src/index.ts index 88bf03b..4f6478c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,23 @@ import { VirtualFS } from './virtual-fs'; import { Runtime, RuntimeOptions } from './runtime'; import { PackageManager } from './npm'; import { ServerBridge, getServerBridge } from './server-bridge'; +import { exec as cpExec, setStreamingCallbacks, clearStreamingCallbacks } from './shims/child_process'; + +export interface RunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface RunOptions { + cwd?: string; + /** Callback for streaming stdout chunks as they arrive (for long-running commands like vitest watch) */ + onStdout?: (data: string) => void; + /** Callback for streaming stderr chunks as they arrive */ + onStderr?: (data: string) => void; + /** AbortSignal to cancel long-running commands */ + signal?: AbortSignal; +} export interface ContainerOptions extends RuntimeOptions { baseUrl?: string; @@ -76,6 +93,7 @@ export function createContainer(options?: ContainerOptions): { serverBridge: ServerBridge; execute: (code: string, filename?: string) => { exports: unknown }; runFile: (filename: string) => { exports: unknown }; + run: (command: string, options?: RunOptions) => Promise; createREPL: () => { eval: (code: string) => unknown }; on: (event: string, listener: (...args: unknown[]) => void) => void; } { @@ -94,6 +112,33 @@ export function createContainer(options?: ContainerOptions): { serverBridge, execute: (code: string, filename?: string) => runtime.execute(code, filename), runFile: (filename: string) => runtime.runFile(filename), + run: (command: string, runOptions?: RunOptions): Promise => { + // If signal is already aborted, resolve immediately + if (runOptions?.signal?.aborted) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 130 }); + } + + // Set streaming callbacks for long-running commands (e.g. vitest watch) + const hasStreaming = runOptions?.onStdout || runOptions?.onStderr || runOptions?.signal; + if (hasStreaming) { + setStreamingCallbacks({ + onStdout: runOptions?.onStdout, + onStderr: runOptions?.onStderr, + signal: runOptions?.signal, + }); + } + + return new Promise((resolve) => { + cpExec(command, { cwd: runOptions?.cwd }, (error, stdout, stderr) => { + if (hasStreaming) clearStreamingCallbacks(); + resolve({ + stdout: String(stdout), + stderr: String(stderr), + exitCode: error ? ((error as any).code ?? 1) : 0, + }); + }); + }); + }, createREPL: () => runtime.createREPL(), on: (event: string, listener: (...args: unknown[]) => void) => { serverBridge.on(event, listener); diff --git a/src/npm-scripts-demo-entry.ts b/src/npm-scripts-demo-entry.ts new file mode 100644 index 0000000..06d6f66 --- /dev/null +++ b/src/npm-scripts-demo-entry.ts @@ -0,0 +1,120 @@ +/** + * npm Scripts Demo — Entry Point + * Interactive terminal for running npm scripts and bash commands + */ + +import { createContainer } from './index'; + +// DOM elements +const pkgEditor = document.getElementById('pkgEditor') as HTMLTextAreaElement; +const terminalOutput = document.getElementById('terminalOutput') as HTMLDivElement; +const terminalInput = document.getElementById('terminalInput') as HTMLInputElement; +const statusEl = document.getElementById('status') as HTMLSpanElement; + +// State +const commandHistory: string[] = []; +let historyIndex = -1; +let isRunning = false; + +// Create the container +const container = createContainer(); + +// Default server.js for "npm start" +container.vfs.writeFileSync('/server.js', `console.log('Server starting on port 3000...'); +console.log('Ready to accept connections'); +`); + +// Write initial package.json +syncPackageJson(); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function appendToTerminal(text: string, className: string = 'stdout') { + const span = document.createElement('span'); + span.className = className; + span.innerHTML = escapeHtml(text); + if (!text.endsWith('\n')) span.innerHTML += '\n'; + terminalOutput.appendChild(span); + terminalOutput.scrollTop = terminalOutput.scrollHeight; +} + +function syncPackageJson() { + try { + // Validate JSON before writing + JSON.parse(pkgEditor.value); + container.vfs.writeFileSync('/package.json', pkgEditor.value); + } catch { + // Invalid JSON — skip sync, will error on npm run + } +} + +async function executeCommand(command: string) { + if (!command.trim()) return; + if (isRunning) return; + + isRunning = true; + terminalInput.disabled = true; + statusEl.textContent = 'Running...'; + + // Add to history + commandHistory.push(command); + historyIndex = commandHistory.length; + + // Show the command + appendToTerminal(`$ ${command}`, 'cmd'); + + // Sync package.json from editor to VFS + syncPackageJson(); + + try { + const result = await container.run(command); + if (result.stdout) appendToTerminal(result.stdout, 'stdout'); + if (result.stderr) appendToTerminal(result.stderr, 'stderr'); + if (result.exitCode !== 0) { + appendToTerminal(`exit code: ${result.exitCode}`, 'dim'); + } + } catch (error) { + appendToTerminal(`Error: ${error}`, 'stderr'); + } + + isRunning = false; + terminalInput.disabled = false; + terminalInput.focus(); + statusEl.textContent = 'Ready'; +} + +// Terminal input handling +terminalInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const command = terminalInput.value.trim(); + terminalInput.value = ''; + executeCommand(command); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + terminalInput.value = commandHistory[historyIndex]; + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex < commandHistory.length - 1) { + historyIndex++; + terminalInput.value = commandHistory[historyIndex]; + } else { + historyIndex = commandHistory.length; + terminalInput.value = ''; + } + } +}); + +// Show welcome message +appendToTerminal('almostnode npm scripts demo', 'info'); +appendToTerminal('Type a command below, e.g. npm run build\n', 'dim'); + +// Focus the input +terminalInput.focus(); diff --git a/src/shims/child_process.ts b/src/shims/child_process.ts index 3cbf112..8340eca 100644 --- a/src/shims/child_process.ts +++ b/src/shims/child_process.ts @@ -22,17 +22,48 @@ if (typeof globalThis.process === 'undefined') { } import { Bash, defineCommand } from 'just-bash'; +import type { CommandContext, ExecResult as JustBashExecResult } from 'just-bash'; import { EventEmitter } from './events'; import { Readable, Writable, Buffer } from './stream'; import type { VirtualFS } from '../virtual-fs'; import { VirtualFSAdapter } from './vfs-adapter'; import { Runtime } from '../runtime'; +import type { PackageJson } from '../types/package-json'; // Singleton bash instance - uses VFS adapter for two-way file sync let bashInstance: Bash | null = null; let vfsAdapter: VirtualFSAdapter | null = null; let currentVfs: VirtualFS | null = null; +// Module-level streaming callbacks for long-running commands (e.g. vitest watch) +// Set by container.run() before calling exec, cleared after +let _streamStdout: ((data: string) => void) | null = null; +let _streamStderr: ((data: string) => void) | null = null; +let _abortSignal: AbortSignal | null = null; + +/** + * Set streaming callbacks for the next command execution. + * Used by container.run() to enable streaming output from custom commands. + */ +export function setStreamingCallbacks(opts: { + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + signal?: AbortSignal; +}): void { + _streamStdout = opts.onStdout || null; + _streamStderr = opts.onStderr || null; + _abortSignal = opts.signal || null; +} + +/** + * Clear streaming callbacks after command execution. + */ +export function clearStreamingCallbacks(): void { + _streamStdout = null; + _streamStderr = null; + _abortSignal = null; +} + /** * Initialize the child_process shim with a VirtualFS instance * Creates a single Bash instance with VirtualFSAdapter for efficient file access @@ -154,6 +185,400 @@ export function initChildProcess(vfs: VirtualFS): void { } }); + // Create custom 'npm' command that runs scripts from package.json + const npmCommand = defineCommand('npm', async (args, ctx) => { + if (!currentVfs) { + return { stdout: '', stderr: 'VFS not initialized\n', exitCode: 1 }; + } + + const subcommand = args[0]; + + if (!subcommand || subcommand === 'help' || subcommand === '--help') { + return { + stdout: 'Usage: npm \n\nCommands:\n run