Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ name: TypeScript CI

on:
push:
branches: [main, "feat/ts-migration**"]
Comment thread
kartikloops marked this conversation as resolved.
branches: [main, "feat/ts-migration**", "feat/ts-integration-tests"]
paths: ["typescript/**"]
pull_request:
branches: [main, "feat/ts-migration**"]
branches: [main, "feat/ts-migration**", "feat/ts-integration-tests"]
paths: ["typescript/**"]

jobs:
Expand All @@ -25,3 +25,5 @@ jobs:
- run: npm run typecheck
- run: npm run lint
- run: npm run test
- run: npm run test:integration
- run: npm run test:performance
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ test-performance: docker-test-build
@echo "Running performance tests (benchmarks, memory, stability)..."
@docker run --rm --init $(DOCKER_TEST_IMAGE) uv run pytest tests/performance/

# TypeScript test targets (no Docker required - use bash/cat/echo from host)
.PHONY: test-ts-integration
test-ts-integration:
@echo "Running TypeScript integration tests..."
@cd typescript && npm run test:integration

.PHONY: test-ts-performance
test-ts-performance:
@echo "Running TypeScript performance tests..."
@cd typescript && npm run test:performance

.PHONY: test-ts-all
test-ts-all:
@echo "Running all TypeScript tests (unit + integration + performance)..."
@cd typescript && npm run test:all

.PHONY: test-coverage
test-coverage: docker-test-build
@echo "Running tests with coverage analysis..."
Expand Down
207 changes: 207 additions & 0 deletions typescript/__tests__/integration/pty_integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as os from "node:os";
import * as fs from "node:fs";
import * as path from "node:path";
import { PtyHandler } from "../../src/interactive/pty_handler.js";
import { PTYError } from "../../src/interactive/models.js";
import { Settings } from "../../src/config/settings.js";

const SETTINGS = new Settings({ ENABLE_COMMAND_VALIDATION: false });

function makeCollector(): { chunks: string[]; output: () => string } {
const chunks: string[] = [];
return { chunks, output: () => chunks.join("") };
}

async function waitUntil(check: () => boolean, deadlineMs: number, intervalMs = 50): Promise<boolean> {
const deadline = Date.now() + deadlineMs;
while (Date.now() < deadline) {
if (check()) return true;
await new Promise<void>((r) => setTimeout(r, intervalMs));
}
return check();
}

