Skip to content
Open
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
155 changes: 155 additions & 0 deletions src/core/dev/codezip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ProjectRuntime } from "../project/schema";
import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types";
import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io";
import { CodeZipDevRunner } from "./codezip";

type ProcessCall = {
command: string[];
options: StreamProcessOptions;
};

const tempDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

function runtime(
overrides: { entrypoint?: string; protocol?: ProjectRuntime["protocol"] } = {},
): ProjectRuntime {
return {
name: "hello_world",
build: "CodeZip",
entrypoint: "main.py",
codeLocation: "app/hello-world",
protocol: "HTTP",
...overrides,
} as ProjectRuntime;
}

async function projectRoot(withNodeModules = false): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "agentcore-codezip-"));
tempDirectories.push(root);
await mkdir(join(root, "app", "hello-world"), { recursive: true });
if (withNodeModules) {
await mkdir(join(root, "app", "hello-world", "node_modules"));
}
return root;
}

function harness(output: ProcessEvent[] = []) {
const calls: ProcessCall[] = [];
const fakeStreamProcess: ProcessStreamer = async function* (command, options) {
calls.push({ command, options });
yield* output;
};
return {
calls,
runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess }),
};
}

function input(root: string, projectRuntime: ProjectRuntime): DevServerInput {
return {
runtime: projectRuntime,
projectRoot: root,
port: 9000,
env: { CUSTOM_ENV: "value" },
signal: new AbortController().signal,
};
}

async function collect(events: AsyncIterable<DevEvent>): Promise<DevEvent[]> {
const collected: DevEvent[] = [];
for await (const event of events) collected.push(event);
return collected;
}

describe("CodeZipDevRunner", () => {
test("rejects a missing runtime code directory", async () => {
const root = await mkdtemp(join(tmpdir(), "agentcore-codezip-"));
tempDirectories.push(root);

await expect(collect(harness().runner.run(input(root, runtime())))).rejects.toThrow(
/runtime code directory not found/,
);
});

test("runs HTTP Python entrypoints with uvicorn", async () => {
const root = await projectRoot();
const { calls, runner } = harness([{ type: "stdout", line: "server output" }]);
const events = await collect(
runner.run(input(root, runtime({ entrypoint: "src/main.py:application" }))),
);

expect(calls).toHaveLength(1);
expect(calls[0]?.command).toEqual([
"uv",
"run",
"uvicorn",
"src.main:application",
"--reload",
"--host",
"127.0.0.1",
"--port",
"9000",
]);
expect(calls[0]?.options).toMatchObject({
cwd: join(root, "app", "hello-world"),
env: { CUSTOM_ENV: "value", PORT: "9000", LOCAL_DEV: "1" },
});
expect(events).toEqual([
{ type: "status", message: "Starting development server" },
{ type: "stdout", line: "server output" },
]);
});

test.each(["MCP", "A2A", "AGUI"] as const)(
"runs %s Python entrypoints directly",
async (protocol) => {
const root = await projectRoot();
const { calls, runner } = harness();

await collect(runner.run(input(root, runtime({ protocol, entrypoint: "main.py:handler" }))));

expect(calls[0]?.command).toEqual(["uv", "run", "python", "main.py"]);
expect(calls[0]?.options.env?.FASTMCP_PORT).toBe(protocol === "MCP" ? "9000" : undefined);
},
);

test("installs missing Node dependencies before starting tsx", async () => {
const root = await projectRoot();
const { calls, runner } = harness([{ type: "stderr", line: "0 errors" }]);
const events = await collect(
runner.run(input(root, runtime({ entrypoint: "src/index.ts:handler" }))),
);

expect(calls.map(({ command }) => command)).toEqual([
["npm", "install"],
["npm", "exec", "--", "tsx", "watch", "src/index.ts"],
]);
expect(events).toEqual([
{ type: "status", message: "Installing Node dependencies with npm" },
{ type: "stderr", line: "0 errors" },
{ type: "status", message: "Starting development server" },
{ type: "stderr", line: "0 errors" },
]);
});

test("starts tsx directly when Node dependencies exist", async () => {
const root = await projectRoot(true);
const { calls, runner } = harness();

await collect(runner.run(input(root, runtime({ entrypoint: "index.js" }))));

expect(calls.map(({ command }) => command)).toEqual([
["npm", "exec", "--", "tsx", "watch", "index.js"],
]);
});
});
82 changes: 82 additions & 0 deletions src/core/dev/codezip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { InputValidationError } from "../../errors";
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io";

type CodeZipDevRunnerConfig = {
streamProcess?: ProcessStreamer;
};

export class CodeZipDevRunner implements DevRunner {
private readonly streamProcess: ProcessStreamer;

constructor(config: CodeZipDevRunnerConfig = {}) {
this.streamProcess = config.streamProcess ?? streamProcess;
}

public async *run(input: DevServerInput): AsyncGenerator<DevEvent, void> {
const directory = join(input.projectRoot, input.runtime.codeLocation);
if (!existsSync(directory)) {
throw new InputValidationError(`runtime code directory not found: ${directory}`);
}

const [entrypoint] = input.runtime.entrypoint.split(":");
if (!entrypoint!.endsWith(".py") && !existsSync(join(directory, "node_modules"))) {
yield { type: "status", message: "Installing Node dependencies with npm" };
yield* this.streamProcess(["npm", "install"], { cwd: directory, signal: input.signal });
}

yield { type: "status", message: "Starting development server" };
const process = commandForRuntime(entrypoint!, directory, input);
yield* this.streamProcess(process.command, process.options);
}
}

