Merge branch 'main' into fix/electron-node-path-finder-launch-v2

This commit is contained in:
Test User
2025-12-22 13:26:08 -05:00
620 changed files with 31532 additions and 24476 deletions

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
getAutomakerDir,
getFeaturesDir,
@@ -18,18 +18,18 @@ import {
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
} from "../src/paths";
} from '../src/paths';
describe("paths.ts", () => {
describe('paths.ts', () => {
let tempDir: string;
let projectPath: string;
let dataDir: string;
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "platform-paths-test-"));
projectPath = path.join(tempDir, "test-project");
dataDir = path.join(tempDir, "user-data");
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'platform-paths-test-'));
projectPath = path.join(tempDir, 'test-project');
dataDir = path.join(tempDir, 'user-data');
await fs.mkdir(projectPath, { recursive: true });
});
@@ -42,98 +42,88 @@ describe("paths.ts", () => {
}
});
describe("Project-level path construction", () => {
it("should return automaker directory path", () => {
describe('Project-level path construction', () => {
it('should return automaker directory path', () => {
const result = getAutomakerDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker"));
expect(result).toBe(path.join(projectPath, '.automaker'));
});
it("should return features directory path", () => {
it('should return features directory path', () => {
const result = getFeaturesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "features"));
expect(result).toBe(path.join(projectPath, '.automaker', 'features'));
});
it("should return feature directory path", () => {
const featureId = "auth-feature";
it('should return feature directory path', () => {
const featureId = 'auth-feature';
const result = getFeatureDir(projectPath, featureId);
expect(result).toBe(
path.join(projectPath, ".automaker", "features", featureId)
);
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId));
});
it("should return feature images directory path", () => {
const featureId = "auth-feature";
it('should return feature images directory path', () => {
const featureId = 'auth-feature';
const result = getFeatureImagesDir(projectPath, featureId);
expect(result).toBe(
path.join(projectPath, ".automaker", "features", featureId, "images")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId, 'images'));
});
it("should return board directory path", () => {
it('should return board directory path', () => {
const result = getBoardDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "board"));
expect(result).toBe(path.join(projectPath, '.automaker', 'board'));
});
it("should return images directory path", () => {
it('should return images directory path', () => {
const result = getImagesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "images"));
expect(result).toBe(path.join(projectPath, '.automaker', 'images'));
});
it("should return context directory path", () => {
it('should return context directory path', () => {
const result = getContextDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "context"));
expect(result).toBe(path.join(projectPath, '.automaker', 'context'));
});
it("should return worktrees directory path", () => {
it('should return worktrees directory path', () => {
const result = getWorktreesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "worktrees"));
expect(result).toBe(path.join(projectPath, '.automaker', 'worktrees'));
});
it("should return app spec file path", () => {
it('should return app spec file path', () => {
const result = getAppSpecPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "app_spec.txt")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'app_spec.txt'));
});
it("should return branch tracking file path", () => {
it('should return branch tracking file path', () => {
const result = getBranchTrackingPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "active-branches.json")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'active-branches.json'));
});
it("should return project settings file path", () => {
it('should return project settings file path', () => {
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
});
});
describe("Global settings path construction", () => {
it("should return global settings path", () => {
describe('Global settings path construction', () => {
it('should return global settings path', () => {
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
expect(result).toBe(path.join(dataDir, 'settings.json'));
});
it("should return credentials path", () => {
it('should return credentials path', () => {
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
expect(result).toBe(path.join(dataDir, 'credentials.json'));
});
});
describe("Directory creation", () => {
it("should create automaker directory", async () => {
describe('Directory creation', () => {
it('should create automaker directory', async () => {
const automakerDir = await ensureAutomakerDir(projectPath);
expect(automakerDir).toBe(path.join(projectPath, ".automaker"));
expect(automakerDir).toBe(path.join(projectPath, '.automaker'));
const stats = await fs.stat(automakerDir);
expect(stats.isDirectory()).toBe(true);
});
it("should be idempotent when creating automaker directory", async () => {
it('should be idempotent when creating automaker directory', async () => {
// Create directory first time
const firstResult = await ensureAutomakerDir(projectPath);
@@ -146,7 +136,7 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should create data directory", async () => {
it('should create data directory', async () => {
const result = await ensureDataDir(dataDir);
expect(result).toBe(dataDir);
@@ -155,7 +145,7 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should be idempotent when creating data directory", async () => {
it('should be idempotent when creating data directory', async () => {
// Create directory first time
const firstResult = await ensureDataDir(dataDir);
@@ -168,13 +158,8 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should create nested directories recursively", async () => {
const deepProjectPath = path.join(
tempDir,
"nested",
"deep",
"project"
);
it('should create nested directories recursively', async () => {
const deepProjectPath = path.join(tempDir, 'nested', 'deep', 'project');
await fs.mkdir(deepProjectPath, { recursive: true });
const automakerDir = await ensureAutomakerDir(deepProjectPath);
@@ -184,29 +169,29 @@ describe("paths.ts", () => {
});
});
describe("Path handling with special characters", () => {
it("should handle feature IDs with special characters", () => {
const featureId = "feature-with-dashes_and_underscores";
describe('Path handling with special characters', () => {
it('should handle feature IDs with special characters', () => {
const featureId = 'feature-with-dashes_and_underscores';
const result = getFeatureDir(projectPath, featureId);
expect(result).toContain(featureId);
});
it("should handle paths with spaces", () => {
const pathWithSpaces = path.join(tempDir, "path with spaces");
it('should handle paths with spaces', () => {
const pathWithSpaces = path.join(tempDir, 'path with spaces');
const result = getAutomakerDir(pathWithSpaces);
expect(result).toBe(path.join(pathWithSpaces, ".automaker"));
expect(result).toBe(path.join(pathWithSpaces, '.automaker'));
});
});
describe("Path relationships", () => {
it("should have feature dir as child of features dir", () => {
describe('Path relationships', () => {
it('should have feature dir as child of features dir', () => {
const featuresDir = getFeaturesDir(projectPath);
const featureDir = getFeatureDir(projectPath, "test-feature");
const featureDir = getFeatureDir(projectPath, 'test-feature');
expect(featureDir.startsWith(featuresDir)).toBe(true);
});
it("should have all project paths under automaker dir", () => {
it('should have all project paths under automaker dir', () => {
const automakerDir = getAutomakerDir(projectPath);
const paths = [
getFeaturesDir(projectPath),

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import path from "path";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
describe("security.ts", () => {
describe('security.ts', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
@@ -16,176 +16,162 @@ describe("security.ts", () => {
process.env = originalEnv;
});
describe("initAllowedPaths", () => {
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
describe('initAllowedPaths', () => {
it('should load ALLOWED_ROOT_DIRECTORY if set', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
delete process.env.DATA_DIR;
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve('/projects'));
});
it("should load DATA_DIR if set", async () => {
it('should load DATA_DIR if set', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "/data/directory";
process.env.DATA_DIR = '/data/directory';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/data/directory"));
expect(allowed).toContain(path.resolve('/data/directory'));
});
it("should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/app/data";
it('should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/app/data';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve("/app/data"));
expect(allowed).toContain(path.resolve('/projects'));
expect(allowed).toContain(path.resolve('/app/data'));
});
it("should handle missing environment variables gracefully", async () => {
it('should handle missing environment variables gracefully', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths } = await import("../src/security");
const { initAllowedPaths } = await import('../src/security');
expect(() => initAllowedPaths()).not.toThrow();
});
});
describe("isPathAllowed", () => {
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
describe('isPathAllowed', () => {
it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/allowed/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/subdir/file.txt")).toBe(true);
expect(isPathAllowed('/allowed/file.txt')).toBe(true);
expect(isPathAllowed('/allowed/subdir/file.txt')).toBe(true);
});
it("should deny paths outside ALLOWED_ROOT_DIRECTORY", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
it('should deny paths outside ALLOWED_ROOT_DIRECTORY', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/not-allowed/file.txt")).toBe(false);
expect(isPathAllowed("/etc/passwd")).toBe(false);
expect(isPathAllowed('/not-allowed/file.txt')).toBe(false);
expect(isPathAllowed('/etc/passwd')).toBe(false);
});
it("should always allow DATA_DIR paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/app/data";
it('should always allow DATA_DIR paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/app/data';
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// DATA_DIR paths are always allowed
expect(isPathAllowed("/app/data/settings.json")).toBe(true);
expect(isPathAllowed("/app/data/credentials.json")).toBe(true);
expect(isPathAllowed('/app/data/settings.json')).toBe(true);
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
});
it("should allow all paths when no restrictions configured", async () => {
it('should allow all paths when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/any/path")).toBe(true);
expect(isPathAllowed("/etc/passwd")).toBe(true);
expect(isPathAllowed('/any/path')).toBe(true);
expect(isPathAllowed('/etc/passwd')).toBe(true);
});
it("should allow all paths when only DATA_DIR is configured", async () => {
it('should allow all paths when only DATA_DIR is configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "/data";
process.env.DATA_DIR = '/data';
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// DATA_DIR should be allowed
expect(isPathAllowed("/data/file.txt")).toBe(true);
expect(isPathAllowed('/data/file.txt')).toBe(true);
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
expect(isPathAllowed("/any/path")).toBe(true);
expect(isPathAllowed('/any/path')).toBe(true);
});
});
describe("validatePath", () => {
it("should return resolved path for allowed paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
describe('validatePath', () => {
it('should return resolved path for allowed paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
const result = validatePath("/allowed/file.txt");
expect(result).toBe(path.resolve("/allowed/file.txt"));
const result = validatePath('/allowed/file.txt');
expect(result).toBe(path.resolve('/allowed/file.txt'));
});
it("should throw error for paths outside allowed directories", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
it('should throw error for paths outside allowed directories', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath, PathNotAllowedError } =
await import("../src/security");
await import('../src/security');
initAllowedPaths();
expect(() => validatePath("/not-allowed/file.txt")).toThrow(
PathNotAllowedError
);
expect(() => validatePath('/not-allowed/file.txt')).toThrow(PathNotAllowedError);
});
it("should resolve relative paths", async () => {
it('should resolve relative paths', async () => {
const cwd = process.cwd();
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
const result = validatePath("./file.txt");
expect(result).toBe(path.resolve(cwd, "./file.txt"));
const result = validatePath('./file.txt');
expect(result).toBe(path.resolve(cwd, './file.txt'));
});
it("should not throw when no restrictions configured", async () => {
it('should not throw when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
expect(() => validatePath("/any/path")).not.toThrow();
expect(() => validatePath('/any/path')).not.toThrow();
});
});
describe("getAllowedPaths", () => {
it("should return empty array when no paths configured", async () => {
describe('getAllowedPaths', () => {
it('should return empty array when no paths configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
@@ -193,58 +179,53 @@ describe("security.ts", () => {
expect(allowed).toHaveLength(0);
});
it("should return configured paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/data";
it('should return configured paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/data';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve("/data"));
expect(allowed).toContain(path.resolve('/projects'));
expect(allowed).toContain(path.resolve('/data'));
});
});
describe("getAllowedRootDirectory", () => {
it("should return the configured root directory", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
describe('getAllowedRootDirectory', () => {
it('should return the configured root directory', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
const { initAllowedPaths, getAllowedRootDirectory } =
await import("../src/security");
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
initAllowedPaths();
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
expect(getAllowedRootDirectory()).toBe(path.resolve('/projects'));
});
it("should return null when not configured", async () => {
it('should return null when not configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, getAllowedRootDirectory } =
await import("../src/security");
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
initAllowedPaths();
expect(getAllowedRootDirectory()).toBeNull();
});
});
describe("getDataDirectory", () => {
it("should return the configured data directory", async () => {
process.env.DATA_DIR = "/data";
describe('getDataDirectory', () => {
it('should return the configured data directory', async () => {
process.env.DATA_DIR = '/data';
const { initAllowedPaths, getDataDirectory } =
await import("../src/security");
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
initAllowedPaths();
expect(getDataDirectory()).toBe(path.resolve("/data"));
expect(getDataDirectory()).toBe(path.resolve('/data'));
});
it("should return null when not configured", async () => {
it('should return null when not configured', async () => {
delete process.env.DATA_DIR;
const { initAllowedPaths, getDataDirectory } =
await import("../src/security");
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
initAllowedPaths();
expect(getDataDirectory()).toBeNull();

View File

@@ -1,21 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
spawnJSONLProcess,
spawnProcess,
type SubprocessOptions,
} from "../src/subprocess";
import * as cp from "child_process";
import { EventEmitter } from "events";
import { Readable } from "stream";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { spawnJSONLProcess, spawnProcess, type SubprocessOptions } from '../src/subprocess';
import * as cp from 'child_process';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
vi.mock("child_process");
vi.mock('child_process');
/**
* Helper to collect all items from an async generator
*/
async function collectAsyncGenerator<T>(
generator: AsyncGenerator<T>
): Promise<T[]> {
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of generator) {
results.push(item);
@@ -23,7 +17,7 @@ async function collectAsyncGenerator<T>(
return results;
}
describe("subprocess.ts", () => {
describe('subprocess.ts', () => {
let consoleSpy: {
log: ReturnType<typeof vi.spyOn>;
error: ReturnType<typeof vi.spyOn>;
@@ -32,8 +26,8 @@ describe("subprocess.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
};
});
@@ -71,7 +65,7 @@ describe("subprocess.ts", () => {
// Emit stderr lines immediately
if (config.stderrLines) {
for (const line of config.stderrLines) {
stderr.emit("data", Buffer.from(line));
stderr.emit('data', Buffer.from(line));
}
}
@@ -79,7 +73,7 @@ describe("subprocess.ts", () => {
const emitLines = async () => {
if (config.stdoutLines) {
for (const line of config.stdoutLines) {
stdout.push(line + "\n");
stdout.push(line + '\n');
// Small delay to allow readline to process
await new Promise((resolve) => setImmediate(resolve));
}
@@ -90,15 +84,13 @@ describe("subprocess.ts", () => {
stdout.push(null); // End stdout
// Small delay before exit
await new Promise((resolve) =>
setTimeout(resolve, config.delayMs ?? 10)
);
await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10));
// Emit exit or error
if (config.error) {
mockProcess.emit("error", config.error);
mockProcess.emit('error', config.error);
} else {
mockProcess.emit("exit", config.exitCode ?? 0);
mockProcess.emit('exit', config.exitCode ?? 0);
}
};
@@ -108,14 +100,14 @@ describe("subprocess.ts", () => {
return mockProcess;
}
describe("spawnJSONLProcess", () => {
describe('spawnJSONLProcess', () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1", "arg2"],
cwd: "/test/dir",
command: 'test-command',
args: ['arg1', 'arg2'],
cwd: '/test/dir',
};
it("should yield parsed JSONL objects line by line", async () => {
it('should yield parsed JSONL objects line by line', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"start","id":1}',
@@ -131,19 +123,14 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "start", id: 1 });
expect(results[1]).toEqual({ type: "progress", value: 50 });
expect(results[2]).toEqual({ type: "complete", result: "success" });
expect(results[0]).toEqual({ type: 'start', id: 1 });
expect(results[1]).toEqual({ type: 'progress', value: 50 });
expect(results[2]).toEqual({ type: 'complete', result: 'success' });
});
it("should skip empty lines", async () => {
it('should skip empty lines', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"first"}',
"",
" ",
'{"type":"second"}',
],
stdoutLines: ['{"type":"first"}', '', ' ', '{"type":"second"}'],
exitCode: 0,
});
@@ -153,17 +140,13 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "first" });
expect(results[1]).toEqual({ type: "second" });
expect(results[0]).toEqual({ type: 'first' });
expect(results[1]).toEqual({ type: 'second' });
});
it("should yield error for malformed JSON and continue processing", async () => {
it('should yield error for malformed JSON and continue processing', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"valid"}',
"{invalid json}",
'{"type":"also_valid"}',
],
stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'],
exitCode: 0,
});
@@ -173,18 +156,18 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "valid" });
expect(results[0]).toEqual({ type: 'valid' });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Failed to parse output"),
type: 'error',
error: expect.stringContaining('Failed to parse output'),
});
expect(results[2]).toEqual({ type: "also_valid" });
expect(results[2]).toEqual({ type: 'also_valid' });
});
it("should collect stderr output", async () => {
it('should collect stderr output', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
stderrLines: ["Warning: something happened", "Error: critical issue"],
stderrLines: ['Warning: something happened', 'Error: critical issue'],
exitCode: 0,
});
@@ -194,17 +177,17 @@ describe("subprocess.ts", () => {
await collectAsyncGenerator(generator);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Warning: something happened")
expect.stringContaining('Warning: something happened')
);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Error: critical issue")
expect.stringContaining('Error: critical issue')
);
});
it("should yield error on non-zero exit code", async () => {
it('should yield error on non-zero exit code', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"started"}'],
stderrLines: ["Process failed with error"],
stderrLines: ['Process failed with error'],
exitCode: 1,
});
@@ -214,14 +197,14 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "started" });
expect(results[0]).toEqual({ type: 'started' });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Process failed with error"),
type: 'error',
error: expect.stringContaining('Process failed with error'),
});
});
it("should yield error with exit code when stderr is empty", async () => {
it('should yield error with exit code when stderr is empty', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
exitCode: 127,
@@ -234,14 +217,14 @@ describe("subprocess.ts", () => {
expect(results).toHaveLength(2);
expect(results[1]).toMatchObject({
type: "error",
error: "Process exited with code 127",
type: 'error',
error: 'Process exited with code 127',
});
});
it("should handle process spawn errors", async () => {
it('should handle process spawn errors', async () => {
const mockProcess = createMockProcess({
error: new Error("Command not found"),
error: new Error('Command not found'),
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
@@ -254,7 +237,7 @@ describe("subprocess.ts", () => {
expect(results).toEqual([]);
});
it("should kill process on AbortController signal", async () => {
it('should kill process on AbortController signal', async () => {
const abortController = new AbortController();
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"start"}'],
@@ -277,53 +260,51 @@ describe("subprocess.ts", () => {
await promise;
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Abort signal received")
);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Abort signal received'));
});
it("should spawn process with correct arguments", async () => {
it('should spawn process with correct arguments', async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "my-command",
args: ["--flag", "value"],
cwd: "/work/dir",
env: { CUSTOM_VAR: "test" },
command: 'my-command',
args: ['--flag', 'value'],
cwd: '/work/dir',
env: { CUSTOM_VAR: 'test' },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
cwd: "/work/dir",
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
stdio: ["ignore", "pipe", "pipe"],
expect(cp.spawn).toHaveBeenCalledWith('my-command', ['--flag', 'value'], {
cwd: '/work/dir',
env: expect.objectContaining({ CUSTOM_VAR: 'test' }),
stdio: ['ignore', 'pipe', 'pipe'],
});
});
it("should merge env with process.env", async () => {
it('should merge env with process.env', async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "test",
command: 'test',
args: [],
cwd: "/test",
env: { CUSTOM: "value" },
cwd: '/test',
env: { CUSTOM: 'value' },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith(
"test",
'test',
[],
expect.objectContaining({
env: expect.objectContaining({
CUSTOM: "value",
CUSTOM: 'value',
// Should also include existing process.env
NODE_ENV: process.env.NODE_ENV,
}),
@@ -331,9 +312,9 @@ describe("subprocess.ts", () => {
);
});
it("should handle complex JSON objects", async () => {
it('should handle complex JSON objects', async () => {
const complexObject = {
type: "complex",
type: 'complex',
nested: { deep: { value: [1, 2, 3] } },
array: [{ id: 1 }, { id: 2 }],
string: 'with "quotes" and \\backslashes',
@@ -354,14 +335,14 @@ describe("subprocess.ts", () => {
});
});
describe("spawnProcess", () => {
describe('spawnProcess', () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1"],
cwd: "/test",
command: 'test-command',
args: ['arg1'],
cwd: '/test',
};
it("should collect stdout and stderr", async () => {
it('should collect stdout and stderr', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -377,25 +358,25 @@ describe("subprocess.ts", () => {
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
stdout.push("line 1\n");
stdout.push("line 2\n");
stdout.push('line 1\n');
stdout.push('line 2\n');
stdout.push(null);
stderr.push("error 1\n");
stderr.push("error 2\n");
stderr.push('error 1\n');
stderr.push('error 2\n');
stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("line 1\nline 2\n");
expect(result.stderr).toBe("error 1\nerror 2\n");
expect(result.stdout).toBe('line 1\nline 2\n');
expect(result.stderr).toBe('error 1\nerror 2\n');
expect(result.exitCode).toBe(0);
});
it("should return correct exit code", async () => {
it('should return correct exit code', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -410,7 +391,7 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 42);
mockProcess.emit('exit', 42);
}, 10);
const result = await spawnProcess(baseOptions);
@@ -418,7 +399,7 @@ describe("subprocess.ts", () => {
expect(result.exitCode).toBe(42);
});
it("should handle process errors", async () => {
it('should handle process errors', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -431,13 +412,13 @@ describe("subprocess.ts", () => {
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.emit("error", new Error("Spawn failed"));
mockProcess.emit('error', new Error('Spawn failed'));
}, 10);
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
await expect(spawnProcess(baseOptions)).rejects.toThrow('Spawn failed');
});
it("should handle AbortController signal", async () => {
it('should handle AbortController signal', async () => {
const abortController = new AbortController();
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
@@ -452,14 +433,14 @@ describe("subprocess.ts", () => {
setTimeout(() => abortController.abort(), 20);
await expect(
spawnProcess({ ...baseOptions, abortController })
).rejects.toThrow("Process aborted");
await expect(spawnProcess({ ...baseOptions, abortController })).rejects.toThrow(
'Process aborted'
);
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
it("should spawn with correct options", async () => {
it('should spawn with correct options', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -474,26 +455,26 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const options: SubprocessOptions = {
command: "my-cmd",
args: ["--verbose"],
cwd: "/my/dir",
env: { MY_VAR: "value" },
command: 'my-cmd',
args: ['--verbose'],
cwd: '/my/dir',
env: { MY_VAR: 'value' },
};
await spawnProcess(options);
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
cwd: "/my/dir",
env: expect.objectContaining({ MY_VAR: "value" }),
stdio: ["ignore", "pipe", "pipe"],
expect(cp.spawn).toHaveBeenCalledWith('my-cmd', ['--verbose'], {
cwd: '/my/dir',
env: expect.objectContaining({ MY_VAR: 'value' }),
stdio: ['ignore', 'pipe', 'pipe'],
});
});
it("should handle empty stdout and stderr", async () => {
it('should handle empty stdout and stderr', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -508,13 +489,13 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
expect(result.exitCode).toBe(0);
});
});