describe("PTY Integration", () => {
let handler: PtyHandler;

beforeEach(() => {
handler = new PtyHandler(SETTINGS);
});

afterEach(async () => {
await handler.cleanup();
});

it("basic echo command", async () => {
const { chunks, output } = makeCollector();
await handler.createSession(["echo", "hello world"], undefined, undefined, (d) => chunks.push(d));
const code = await handler.waitForExit(5000);
expect(code).toBe(0);
expect(output()).toContain("hello world");
});

it("interactive input/output via cat", async () => {
const { chunks, output } = makeCollector();
await handler.createSession(["cat"], undefined, undefined, (d) => chunks.push(d));
expect(handler.isProcessAlive()).toBe(true);

handler.writeInput("test line\n");

const found = await waitUntil(() => output().includes("test line"), 5000);
expect(found).toBe(true);
expect(output()).toContain("test line");

await handler.terminateProcess(false);
expect(handler.isProcessAlive()).toBe(false);
});

it("multi-line output from bash", async () => {
const { chunks, output } = makeCollector();
await handler.createSession(
["bash", "-c", "echo line1; echo line2; echo line3"],
undefined,
undefined,
(d) => chunks.push(d),
);
const code = await handler.waitForExit(5000);
expect(code).toBe(0);
expect(output()).toContain("line1");
expect(output()).toContain("line2");
expect(output()).toContain("line3");
});

it("process lifecycle: spawn, verify alive, terminate, cleanup idempotent", async () => {
await handler.createSession(["sleep", "10"]);
expect(handler.isProcessAlive()).toBe(true);

await handler.terminateProcess(false);
expect(handler.isProcessAlive()).toBe(false);

await expect(handler.cleanup()).resolves.not.toThrow();
await expect(handler.cleanup()).resolves.not.toThrow();
});

it("error handling: invalid command throws PTYError or exits non-zero", async () => {
// node-pty behavior is platform-specific: on Linux spawn() throws synchronously
// for a missing executable; on macOS the process entry is created and onExit
// fires immediately with a non-zero code.
try {
await handler.createSession(["/nonexistent/command_xyz"]);
const code = await handler.waitForExit(2000);
expect(code).not.toBeNull();
expect(code).not.toBe(0);
} catch (e) {
expect(e).toBeInstanceOf(PTYError);
}
});

it("concurrent read/write via cat", async () => {
const { chunks, output } = makeCollector();
await handler.createSession(["cat"], undefined, undefined, (d) => chunks.push(d));

const lines = ["concurrent line 0", "concurrent line 1", "concurrent line 2"];

const writer = (async () => {
for (const line of lines) {
handler.writeInput(`${line}\n`);
await new Promise<void>((r) => setTimeout(r, 50));
}
await handler.terminateProcess(false);
})();

const reader = (async () => {
const found = await waitUntil(
() => lines.every((l) => output().includes(l)),
8000,
);
return found;
})();

const [, allFound] = await Promise.all([writer, reader]);
expect(allFound).toBe(true);
for (const line of lines) {
expect(output()).toContain(line);
}
});

it("environment variable and working directory", async () => {
const tmpDir = os.tmpdir();
const tmpFile = path.join(tmpDir, `pty_test_${Date.now()}.txt`);
fs.writeFileSync(tmpFile, "");
const filename = path.basename(tmpFile);

try {
const { chunks, output } = makeCollector();
await handler.createSession(
["bash", "-c", `echo $TEST_VAR; ls ${filename}`],
{ TEST_VAR: "env_test_value" },
tmpDir,
(d) => chunks.push(d),
);
const code = await handler.waitForExit(5000);
expect(code).toBe(0);
expect(output()).toContain("env_test_value");
expect(output()).toContain(filename);
} finally {
fs.unlinkSync(tmpFile);
}
});

it("large output handling: 100-line bash loop", async () => {
const { chunks, output } = makeCollector();
await handler.createSession(
["bash", "-c", 'for i in $(seq 1 100); do echo "This is line $i with some extra text"; done'],
undefined,
undefined,
(d) => chunks.push(d),
);
const code = await handler.waitForExit(10000);
expect(code).toBe(0);
expect(output().length).toBeGreaterThan(1000);
expect(output()).toContain("line 1");
expect(output()).toContain("line 100");
});

it("timeout behavior: waitForExit returns null before process exits", async () => {
await handler.createSession(["sleep", "10"]);

const result = await handler.waitForExit(100);
expect(result).toBeNull();
expect(handler.isProcessAlive()).toBe(true);

await handler.terminateProcess(true);
await waitUntil(() => !handler.isProcessAlive(), 3000);
expect(handler.isProcessAlive()).toBe(false);
});

it("sequential sessions: two independent sessions", async () => {
const handler1 = new PtyHandler(SETTINGS);
const collector1 = makeCollector();
await handler1.createSession(
["echo", "first_session_output"],
undefined,
undefined,
(d) => collector1.chunks.push(d),
);
const code1 = await handler1.waitForExit(5000);
await handler1.cleanup();

expect(code1).toBe(0);
expect(collector1.output()).toContain("first_session_output");

const collector2 = makeCollector();
await handler.createSession(
["echo", "second_session_output"],
undefined,
undefined,
(d) => collector2.chunks.push(d),
);
const code2 = await handler.waitForExit(5000);

expect(code2).toBe(0);
expect(collector2.output()).toContain("second_session_output");
expect(collector1.output()).not.toContain("second_session_output");
expect(collector2.output()).not.toContain("first_session_output");
});
});
2 changes: 1 addition & 1 deletion typescript/__tests__/interactive/pty_handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe("PtyHandler", () => {
await expect(handler.createSession(["openroad"])).rejects.toThrow(
"could not be started",
);
await expect(handler.createSession(["openroad"])).rejects.toThrow("PATH=");
await expect(handler.createSession(["openroad"])).rejects.toThrow("PATH is");
await expect(handler.createSession(["openroad"])).rejects.toThrow("spawn-helper");
});

Expand Down
Loading
Loading