function commandForRuntime(
entrypoint: string,
directory: string,
input: DevServerInput,
): { command: string[]; options: StreamProcessOptions } {
const env: NodeJS.ProcessEnv = {
...process.env,
...input.env,
PORT: String(input.port),
LOCAL_DEV: "1",
};

if (input.runtime.protocol === "MCP") {
env.FASTMCP_PORT = String(input.port);
}

if (!entrypoint.endsWith(".py")) {
return {
command: ["npm", "exec", "--", "tsx", "watch", entrypoint],
options: { cwd: directory, env, signal: input.signal },
};
}

if ((input.runtime.protocol ?? "HTTP") !== "HTTP") {
return {
command: ["uv", "run", "python", entrypoint],
options: { cwd: directory, env, signal: input.signal },
};
}

const [, handler = "app"] = input.runtime.entrypoint.split(":");
const module = entrypoint.replace(/\.py$/, "").replaceAll("/", ".");
return {
command: [
"uv",
"run",
"uvicorn",
`${module}:${handler}`,
"--reload",
"--host",
"127.0.0.1",
"--port",
String(input.port),
],
options: { cwd: directory, env, signal: input.signal },
};
}
18 changes: 18 additions & 0 deletions src/handlers/project/dev/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ProjectRuntime } from "../../../core/project/schema";

export type DevEvent =
| { type: "status"; message: string }
| { type: "stdout"; line: string }
| { type: "stderr"; line: string };

export type DevServerInput = {
runtime: ProjectRuntime;
projectRoot: string;
port: number;
env?: Record<string, string>;
signal: AbortSignal;
};

export interface DevRunner {
run(input: DevServerInput): AsyncGenerator<DevEvent, void>;
}
107 changes: 107 additions & 0 deletions src/io/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
ProcessFailedError,
requireTool,
runProcess,
streamProcess,
toolAvailable,
type ProcessEvent,
} from "./exec";

// Scripts run from files rather than `node -e` one-liners: on win32 runProcess
Expand Down Expand Up @@ -73,3 +75,108 @@
).rejects.toBeInstanceOf(ProcessFailedError);
});
});

async function collect(events: AsyncIterable<ProcessEvent>): Promise<ProcessEvent[]> {
const collected: ProcessEvent[] = [];
for await (const event of events) collected.push(event);
return collected;
}

describe("streamProcess", () => {
test("yields complete lines with their source stream", async () => {
const streaming = await script(
"stream.js",
"console.log('one'); console.log('two'); console.error('0 errors')",
);

const events = await collect(streamProcess(["node", streaming], { cwd: process.cwd() }));

expect(events).toContainEqual({ type: "stdout", line: "one" });
expect(events).toContainEqual({ type: "stdout", line: "two" });
expect(events).toContainEqual({ type: "stderr", line: "0 errors" });
});

test("throws ProcessFailedError after yielding failure output", async () => {
const failing = await script("stream-fail.js", "console.error('boom'); process.exit(3)");
const iterator = streamProcess(["node", failing], { cwd: process.cwd() });

expect(await iterator.next()).toEqual({
done: false,
value: { type: "stderr", line: "boom" },
});
await expect(iterator.next()).rejects.toThrow(/exit code 3/);
});

test("throws ProcessFailedError when the executable cannot spawn", async () => {
await expect(
collect(streamProcess(["definitely-not-a-real-tool-xyz"], { cwd: process.cwd() })),
).rejects.toBeInstanceOf(ProcessFailedError);
});

test("aborts a running process", async () => {
const running = await script("running.js", "console.log('ready'); setInterval(() => {}, 1000)");
const controller = new AbortController();
const iterator = streamProcess(["node", running], {
cwd: process.cwd(),
signal: controller.signal,
});

expect(await iterator.next()).toEqual({
done: false,
value: { type: "stdout", line: "ready" },
});
controller.abort();

await expect(iterator.next()).rejects.toMatchObject({ name: "AbortError" });
});

test("stops the process when iteration ends early", async () => {
const running = await script(
"return.js",
"console.log('ready'); setInterval(() => console.log('tick'), 1000)",
);
const iterator = streamProcess(["node", running], { cwd: process.cwd() });

expect((await iterator.next()).value).toEqual({ type: "stdout", line: "ready" });
await expect(iterator.return(undefined)).resolves.toEqual({ done: true, value: undefined });
});

test("kills descendants that outlive the direct child", async () => {
if (process.platform === "win32") return;

const parent = await script(
"process-tree.js",
[
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'process.on(\"SIGTERM\", () => {}); setInterval(() => {}, 1000)'], { stdio: 'ignore' });",
"console.log(child.pid);",
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const controller = new AbortController();
const iterator = streamProcess(["node", parent], {
cwd: process.cwd(),
signal: controller.signal,
});
const first = await iterator.next();
const descendantPid = Number(first.value?.line);

try {
controller.abort();
await expect(iterator.next()).rejects.toMatchObject({ name: "AbortError" });
expect(processExists(descendantPid)).toBe(false);

Check failure on line 168 in src/io/exec.test.ts

View workflow job for this annotation

GitHub Actions / unit-test / Test (Linux)

error: expect(received).toBe(expected)

Expected: false Received: true at <anonymous> (/codebuild/output/src520700317/src/actions-runner/_work/agentcore-cli/agentcore-cli/src/io/exec.test.ts:168:44)
} finally {
if (processExists(descendantPid)) process.kill(descendantPid, "SIGKILL");
}
});
});

function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
Loading
Loading