From 3dce8ee4624179bc339b114325dbc27cdf6c0a64 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 9 Sep 2026 18:28:57 +0000 Subject: [PATCH 1/5] test: add initProject and inTempDirectory helpers to src/testing --- src/testing/fs.ts | 20 ++++++++++++++ src/testing/index.tsx | 2 ++ src/testing/projects.ts | 58 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/testing/fs.ts create mode 100644 src/testing/projects.ts diff --git a/src/testing/fs.ts b/src/testing/fs.ts new file mode 100644 index 000000000..cc67d322f --- /dev/null +++ b/src/testing/fs.ts @@ -0,0 +1,20 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +/** A temp directory and a handler that restores the cwd and removes it. */ +export type TempDirectory = { path: string; cleanup: () => Promise }; + +/** Creates a temp directory, cds into it, and returns its realpath plus a cleanup handler. */ +export async function inTempDirectory(prefix = "agentcore-project-"): Promise { + const originalCwd = process.cwd(); + const directory = await mkdtemp(join(tmpdir(), prefix)); + process.chdir(directory); + return { + path: process.cwd(), + cleanup: async () => { + process.chdir(originalCwd); + await rm(directory, { recursive: true, force: true }); + }, + }; +} diff --git a/src/testing/index.tsx b/src/testing/index.tsx index d99b7bcb7..39c3266fd 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -35,3 +35,5 @@ export { } from "./renderScreen"; export { createSilentLogger, assertLogsMatch, type LogQuery } from "./logging"; export { TestGlobalConfigAccessor } from "./globalConfig"; +export { inTempDirectory, type TempDirectory } from "./fs"; +export { initProject, type InitProjectOptions, type InitializedProject } from "./projects"; diff --git a/src/testing/projects.ts b/src/testing/projects.ts new file mode 100644 index 000000000..b89eec6b1 --- /dev/null +++ b/src/testing/projects.ts @@ -0,0 +1,58 @@ +import { join } from "node:path"; +import { createRootHandler, type RootHandlerConfig } from "../handlers"; +import type { Core } from "../handlers/types"; +import { inTempDirectory } from "./fs"; +import { createSilentLogger } from "./logging"; +import { testIO } from "./testIO"; +import { TestCoreClient } from "./TestCoreClient"; +import { TestGlobalConfigAccessor } from "./globalConfig"; + +export type InitProjectOptions = { + /** Project name passed to `project create`. */ + name?: string; + /** Extra flags appended to the `project create` command, e.g. `["--template", "empty"]`. */ + flags?: string[]; + /** Temp directory prefix, for recognizable paths while debugging. */ + prefix?: string; + /** Core injected into the root handler; defaults to a fresh {@link TestCoreClient}. */ + core?: Core; +} & Partial; + +/** A scaffolded project: its name, root path, and a handler that removes it and restores the cwd. */ +export type InitializedProject = { + projectName: string; + projectRoot: string; + cleanup: () => Promise; +}; + +/** Scaffolds a project with `project create` and cds into it so withProject resolves it. */ +export async function initProject(options: InitProjectOptions = {}): Promise { + const { + name = "TestProject", + flags = [], + prefix, + core = new TestCoreClient(), + ...config + } = options; + const { path, cleanup } = await inTempDirectory(prefix); + const root = createRootHandler(core, { + io: testIO().io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + ...config, + }); + await root.route([ + "node", + "agentcore", + "project", + "create", + "--name", + name, + ...flags, + "--skip-install", + "--skip-git", + ]); + const projectRoot = join(path, name); + process.chdir(projectRoot); + return { projectName: name, projectRoot, cleanup }; +} From 676235df0cba8fe3da813c0692f18f93249edd84 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 9 Sep 2026 18:28:57 +0000 Subject: [PATCH 2/5] test: dedupe project scaffolding onto shared testing helpers --- .../add/evaluator/code-based/index.test.ts | 66 +++---- .../evaluator/llm-as-a-judge/index.test.ts | 54 ++---- .../project/add/gateway-test-support.ts | 42 ++--- .../project/add/harness/index.test.ts | 56 +++--- src/handlers/project/add/memory/index.test.ts | 56 ++---- .../project/add/online-eval/index.test.ts | 53 ++---- .../project/add/online-insight/index.test.ts | 53 ++---- .../project/add/payment-test-support.ts | 39 ++-- .../project/add/runtime/index.test.ts | 77 ++++---- src/handlers/project/build/index.test.ts | 35 +--- .../project/buildDeploy.screen.test.tsx | 46 +---- .../project/create/create.screen.test.tsx | 54 +++--- src/handlers/project/deploy/index.test.ts | 78 +++----- src/handlers/project/export/harness.test.ts | 41 +---- src/handlers/project/invoke/index.test.tsx | 19 +- .../project/invoke/invoke.screen.test.tsx | 20 +- src/handlers/project/project.test.ts | 174 ++++++++++-------- src/handlers/project/remove/index.test.ts | 126 +++++++------ src/handlers/project/status/index.test.ts | 45 ++--- .../project/status/status.screen.test.tsx | 19 +- 20 files changed, 460 insertions(+), 693 deletions(-) diff --git a/src/handlers/project/add/evaluator/code-based/index.test.ts b/src/handlers/project/add/evaluator/code-based/index.test.ts index b21b1130d..87cc968ab 100644 --- a/src/handlers/project/add/evaluator/code-based/index.test.ts +++ b/src/handlers/project/add/evaluator/code-based/index.test.ts @@ -1,32 +1,18 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../../../testing"; import { InputValidationError } from "../../../../../errors"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-code-eval-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[]) { const io = testIO(); @@ -39,14 +25,6 @@ async function run(args: string[]) { return { io }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - const spec = (projectRoot: string) => Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); const evaluator = async (projectRoot: string, name: string) => @@ -54,7 +32,8 @@ const evaluator = async (projectRoot: string, name: string) => describe("project add evaluator code-based", () => { test("scaffolds managed evaluator code with an explicit timeout", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run([ "add", "evaluator", @@ -93,7 +72,8 @@ describe("project add evaluator code-based", () => { }); test("no lambda → managed stub with the default timeout", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "evaluator", "code-based", "--name", "custom_eval", "--level", "TOOL_CALL"]); expect((await evaluator(projectRoot, "custom_eval")).config.codeBased.managed).toMatchObject({ @@ -108,7 +88,8 @@ describe("project add evaluator code-based", () => { }); test("--lambda-arn → external config, no scaffold", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const arn = "arn:aws:lambda:us-west-2:123456789012:function:refund-policy"; await run([ "add", @@ -131,7 +112,8 @@ describe("project add evaluator code-based", () => { }); test("persists description, kms key, and tags", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const kms = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; await run([ "add", @@ -177,7 +159,8 @@ describe("project add evaluator code-based", () => { ["invalid --level", ["--name", "x", "--level", "NOPE"]], ["invalid --lambda-arn", ["--name", "x", "--level", "SESSION", "--lambda-arn", "not-an-arn"]], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "evaluator", "code-based", ...flags])).rejects.toBeInstanceOf( InputValidationError, ); @@ -187,7 +170,8 @@ describe("project add evaluator code-based", () => { ["--metric", "deepeval.FaithfulnessMetric"], ["--model", "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0"], ])("rejects removed %s before writing", async (removedFlag, value) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await expect( run([ "add", @@ -207,7 +191,8 @@ describe("project add evaluator code-based", () => { }); test("rejects a duplicate evaluator name", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const flags = [ "add", "evaluator", @@ -224,7 +209,8 @@ describe("project add evaluator code-based", () => { }); test("errors before writing when app/ already exists (cross-resource collision)", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const appDir = join(projectRoot, "app", "collide"); await mkdir(appDir, { recursive: true }); await Bun.write(join(appDir, "pyproject.toml"), "# pre-existing\n"); @@ -239,7 +225,8 @@ describe("project add evaluator code-based", () => { }); test("empty stub warns it returns Pass until implemented", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run([ "add", "evaluator", @@ -253,7 +240,8 @@ describe("project add evaluator code-based", () => { }); test("--json reports the empty stub guidance as a structured note", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run([ "add", "evaluator", @@ -272,7 +260,8 @@ describe("project add evaluator code-based", () => { }); test("external mode prints no stub note", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run([ "add", "evaluator", @@ -288,7 +277,8 @@ describe("project add evaluator code-based", () => { }); test("remove evaluator drops it from the spec", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "evaluator", "code-based", "--name", "gone", "--level", "SESSION"]); expect(await evaluator(projectRoot, "gone")).toBeDefined(); await run(["remove", "evaluator", "--name", "gone"]); diff --git a/src/handlers/project/add/evaluator/llm-as-a-judge/index.test.ts b/src/handlers/project/add/evaluator/llm-as-a-judge/index.test.ts index d7ec605da..0fbbb40f9 100644 --- a/src/handlers/project/add/evaluator/llm-as-a-judge/index.test.ts +++ b/src/handlers/project/add/evaluator/llm-as-a-judge/index.test.ts @@ -1,32 +1,18 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../../../testing"; import { DeserializationError, InputValidationError } from "../../../../../errors"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-evaluator-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -40,19 +26,12 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - const MODEL = "anthropic.claude-3-5-sonnet-20240620-v1:0"; describe("project add evaluator llm-as-a-judge", () => { test("writes a numerical preset evaluator into the spec", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run([ "add", "evaluator", @@ -86,7 +65,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("writes a categorical preset evaluator", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run([ "add", "evaluator", @@ -112,7 +92,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("reads instructions from a file:// source", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const instructionsPath = join(projectRoot, "instructions.txt"); await writeFile(instructionsPath, "Evaluate factual accuracy.\n"); @@ -138,7 +119,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("accepts an inline JSON rating scale on --rating-scale", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run([ "add", @@ -170,7 +152,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("persists description, kms key, and tags", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const kms = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; await run([ "add", @@ -204,7 +187,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("rejects a duplicate evaluator name", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const flags = [ "add", "evaluator", @@ -225,7 +209,8 @@ describe("project add evaluator llm-as-a-judge", () => { }); test("rejects when the existing spec is invalid", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const specPath = join(projectRoot, "agentcore", "agentcore.json"); const spec = await Bun.file(specPath).json(); spec.unknownField = "bad"; @@ -341,7 +326,8 @@ describe("project add evaluator llm-as-a-judge", () => { ["--name", "x", "--level", "SESSION", "--model", MODEL, "--instructions", "i"], ], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "evaluator", "llm-as-a-judge", ...flags])).rejects.toBeInstanceOf( InputValidationError, ); diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index ab7518007..5e1ce042b 100644 --- a/src/handlers/project/add/gateway-test-support.ts +++ b/src/handlers/project/add/gateway-test-support.ts @@ -1,9 +1,8 @@ -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -21,8 +20,7 @@ export async function writeProjectSpec(projectRoot: string, spec: unknown): Prom } export function createGatewayProjectTestHarness(directoryPrefix: string) { - const originalCwd = process.cwd(); - const tempDirectories: string[] = []; + const cleanups: Array<() => Promise> = []; async function run(args: string[], stdin?: string) { const io = testIO(); @@ -37,33 +35,25 @@ export function createGatewayProjectTestHarness(directoryPrefix: string) { } async function inProject(name = "TestProject"): Promise { - const directory = await mkdtemp(join(tmpdir(), `agentcore-${directoryPrefix}-`)); - tempDirectories.push(directory); - process.chdir(directory); - await run([ - "create", - "--name", + const { projectRoot, cleanup } = await initProject({ name, - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return process.cwd(); + flags: ["--template", "agent-python-minimal"], + prefix: `agentcore-${directoryPrefix}-`, + }); + cleanups.push(cleanup); + return projectRoot; } async function addGateway(name = "tools"): Promise { await run(["add", "gateway", "--name", name]); } - async function cleanup(): Promise { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); - } - - return { addGateway, cleanup, inProject, projectSpec, run, writeProjectSpec }; + return { + addGateway, + cleanup: () => Promise.all(cleanups.splice(0).map((cleanup) => cleanup())), + inProject, + projectSpec, + run, + writeProjectSpec, + }; } diff --git a/src/handlers/project/add/harness/index.test.ts b/src/handlers/project/add/harness/index.test.ts index b1e10aa9e..df7a9cc75 100644 --- a/src/handlers/project/add/harness/index.test.ts +++ b/src/handlers/project/add/harness/index.test.ts @@ -1,11 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -13,22 +12,8 @@ import { import { DeserializationError, InputValidationError } from "../../../../errors"; import { FsReadWriteJson, type ReadWriteJson } from "../../../../io"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-harness-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -42,14 +27,6 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - describe("project add harness", () => { const defaultModel = { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" }; @@ -434,7 +411,8 @@ describe("project add harness", () => { { maxIterations: 10, maxTokens: 4096, timeoutSeconds: 60 }, ], ])("%s", async (_label, flags, expected) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "harness", ...flags]); const harnessJson = await Bun.file(join(projectRoot, "app", "x", "harness.json")).json(); @@ -448,7 +426,8 @@ describe("project add harness", () => { }); test("--system-prompt overrides the default system-prompt.md", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "harness", "--name", "x", "--system-prompt", "You are a pirate."]); const prompt = await Bun.file(join(projectRoot, "app", "x", "system-prompt.md")).text(); @@ -459,7 +438,8 @@ describe("project add harness", () => { }); test("--dockerfile copies the file into the harness directory and stores the relative path", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const dockerfilePath = join(projectRoot, "Dockerfile"); await Bun.write(dockerfilePath, "FROM python:3.12-slim\nCOPY . /app\n"); @@ -474,7 +454,8 @@ describe("project add harness", () => { }); test("--dockerfile with VPC mode succeeds when vpcId is in network-config", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const dockerfilePath = join(projectRoot, "Dockerfile"); await Bun.write(dockerfilePath, "FROM python:3.12-slim\n"); @@ -505,7 +486,8 @@ describe("project add harness", () => { }); test("--dockerfile with VPC mode fails without vpcId in network-config", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const dockerfilePath = join(projectRoot, "Dockerfile"); await Bun.write(dockerfilePath, "FROM python:3.12-slim\n"); @@ -527,7 +509,8 @@ describe("project add harness", () => { }); test("rejects a duplicate harness name", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "harness", "--name", "x"]); await expect(run(["add", "harness", "--name", "x"])).rejects.toBeInstanceOf( InputValidationError, @@ -535,7 +518,8 @@ describe("project add harness", () => { }); test("cleans up scaffolded files when the spec write fails", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const logger = createSilentLogger(); const realJson = new FsReadWriteJson({ logger }); @@ -554,7 +538,8 @@ describe("project add harness", () => { }); test("rejects when the existing spec is invalid", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const specPath = join(projectRoot, "agentcore", "agentcore.json"); const spec = await Bun.file(specPath).json(); @@ -631,7 +616,8 @@ describe("project add harness", () => { ], ], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); }); diff --git a/src/handlers/project/add/memory/index.test.ts b/src/handlers/project/add/memory/index.test.ts index e218c020d..5efcee8b8 100644 --- a/src/handlers/project/add/memory/index.test.ts +++ b/src/handlers/project/add/memory/index.test.ts @@ -1,11 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -13,24 +12,8 @@ import { import { InputValidationError } from "../../../../errors"; import { MEMORY_DESCRIPTION_MAX_LENGTH } from "../../../../projectSchemas/memory"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-memory-")); - tempDirectories.push(directory); - // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var - // symlink), matching the paths the manager derives from process.cwd(). - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); // Ink writes cursor/erase sequences around each frame; the TTY assertions // below care about frame text, not terminal control. Built without a @@ -53,18 +36,10 @@ async function run(args: string[], opts?: { core?: TestCoreClient; isTTY?: boole return { io, core }; } -/** Scaffolds a project and cds into it so withProject resolves it. */ -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - describe("project add memory", () => { test("--json returns a structured project mutation result", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run(["add", "memory", "--name", "customer_memory", "--json"]); expect(JSON.parse(io.stdout())).toEqual({ @@ -78,7 +53,8 @@ describe("project add memory", () => { // The same progress driver create, build, and deploy use: a TTY gets the live // step list with every step marked done, and the success line follows it. test("renders a live step list on a TTY", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run(["add", "memory", "--name", "customer_memory"], { isTTY: true }); const frames = stripAnsi(io.stderr()); @@ -89,7 +65,8 @@ describe("project add memory", () => { }); test("--json on a TTY keeps the plain step lines so no ANSI reaches stderr", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run(["add", "memory", "--name", "customer_memory", "--json"], { isTTY: true, }); @@ -276,7 +253,8 @@ describe("project add memory", () => { ], ["tags", ["--name", "x", "--tags", '{"team":"ml"}'], { tags: { team: "ml" } }], ])("%s", async (_label, flags, expected) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "memory", ...flags]); const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -285,14 +263,16 @@ describe("project add memory", () => { }); test("adds no files under app/", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "memory", "--name", "x"]); expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); }); test("rejects a duplicate memory name", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "memory", "--name", "x"]); await expect(run(["add", "memory", "--name", "x"])).rejects.toBeInstanceOf( InputValidationError, @@ -403,7 +383,8 @@ describe("project add memory", () => { ], ["malformed --strategies JSON", ["--name", "x", "--strategies", "[{"]], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "memory", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); @@ -513,7 +494,8 @@ describe("project add memory", () => { /Invalid value for option '--tags'/, ], ])("%s", async (_label, flags, error) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "memory", ...flags])).rejects.toThrow(error); }); }); diff --git a/src/handlers/project/add/online-eval/index.test.ts b/src/handlers/project/add/online-eval/index.test.ts index 97dc789d9..8f8ae5d24 100644 --- a/src/handlers/project/add/online-eval/index.test.ts +++ b/src/handlers/project/add/online-eval/index.test.ts @@ -1,32 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../../testing"; import { DeserializationError, InputValidationError } from "../../../../errors"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-online-eval-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -40,22 +25,6 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run([ - "create", - "--name", - name, - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - describe("project add online-eval", () => { test.each<[string, string[], Record]>([ [ @@ -167,7 +136,10 @@ describe("project add online-eval", () => { { tags: { team: "ml" } }, ], ])("%s", async (_label, flags, expected) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "online-eval", ...flags]); const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -176,7 +148,8 @@ describe("project add online-eval", () => { }); test("rejects a duplicate online-eval name", async () => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); const flags = [ "--name", "x", @@ -194,7 +167,10 @@ describe("project add online-eval", () => { }); test("rejects when the existing spec is invalid", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const specPath = join(projectRoot, "agentcore", "agentcore.json"); const spec = await Bun.file(specPath).json(); @@ -271,7 +247,8 @@ describe("project add online-eval", () => { ], ], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); await expect(run(["add", "online-eval", ...flags])).rejects.toBeInstanceOf( InputValidationError, ); diff --git a/src/handlers/project/add/online-insight/index.test.ts b/src/handlers/project/add/online-insight/index.test.ts index dce9ca725..00137d0f5 100644 --- a/src/handlers/project/add/online-insight/index.test.ts +++ b/src/handlers/project/add/online-insight/index.test.ts @@ -1,32 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../../testing"; import { DeserializationError, InputValidationError } from "../../../../errors"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-online-insight-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -40,22 +25,6 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run([ - "create", - "--name", - name, - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - const INSIGHT = "Builtin.Insight.FailureAnalysis"; describe("project add online-insight", () => { @@ -158,7 +127,10 @@ describe("project add online-insight", () => { { description: "monitor prod", enableOnCreate: false, tags: { team: "ml" } }, ], ])("%s", async (_label, flags, expected) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "online-insight", ...flags]); const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -167,7 +139,8 @@ describe("project add online-insight", () => { }); test("rejects a duplicate online-insight name", async () => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); const flags = [ "--name", "x", @@ -185,7 +158,10 @@ describe("project add online-insight", () => { }); test("rejects when the existing spec is invalid", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const specPath = join(projectRoot, "agentcore", "agentcore.json"); const spec = await Bun.file(specPath).json(); @@ -262,7 +238,8 @@ describe("project add online-insight", () => { ], ], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); await expect(run(["add", "online-insight", ...flags])).rejects.toBeInstanceOf( InputValidationError, ); diff --git a/src/handlers/project/add/payment-test-support.ts b/src/handlers/project/add/payment-test-support.ts index 4ec8be70c..c3f2ba85f 100644 --- a/src/handlers/project/add/payment-test-support.ts +++ b/src/handlers/project/add/payment-test-support.ts @@ -1,17 +1,15 @@ -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../testing"; export function createPaymentProjectTestHarness(directoryPrefix: string) { - const originalCwd = process.cwd(); - const tempDirectories: string[] = []; + const cleanups: Array<() => Promise> = []; async function run(args: string[]) { const io = testIO(); @@ -25,20 +23,12 @@ export function createPaymentProjectTestHarness(directoryPrefix: string) { } async function inProject(name = "TestProject"): Promise { - const directory = await mkdtemp(join(tmpdir(), `agentcore-${directoryPrefix}-`)); - tempDirectories.push(directory); - process.chdir(directory); - await run([ - "create", - "--name", + const { projectRoot, cleanup } = await initProject({ name, - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); + flags: ["--template", "agent-python-minimal"], + prefix: `agentcore-${directoryPrefix}-`, + }); + cleanups.push(cleanup); return projectRoot; } @@ -53,12 +43,11 @@ export function createPaymentProjectTestHarness(directoryPrefix: string) { ); } - async function cleanup(): Promise { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); - } - - return { cleanup, inProject, projectSpec, run, writeProjectSpec }; + return { + cleanup: () => Promise.all(cleanups.splice(0).map((cleanup) => cleanup())), + inProject, + projectSpec, + run, + writeProjectSpec, + }; } diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index ef0ec71ef..931a02178 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -13,22 +12,8 @@ import { InputValidationError } from "../../../../errors"; import type { BedrockAgentImportPlan } from "../../../../core/project/bedrockAgentImport"; import { credentialEnvVarName } from "../../../../projectSchemas/credential"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-runtime-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -42,14 +27,6 @@ async function run(args: string[], opts?: { core?: TestCoreClient }) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - function translatedImportPlan( overrides: Partial = {}, ): BedrockAgentImportPlan { @@ -283,7 +260,8 @@ describe("project add runtime", () => { ["--name", "configured_agent", ...template, ...allInfrastructureFlags], ], ])("%s — accepts flags", async (label, flags) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "runtime", ...flags]); const name = flags[flags.indexOf("--name") + 1]!; @@ -308,7 +286,8 @@ describe("project add runtime", () => { ["mcp-python-fastmcp", []], ["agui-python-strands", ["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"]], ])("%s ships with its pre-configured memory", async (templateName, expectedStrategies) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "runtime", "--name", "my_agent", "--template", templateName]); const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -326,7 +305,8 @@ describe("project add runtime", () => { }); test("agent-typescript-strands scaffolds a TypeScript agent", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "runtime", "--name", "my_agent", "--template", "agent-typescript-strands"]); const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -352,7 +332,8 @@ describe("project add runtime", () => { }); test("agent-typescript-vercel scaffolds a memory-free TypeScript runtime", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "runtime", "--name", "my_agent", "--template", "agent-typescript-vercel"]); const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -375,7 +356,8 @@ describe("project add runtime", () => { ])( "scaffolds agent-python-strands for --model-provider %s with an API-key credential", async (flagValue, provider) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const apiKeyPath = join(projectRoot, "api-key.txt"); await Bun.write(apiKeyPath, "test-api-key"); @@ -438,12 +420,14 @@ describe("project add runtime", () => { ], ["runtime names are limited in length", ["--name", "x".repeat(43)]], ])("%s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); test("rejects an unknown --template value", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect( run(["add", "runtime", "--name", "my_agent", "--template", "nonsense"]), ).rejects.toThrow(); @@ -467,7 +451,8 @@ describe("project add runtime --type import", () => { ]; test("scaffolds owned runtime code translated from the selected agent version", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); @@ -508,7 +493,8 @@ describe("project add runtime --type import", () => { }); test("--json reports import follow-up as a structured note", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); @@ -521,7 +507,8 @@ describe("project add runtime --type import", () => { }); test("supports LangGraph translation", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan({ framework: "langgraph", @@ -544,7 +531,8 @@ describe("project add runtime --type import", () => { }); test("documents required permissions instead of generating policies for a caller-owned role", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); const roleArn = "arn:aws:iam::111122223333:role/ExistingRuntimeRole"; @@ -563,12 +551,14 @@ describe("project add runtime --type import", () => { }); test("rejects a nonexistent agent with the describe error", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(importArgs)).rejects.toThrow(/no Bedrock Agent with id 'A1B2C3D4E5'/); }); test("rejects an unsupported --region before any service call", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); const args = [...importArgs.slice(0, -2), "--region", "eu-north-1"]; await expect(run(args, { core })).rejects.toThrow(/not a supported Bedrock Agent region/); @@ -576,21 +566,24 @@ describe("project add runtime --type import", () => { }); test("requires --agent-id and --agent-alias-id with --type import", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect( run(["add", "runtime", "--name", "p", "--type", "import", "--region", "us-east-1"]), ).rejects.toThrow(/requires both --agent-id and --agent-alias-id/); }); test("rejects --agent-id without --type import", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "runtime", "--name", "p", "--agent-id", "A1"])).rejects.toThrow( /--agent-id and --agent-alias-id require --type import/, ); }); test("accepts translation flags and rejects a template", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const core = new TestCoreClient(); core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); await expect(run([...importArgs, "--framework", "strands"], { core })).resolves.toBeDefined(); diff --git a/src/handlers/project/build/index.test.ts b/src/handlers/project/build/index.test.ts index a286fb512..0e74aabce 100644 --- a/src/handlers/project/build/index.test.ts +++ b/src/handlers/project/build/index.test.ts @@ -1,10 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -46,29 +44,16 @@ function testBuildCommand(options: TestBuildOptions = {}) { return { io, run: (args: string[] = []) => root.route(["node", "agentcore", "project", "build", ...args]), - create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]), }; } -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); /** Scaffolds a project named 'orders' and cds into it. */ -async function inProject(subject: ReturnType): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-build-")); - tempDirectories.push(directory); - process.chdir(directory); - // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var - // symlink), matching the paths the manager derives from process.cwd(). - await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]); - process.chdir(join(process.cwd(), "orders")); +async function inProject(): Promise { + const { cleanup } = await initProject({ name: "orders" }); + cleanups.push(cleanup); } describe("project build handler", () => { @@ -79,7 +64,7 @@ describe("project build handler", () => { { type: "output", line: "synth chatter" }, ], }); - await inProject(subject); + await inProject(); await subject.run(); @@ -92,7 +77,7 @@ describe("project build handler", () => { test("renders the success message as JSON with --json", async () => { const subject = testBuildCommand(); - await inProject(subject); + await inProject(); await subject.run(["--json"]); @@ -102,7 +87,7 @@ describe("project build handler", () => { test("renders a build failure as JSON without changing the thrown error", async () => { const failure = new Error("cdk synth exploded"); const subject = testBuildCommand({ failure }); - await inProject(subject); + await inProject(); await expect(subject.run(["--json"])).rejects.toThrow("cdk synth exploded"); @@ -112,7 +97,7 @@ describe("project build handler", () => { test("keeps stdout empty on failure without --json", async () => { const subject = testBuildCommand({ failure: new Error("cdk synth exploded") }); - await inProject(subject); + await inProject(); await expect(subject.run()).rejects.toThrow("cdk synth exploded"); diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 2881472f4..44d06b3a2 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -1,18 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { QueryClient } from "@tanstack/react-query"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import type { DeployBackendInput, ProjectBackend } from "../../core/project"; -import { createRootHandler } from "../index"; import { cleanupScreens, - createSilentLogger, flatFrame, + initProject, + inTempDirectory, renderScreen, TestCoreClient, - TestGlobalConfigAccessor, - testIO, waitForFlatText, waitForText, } from "../../testing"; @@ -66,16 +63,9 @@ function fakeBackend(options: FakeBackendOptions = {}) { return { backend, deploys }; } -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - +const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); /** Scaffolds project 'orders' with a default target and cds into it. */ const STAGING = { name: "staging", account: "444455556666", region: "eu-west-1" } as const; @@ -84,25 +74,8 @@ async function inProject( core: TestCoreClient, options: { empty?: boolean; targets?: boolean; staging?: boolean } = {}, ): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-build-deploy-screen-")); - tempDirectories.push(directory); - process.chdir(directory); - const root = createRootHandler(core, { - io: testIO().io, - globalConfigAccessor: new TestGlobalConfigAccessor(), - logger: createSilentLogger(), - }); - await root.route([ - "node", - "agentcore", - "project", - "create", - "--name", - "orders", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(process.cwd(), "orders"); + const { projectRoot, cleanup } = await initProject({ name: "orders", core }); + cleanups.push(cleanup); if (options.targets !== false) { await writeFile( join(projectRoot, "agentcore", "aws-targets.json"), @@ -119,7 +92,6 @@ async function inProject( JSON.stringify({ name: "orders", version: 1 }), ); } - process.chdir(projectRoot); return projectRoot; } @@ -169,9 +141,7 @@ describe("project build screen", () => { }); test("reports the CLI's own guidance outside a project", async () => { - const directory = await mkdtemp(join(tmpdir(), "agentcore-no-project-")); - tempDirectories.push(directory); - process.chdir(directory); + cleanups.push((await inTempDirectory()).cleanup); const r = renderScreen("/agentcore/project/build"); await waitForFlatText(r.lastFrame, "No AgentCore project found"); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index 4dbb19094..a2c382631 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -1,13 +1,13 @@ import { test, expect, describe, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, readdir } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { renderScreen, waitForText, cleanupScreens, createSilentLogger, + inTempDirectory, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -20,29 +20,13 @@ import type { AppIO } from "../../../io"; import { resolveRuntimeTemplateShortcut } from "../shortcuts"; import type { CreateProjectInput } from "../types"; +const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); // The wizard scaffolds into process.cwd() exactly like the flag-driven path, // so creation tests run inside a temp directory (same pattern as // project.test.ts / manager.test.ts). -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-create-wizard-")); - tempDirectories.push(directory); - process.chdir(directory); - // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var - // symlink), matching the paths the manager derives from process.cwd(). - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); // spyOnCreate records every CreateProjectInput handed to the manager while // still running the real FsProjectManager underneath, so a test can assert @@ -62,7 +46,8 @@ const DEFAULT_MODEL_ID = "global.anthropic.claude-sonnet-4-6"; describe("project create wizard", () => { test("harness flow: name → type → model → review → created", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -129,7 +114,7 @@ describe("project create wizard", () => { }, 10000); test("an edited model id flows into the harness input", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -164,7 +149,8 @@ describe("project create wizard", () => { }, 10000); test("a provider API key ARN flows through the existing harness input", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -303,7 +289,8 @@ describe("project create wizard", () => { }); test("template flow: strands goes straight to review (no memory question)", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -353,7 +340,8 @@ describe("project create wizard", () => { }, 10000); test("template flow: the minimal template scaffolds without memory", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -388,7 +376,7 @@ describe("project create wizard", () => { }, 10000); test("template flow: the LangChain template hands the preset to create", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -423,7 +411,8 @@ describe("project create wizard", () => { }, 10000); test("template flow: the empty template creates a project with no runtime", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const core = new TestCoreClient(); const inputs = spyOnCreate(core); const r = renderScreen("/agentcore/project/create", { core }); @@ -569,7 +558,7 @@ describe("project create wizard", () => { }); test("a create() error offers r to retry only before anything was written, esc returns to review with the input kept", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); const core = new TestCoreClient(); const created: CreateProjectInput[] = []; const real = core.projectManager.create.bind(core.projectManager); @@ -621,7 +610,9 @@ describe("project create wizard", () => { }); test("on Windows a deep project root is refused before anything is written", async () => { - const deep = join(await inTempDirectory(), "n".repeat(120)); + const { path, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + const deep = join(path, "n".repeat(120)); await mkdir(deep); process.chdir(deep); const r = renderScreen("/agentcore/project/create", { platform: "win32" }); @@ -657,7 +648,7 @@ describe("project create dispatch", () => { } test("bare create in a TTY session opens the wizard", async () => { - await inTempDirectory(); // hygiene: nothing must be created outside a temp dir + cleanups.push((await inTempDirectory()).cleanup); // hygiene: nothing must be created outside a temp dir const { streams, stdin } = ttyTestIO(); const root = buildRoot(streams.io); @@ -728,7 +719,8 @@ describe("project create dispatch", () => { }); test("flag-driven create still runs headless in a TTY session", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const { streams } = ttyTestIO(); const root = buildRoot(streams.io); diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 909ceb73c..820dd1466 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { UserCancellationError } from "../../../errors/errors"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -105,39 +105,17 @@ function testDeployCommand( ...fake, io, run: (args: string[] = []) => root.route(["node", "agentcore", "project", "deploy", ...args]), - create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]), }; } -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-deploy-")); - tempDirectories.push(directory); - process.chdir(directory); - // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var - // symlink), matching the paths the manager derives from process.cwd(). - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); /** Scaffolds a project whose aws-targets.json holds exactly `contents`, and cds into it. */ -async function inProjectWithTargets( - subject: ReturnType, - contents: string = JSON.stringify(TARGETS), -): Promise { - const directory = await inTempDirectory(); - await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]); - const projectRoot = join(directory, "orders"); +async function inProjectWithTargets(contents: string = JSON.stringify(TARGETS)): Promise { + const { projectRoot, cleanup } = await initProject({ name: "orders" }); + cleanups.push(cleanup); await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), contents); - process.chdir(projectRoot); return projectRoot; } @@ -162,7 +140,7 @@ describe("project deploy handler", () => { { type: "step", message: "Deploying stack" }, ], ); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(); @@ -179,7 +157,7 @@ describe("project deploy handler", () => { test("passes an explicit target and renders the result as JSON", async () => { const result = { outputs: { ServiceUrl: "https://service.example" } }; const subject = testDeployCommand(result); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(["--target", "staging", "--json"]); @@ -193,7 +171,7 @@ describe("project deploy handler", () => { test("renders a teardown result as JSON with the removal message", async () => { const subject = testDeployCommand({ outputs: {}, tornDown: true }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(["--yes", "--json"]); @@ -208,7 +186,7 @@ describe("project deploy handler", () => { const subject = testDeployCommand({ outputs: {} }, [], { failure: new Error("The stack failed creation: ROLLBACK_COMPLETE"), }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await expect(subject.run(["--json"])).rejects.toThrow("ROLLBACK_COMPLETE"); @@ -225,7 +203,7 @@ describe("project deploy handler", () => { stdin: "\n", teardown: TEARDOWN, }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(["--yes"]); @@ -242,7 +220,7 @@ describe("project deploy handler", () => { stdin: "yes\n", teardown: TEARDOWN, }); - const projectRoot = await inProjectWithTargets(subject); + const projectRoot = await inProjectWithTargets(); await emptyProjectSpec(projectRoot); await subject.run(); @@ -262,7 +240,7 @@ describe("project deploy handler", () => { stdin, teardown: TEARDOWN, }); - const projectRoot = await inProjectWithTargets(subject); + const projectRoot = await inProjectWithTargets(); await emptyProjectSpec(projectRoot); await expect(subject.run()).rejects.toBeInstanceOf(UserCancellationError); @@ -279,7 +257,7 @@ describe("project deploy handler", () => { stdin: "", teardown: TEARDOWN, }); - const projectRoot = await inProjectWithTargets(subject); + const projectRoot = await inProjectWithTargets(); await emptyProjectSpec(projectRoot); await expect(subject.run()).rejects.toBeInstanceOf(UserCancellationError); @@ -297,7 +275,7 @@ describe("project deploy handler", () => { stdin: "yes\n", teardown: TEARDOWN, }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await expect(subject.run()).rejects.toThrow(/--yes/); @@ -310,7 +288,7 @@ describe("project deploy handler", () => { stdin: "yes\n", teardown: TEARDOWN, }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await expect(subject.run()).rejects.toThrow(/--yes/); @@ -324,7 +302,7 @@ describe("project deploy handler", () => { stdin: "yes\n", teardown: TEARDOWN, }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await expect(subject.run(["--json"])).rejects.toThrow(/--yes/); @@ -341,7 +319,7 @@ describe("project deploy handler", () => { isTTY: true, stdin: "yes\n", }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(); @@ -352,7 +330,7 @@ describe("project deploy handler", () => { const subject = testDeployCommand({ outputs: {}, tornDown: true }, [ { type: "step", message: "Removing stack AgentCore-orders-default" }, ]); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await subject.run(["--yes"]); @@ -364,7 +342,7 @@ describe("project deploy handler", () => { test("rejects an unknown target without invoking the backend", async () => { const subject = testDeployCommand({ outputs: {} }); - await inProjectWithTargets(subject); + await inProjectWithTargets(); await expect(subject.run(["--target", "nope"])).rejects.toThrow( /no deployment target named 'nope'/, @@ -374,7 +352,7 @@ describe("project deploy handler", () => { test("requires deployment targets to be configured for a named target", async () => { const subject = testDeployCommand({ outputs: {} }); - await inProjectWithTargets(subject, JSON.stringify([])); + await inProjectWithTargets(JSON.stringify([])); await expect(subject.run(["--target", "staging"])).rejects.toThrow( /No deployment targets are configured/, @@ -386,7 +364,7 @@ describe("project deploy handler", () => { // the first deploy must invent the default target rather than demand edits. test("creates the default target from the environment on first deploy", async () => { const subject = testDeployCommand({ outputs: { RuntimeArn: "arn:runtime" } }); - const projectRoot = await inProjectWithTargets(subject, JSON.stringify([])); + const projectRoot = await inProjectWithTargets(JSON.stringify([])); await subject.run(["--region", "us-west-2"]); @@ -407,7 +385,7 @@ describe("project deploy handler", () => { test("rejects an unsupported region instead of writing an invalid target", async () => { const subject = testDeployCommand({ outputs: {} }); - const projectRoot = await inProjectWithTargets(subject, JSON.stringify([])); + const projectRoot = await inProjectWithTargets(JSON.stringify([])); const message = await messageFrom(subject.run(["--region", "us-west-1"])); @@ -423,7 +401,7 @@ describe("project deploy handler", () => { throw new Error("Could not load credentials from any providers"); }, }); - await inProjectWithTargets(subject, JSON.stringify([])); + await inProjectWithTargets(JSON.stringify([])); const message = await messageFrom(subject.run(["--region", "us-east-1"])); @@ -447,7 +425,6 @@ describe("project deploy reports which field of aws-targets.json is wrong", () = test("names the offending field for an unsupported region", async () => { const subject = testDeployCommand({ outputs: {} }); await inProjectWithTargets( - subject, JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-11" }]), ); @@ -461,7 +438,7 @@ describe("project deploy reports which field of aws-targets.json is wrong", () = test("surfaces the duplicate target name", async () => { const subject = testDeployCommand({ outputs: {} }); - await inProjectWithTargets(subject, JSON.stringify([DEFAULT_TARGET, DEFAULT_TARGET])); + await inProjectWithTargets(JSON.stringify([DEFAULT_TARGET, DEFAULT_TARGET])); await expect(subject.run()).rejects.toThrow(/Duplicate deployment target name: default/); expect(subject.calls).toEqual([]); @@ -470,7 +447,6 @@ describe("project deploy reports which field of aws-targets.json is wrong", () = test("surfaces the account id rule", async () => { const subject = testDeployCommand({ outputs: {} }); await inProjectWithTargets( - subject, JSON.stringify([{ name: "default", account: "123", region: "us-east-1" }]), ); @@ -480,7 +456,7 @@ describe("project deploy reports which field of aws-targets.json is wrong", () = test("surfaces the parse error for malformed json", async () => { const subject = testDeployCommand({ outputs: {} }); - await inProjectWithTargets(subject, '[{ "name": "default", }]'); + await inProjectWithTargets('[{ "name": "default", }]'); await expect(subject.run()).rejects.toThrow(/JSON Parse error/); expect(subject.calls).toEqual([]); diff --git a/src/handlers/project/export/harness.test.ts b/src/handlers/project/export/harness.test.ts index 43da9635f..3f29bfe04 100644 --- a/src/handlers/project/export/harness.test.ts +++ b/src/handlers/project/export/harness.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, + inTempDirectory, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -38,39 +38,18 @@ function testExportCommand() { return subject; } -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-export-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); /** Scaffolds a project with one harness named `exportme` and cds into it. */ async function inProjectWithHarness( subject: ReturnType, ): Promise { - const directory = await inTempDirectory(); - await subject.project([ - "create", - "--name", - "orders", - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, "orders"); - process.chdir(projectRoot); + const { projectRoot, cleanup } = await initProject({ + name: "orders", + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await subject.project([ "add", "harness", @@ -304,7 +283,7 @@ describe("project export harness handler", () => { test("validates the project before fetching from the service", async () => { const subject = testExportCommand(); - await inTempDirectory(); // not a project + cleanups.push((await inTempDirectory()).cleanup); // not a project await expect(subject.run(["--arn", HARNESS_ARN])).rejects.toThrow(/No AgentCore project found/); expect(subject.core.harness.calls).toEqual([]); diff --git a/src/handlers/project/invoke/index.test.tsx b/src/handlers/project/invoke/index.test.tsx index b2308f07a..4c60b9971 100644 --- a/src/handlers/project/invoke/index.test.tsx +++ b/src/handlers/project/invoke/index.test.tsx @@ -1,7 +1,6 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import type { InvokeHarnessRequest } from "@aws-sdk/client-bedrock-agentcore"; import type { GetAgentRuntimeResponse, @@ -17,6 +16,7 @@ import { TestCoreClient, TestGlobalConfigAccessor, testIO, + inTempDirectory, } from "../../../testing"; import { createRootHandler } from "../../index"; import { JsonKey, RegionKey } from "../../keys"; @@ -27,9 +27,8 @@ import { createProjectInvokeHandler } from "."; import { createProjectInvokeHarnessHandler } from "./harness"; import { createProjectInvokeRuntimeHandler } from "./runtime"; -const originalCwd = process.cwd(); -const temporaryDirectories: string[] = []; const servers: HttpServerHandle[] = []; +const cleanups: Array<() => Promise> = []; const TARGET = { name: "default", @@ -66,8 +65,8 @@ async function inProject( }, options: { writeTargets?: boolean } = {}, ): Promise { - const root = await mkdtemp(join(tmpdir(), "agentcore-project-invoke-reduced-")); - temporaryDirectories.push(root); + const { path: root, cleanup } = await inTempDirectory("agentcore-project-invoke-reduced-"); + cleanups.push(cleanup); await mkdir(join(root, "agentcore"), { recursive: true }); const spec = ProjectSpecSchema.parse({ name: "orders", @@ -79,7 +78,6 @@ async function inProject( if (options.writeTargets !== false) { await writeFile(join(root, "agentcore", "aws-targets.json"), JSON.stringify([TARGET])); } - process.chdir(root); } function backend() { @@ -171,13 +169,8 @@ function context(project: Project): Context { } afterEach(async () => { - process.chdir(originalCwd); await Promise.all(servers.splice(0).map((server) => server.close())); - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); }); describe("project invoke", () => { diff --git a/src/handlers/project/invoke/invoke.screen.test.tsx b/src/handlers/project/invoke/invoke.screen.test.tsx index f1a2be059..983b56b49 100644 --- a/src/handlers/project/invoke/invoke.screen.test.tsx +++ b/src/handlers/project/invoke/invoke.screen.test.tsx @@ -4,14 +4,12 @@ import type { GetAgentRuntimeResponse, GetHarnessResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; import { cleanupScreens, flatFrame, + inTempDirectory, renderScreen, TestCoreClient, waitForFlatText, @@ -19,16 +17,9 @@ import { } from "../../../testing"; import type { Project, ResolvedDeployedResource } from "../types"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - +const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); const project: Project = { name: "orders", @@ -134,9 +125,8 @@ describe("project invoke picker", () => { }); test("reports the CLI's own guidance outside a project", async () => { - const directory = await mkdtemp(join(tmpdir(), "agentcore-no-project-")); - tempDirectories.push(directory); - process.chdir(directory); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const screen = renderScreen("/agentcore/project/invoke", { core: core() }); await waitForFlatText(screen.lastFrame, "No AgentCore project found"); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 3dc5f2bd3..c89324a6b 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,11 +1,12 @@ import { afterEach, test, expect, describe } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { mkdir, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, + initProject, + inTempDirectory, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -29,46 +30,22 @@ async function run( } test("project status requires an AgentCore project", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["status"])).rejects.toThrow(/No AgentCore project found/); }); test("project dev requires an AgentCore project", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["dev"])).rejects.toThrow(/No AgentCore project found/); }); -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-project-")); - tempDirectories.push(directory); - process.chdir(directory); - // cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var - // symlink), matching the paths the manager derives from process.cwd(). - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -/** Scaffolds a project and cds into it so withProject resolves it. */ -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", name, "--skip-install", "--skip-git"]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); describe("project create", () => { test("--json returns the created project without human success text", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const { io } = await run([ "create", "--name", @@ -88,7 +65,8 @@ describe("project create", () => { }); test("scaffolds a harness project by default, named for the project", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run(["create", "--name", "MyAgent"]); const projectRoot = join(directory, "MyAgent"); @@ -108,7 +86,9 @@ describe("project create", () => { }); test("refuses a project root that would exceed MAX_PATH on Windows, leaving nothing behind", async () => { - const deep = join(await inTempDirectory(), "n".repeat(120)); + const { path, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + const deep = join(path, "n".repeat(120)); await mkdir(deep); process.chdir(deep); @@ -122,7 +102,8 @@ describe("project create", () => { }); test("a harness create installs CDK dependencies and git only (no uv sync)", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const { core } = await run(["create", "--name", "MyAgent"]); const projectRoot = join(directory, "MyAgent"); @@ -136,7 +117,8 @@ describe("project create", () => { }); test("the empty template scaffolds a project with no runtime and no harness", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -155,21 +137,22 @@ describe("project create", () => { }); test("rejects --model-provider with the empty template", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect( run(["create", "--name", "MyAgent", "--template", "empty", "--model-provider", "anthropic"]), ).rejects.toThrow(/--model-provider only applies to runtime templates/); }); test("rejects --model-provider without a template", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect( run(["create", "--name", "MyAgent", "--model-provider", "anthropic"]), ).rejects.toThrow(/--model-provider only applies to runtime templates/); }); test("rejects --api-key with a template that does not support it", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await expect( run( [ @@ -190,7 +173,7 @@ describe("project create", () => { }); test("rejects --model-provider with a template that does not support it", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect( run([ "create", @@ -207,7 +190,8 @@ describe("project create", () => { }); test("runs the post-scaffold steps and reports progress on stderr", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const { io, core } = await run([ "create", "--name", @@ -234,14 +218,15 @@ describe("project create", () => { }); test("--skip-install and --skip-git run no commands", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); const { core } = await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]); expect(core.projectCommands).toEqual([]); }); test("scaffolds the strands template with longAndShortTerm memory pre-configured", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -274,7 +259,8 @@ describe("project create", () => { }); test("scaffolds a keyless LiteLLM runtime with no credential", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -299,7 +285,8 @@ describe("project create", () => { ["gemini", "agent_python_strandsGeminiApiKey"], ["lite_llm", "agent_python_strandsLiteLLMApiKey"], ])("scaffolds a runtime with a %s API-key credential", async (provider, credentialName) => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const apiKeyPath = join(directory, "api-key.txt"); await Bun.write(apiKeyPath, "test-api-key"); @@ -328,7 +315,8 @@ describe("project create", () => { }); test("scaffolds a Container agent from the strands -container template", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -354,7 +342,8 @@ describe("project create", () => { }); test("omits the Dockerfile from a CodeZip strands template", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -371,7 +360,8 @@ describe("project create", () => { }); test("generates uv.lock for a Container scaffold even with --skip-install", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); const { core } = await run([ "create", "--name", @@ -389,7 +379,8 @@ describe("project create", () => { }); test("scaffolds an MCP server from the mcp-python-fastmcp template (CodeZip default)", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -418,7 +409,8 @@ describe("project create", () => { }); test("scaffolds the minimal Python template", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -444,7 +436,8 @@ describe("project create", () => { }); test("renders the LangChain template's pyproject name and no credentials", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -465,7 +458,8 @@ describe("project create", () => { }); test("scaffolds a TypeScript strands runtime with memory pre-configured", async () => { - const directory = await inTempDirectory(); + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); await run([ "create", "--name", @@ -495,17 +489,17 @@ describe("project create", () => { }); test("rejects an invalid --name", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["create", "--name", "1-bad"])).rejects.toThrow(); }); test("rejects a reserved --name", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["create", "--name", "test"])).rejects.toThrow(/conflicts with/); }); test("rejects an unknown --template value", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["create", "--name", "MyAgent", "--template", "nonsense"])).rejects.toThrow(); }); }); @@ -521,7 +515,8 @@ describe("project add config-bundle", () => { }; test("adds a configuration bundle to agentcore.json", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run([ "add", "config-bundle", @@ -544,7 +539,8 @@ describe("project add config-bundle", () => { }); test("stores optional configuration bundle fields", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const kmsKeyArn = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; await run([ @@ -577,7 +573,8 @@ describe("project add config-bundle", () => { }); test("reads components from a file", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const componentsPath = join(projectRoot, "components.json"); await Bun.write(componentsPath, JSON.stringify(components)); @@ -595,7 +592,8 @@ describe("project add config-bundle", () => { }); test("adds no files under app", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run([ "add", @@ -610,7 +608,8 @@ describe("project add config-bundle", () => { }); test("rejects a duplicate configuration bundle name", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const args = [ "add", "config-bundle", @@ -690,7 +689,8 @@ describe("project add config-bundle", () => { ], ], ])("rejects %s", async (_label, flags) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "config-bundle", ...flags])).rejects.toBeInstanceOf( InputValidationError, ); @@ -699,7 +699,8 @@ describe("project add config-bundle", () => { describe("project add credentials", () => { test("--json reports the credential without exposing its secret", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const keyPath = join(projectRoot, "key.txt"); await Bun.write(keyPath, "sk-secret-value\n"); @@ -724,7 +725,8 @@ describe("project add credentials", () => { }); test("api-key with a file:// secret records the spec entry and stores the trailing-newline-stripped key in .env.local", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const keyPath = join(projectRoot, "key.txt"); // The trailing newline mirrors `echo` and editor output; it must not reach the value. await Bun.write(keyPath, "sk-123\n"); @@ -750,7 +752,8 @@ describe("project add credentials", () => { }); test("api-key without a secret writes a commented placeholder and tells the user to fill it", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run(["add", "credentials", "api-key", "--name", "svc-key"]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); @@ -762,7 +765,8 @@ describe("project add credentials", () => { }); test("--json reports credential setup guidance as structured notes", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run(["add", "credentials", "api-key", "--name", "svc-key", "--json"]); expect(JSON.parse(io.stdout()).notes).toEqual([ @@ -772,7 +776,8 @@ describe("project add credentials", () => { }); test("api-key with an external secret reference records it in the spec and skips .env.local", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const secretRef = { secretId: "arn:aws:secretsmanager:us-west-2:123456789012:secret:s", jsonKey: "apiKey", @@ -799,7 +804,8 @@ describe("project add credentials", () => { const discoveryUrl = "https://idp.example.com/.well-known/openid-configuration"; test("oauth custom with guided flags and a stdin secret records the spec entry and the secret", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); await run( [ @@ -838,7 +844,8 @@ describe("project add credentials", () => { }); test("oauth vendored with --provider-configuration records the config and a secret placeholder", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const { io } = await run([ "add", @@ -870,7 +877,8 @@ describe("project add credentials", () => { }); test("preserves existing .env.local content and never overwrites an existing key", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const envPath = join(projectRoot, "agentcore", ".env.local"); const original = await Bun.file(envPath).text(); await Bun.write(envPath, `${original}AGENTCORE_CREDENTIAL_SVC_KEY=user-managed\n`); @@ -885,7 +893,8 @@ describe("project add credentials", () => { }); test("creates .env.local when the project lacks one", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); const envPath = join(projectRoot, "agentcore", ".env.local"); await rm(envPath); @@ -896,7 +905,8 @@ describe("project add credentials", () => { }); test("rejects a duplicate credential name across credential types", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "credentials", "api-key", "--name", "dup"]); await expect( run(["add", "credentials", "oauth", "--name", "dup", "--discovery-url", discoveryUrl]), @@ -904,7 +914,8 @@ describe("project add credentials", () => { }); test("rejects two names that derive the same environment variable", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await run(["add", "credentials", "api-key", "--name", "svc-key"]); await expect(run(["add", "credentials", "api-key", "--name", "svc_key"])).rejects.toThrow( /same environment variable/, @@ -912,7 +923,8 @@ describe("project add credentials", () => { }); test("rejects different credential types that collide on one secret variable", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); // OAuth 'foo' → AGENTCORE_CREDENTIAL_FOO_CLIENT_SECRET; api-key 'foo_client_secret' → the same. await run(["add", "credentials", "oauth", "--name", "foo", "--discovery-url", discoveryUrl]); await expect( @@ -921,7 +933,8 @@ describe("project add credentials", () => { }); test("rejects a name ending in a field suffix even with nothing to collide with", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); // Nothing in the spec derives AGENTCORE_CREDENTIAL_SVC_CLIENT_ID, but a pre-0.29 // OAuth credential named 'svc' would read it as its client id. await expect(run(["add", "credentials", "api-key", "--name", "svc-client-id"])).rejects.toThrow( @@ -1010,7 +1023,8 @@ describe("project add credentials", () => { /secret material/, ], ])("rejects %s", async (_label, args, message) => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["add", "credentials", ...args], { stdin: "line1\nline2" })).rejects.toThrow( message, ); @@ -1019,7 +1033,8 @@ describe("project add credentials", () => { describe("project build", () => { async function inBuildableProject(): Promise { - const projectRoot = await inProject("MyAgent"); + const { projectRoot, cleanup } = await initProject({ name: "MyAgent" }); + cleanups.push(cleanup); // create --skip-install leaves no node_modules, which build requires. await mkdir(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); return projectRoot; @@ -1061,7 +1076,7 @@ describe("project build", () => { }); test("fails with actionable guidance outside a project", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["build"])).rejects.toThrow(/No AgentCore project found/); }); @@ -1075,7 +1090,7 @@ describe("project build", () => { describe("project deploy", () => { test("requires an AgentCore project", async () => { - await inTempDirectory(); + cleanups.push((await inTempDirectory()).cleanup); await expect(run(["deploy"])).rejects.toThrow(/No AgentCore project found/); }); @@ -1083,7 +1098,8 @@ describe("project deploy", () => { // of rejecting (covered with a stubbed backend in deploy/index.test.ts); only // a named target still demands configuration. test("rejects a project with no deployment targets for a named target", async () => { - await inProject(); + const { cleanup } = await initProject(); + cleanups.push(cleanup); await expect(run(["deploy", "--target", "staging"])).rejects.toThrow( /No deployment targets are configured/, ); diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index e691ceb83..346c20826 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -1,11 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -20,22 +19,8 @@ import { projectSpec, writeProjectSpec } from "../add/gateway-test-support"; import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { ENV_LOCAL_RELATIVE_PATH } from "../../../core/project/envLocal"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -async function inTempDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-remove-")); - tempDirectories.push(directory); - process.chdir(directory); - return process.cwd(); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); async function run(args: string[], ioOptions?: TestIOOptions) { const io = testIO(ioOptions); @@ -49,22 +34,6 @@ async function run(args: string[], ioOptions?: TestIOOptions) { return { io, core }; } -async function inProject(name = "TestProject"): Promise { - const directory = await inTempDirectory(); - await run([ - "create", - "--name", - name, - "--template", - "agent-python-minimal", - "--skip-install", - "--skip-git", - ]); - const projectRoot = join(directory, name); - process.chdir(projectRoot); - return projectRoot; -} - type RemoveCase = { label: string; commands: string[][]; @@ -177,7 +146,10 @@ describe("project remove", () => { expectedRemaining: [], }, ])("$label", async ({ commands, specKey, expectedRemaining }) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); for (const cmd of commands) { await run(cmd); @@ -189,7 +161,10 @@ describe("project remove", () => { }); test("removing a non-existent resource fails with a not-found error", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const before = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); const removal = run(["remove", "harness", "--name", "ghost"]); @@ -201,14 +176,18 @@ describe("project remove", () => { }); test("removing a target of a non-existent gateway names the missing gateway", async () => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); await expect( run(["remove", "gateway-target", "--gateway", "ghost", "--name", "t"]), ).rejects.toThrow(`no gateway named 'ghost' exists in this project`); }); test("removing a credential deletes its .env.local entry and reports it", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "credentials", "api-key", "--name", "svc-key", "--api-key", "-"], { stdin: "sekret", }); @@ -225,7 +204,10 @@ describe("project remove", () => { }); test("--json reports a removal and its cleaned environment keys", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "credentials", "api-key", "--name", "svc-key", "--api-key", "-"], { stdin: "sekret", }); @@ -244,7 +226,10 @@ describe("project remove", () => { }); test("removing a secret-reference credential leaves .env.local alone", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run([ "add", "credentials", @@ -292,7 +277,10 @@ describe("project remove", () => { ], }, ])("removes a nested $resource while preserving sibling Targets", async ({ resource, add }) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "gateway", "--name", "tools"]); await run([ "add", @@ -333,7 +321,10 @@ describe("project remove", () => { }); test("removes a payment manager with its connectors while preserving reusable credentials", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); await run(["add", "payment-manager", "--name", "keep"]); await run(["add", "payment-manager", "--name", "remove"]); @@ -364,7 +355,10 @@ describe("project remove", () => { }); test("removes a nested payment connector while preserving siblings and credentials", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); await run(["add", "payment-manager", "--name", "payments"]); await run([ @@ -443,7 +437,8 @@ describe("project remove", () => { ["remove", "payment-manager", "--manager", "payments", "--name", "payments"], ], ])("%s", async (_label, args) => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); }); @@ -464,7 +459,10 @@ describe("project remove", () => { ["with --engine", ["--engine", "Guardrails"]], ["resolving the engine from an unambiguous name", []], ])("removes a policy from its engine %s", async (_label, engineArgs) => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "policy-engine", "--name", "Guardrails"]); await addPolicy("Guardrails", "DenyAll"); @@ -484,7 +482,10 @@ describe("project remove", () => { }); test("rejects an ambiguous policy name without --engine", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "policy-engine", "--name", "First"]); await run(["add", "policy-engine", "--name", "Second"]); await addPolicy("First", "DenyAll"); @@ -500,7 +501,10 @@ describe("project remove", () => { }); test("removing an engine strips gateway references", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "gateway", "--name", "tools"]); await run(["add", "policy-engine", "--name", "Guardrails", "--attach-to-gateways", "tools"]); @@ -516,7 +520,10 @@ describe("project remove all", () => { // Fills a project with one of everything the CLI can add, plus an // unassignedTargets entry only reachable by editing the spec. async function populatedProject(): Promise { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["add", "harness", "--name", "my_harness"]); await run(["add", "gateway", "--name", "tools"]); await run([ @@ -620,7 +627,10 @@ describe("project remove all", () => { }); test("prompts on a TTY and proceeds on 'y'", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const { io } = await run(["remove", "all"], { isTTY: true, stdin: "y\n" }); @@ -629,7 +639,10 @@ describe("project remove all", () => { }); test("declining the prompt cancels without touching the spec", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const before = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); await expect(run(["remove", "all"], { isTTY: true, stdin: "n\n" })).rejects.toBeInstanceOf( @@ -640,7 +653,10 @@ describe("project remove all", () => { }); test("without --yes and without a TTY it fails rather than proceeding", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); const before = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).text(); const removal = run(["remove", "all"]); @@ -651,14 +667,18 @@ describe("project remove all", () => { }); test("rejects --name alongside all", async () => { - await inProject(); + const { cleanup } = await initProject({ flags: ["--template", "agent-python-minimal"] }); + cleanups.push(cleanup); await expect(run(["remove", "all", "--name", "x", "--yes"])).rejects.toThrow( "--name is not valid when removing all resources", ); }); test("is idempotent on an already-empty project", async () => { - const projectRoot = await inProject(); + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); await run(["remove", "all", "--yes"]); await run(["remove", "all", "--yes"]); diff --git a/src/handlers/project/status/index.test.ts b/src/handlers/project/status/index.test.ts index 548cd3c51..ce9132543 100644 --- a/src/handlers/project/status/index.test.ts +++ b/src/handlers/project/status/index.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createRootHandler } from "../../index"; import { createSilentLogger, + initProject, TestCoreClient, TestGlobalConfigAccessor, TestIdentityClient, @@ -60,7 +60,6 @@ function statusCommand(backend: ProjectBackend, io = testIO()) { io, json: () => JSON.parse(io.stdout()), run: (args: string[] = []) => root.route(["node", "agentcore", "project", "status", ...args]), - create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]), }; } @@ -69,15 +68,8 @@ function testStatusCommand(deployed: ResolvedProjectResource[] = [], io = testIO return { ...fake, ...statusCommand(fake.backend, io) }; } -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); // The report is refused when the ambient region is not the target's, so pin // the ambient region to the default target's rather than leave it to the @@ -92,20 +84,15 @@ afterEach(() => { }); async function inProject( - subject: ReturnType, spec: Record = {}, targets: AwsDeploymentTarget[] = TARGETS, ): Promise { - const directory = await mkdtemp(join(tmpdir(), "agentcore-status-")); - tempDirectories.push(directory); - process.chdir(directory); - await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]); - const projectRoot = join(process.cwd(), "orders"); + const { projectRoot, cleanup } = await initProject({ name: "orders" }); + cleanups.push(cleanup); await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), JSON.stringify(targets)); const specPath = join(projectRoot, "agentcore", "agentcore.json"); const current = JSON.parse(await Bun.file(specPath).text()); await writeFile(specPath, JSON.stringify({ ...current, ...spec })); - process.chdir(projectRoot); return projectRoot; } @@ -164,7 +151,7 @@ describe("project status handler", () => { : undefined, }); const subject = statusCommand(backend); - const projectRoot = await inProject(subject); + const projectRoot = await inProject(); const stateDirectory = join(projectRoot, "agentcore", ".cli"); await mkdir(stateDirectory, { recursive: true }); await Bun.write( @@ -204,7 +191,7 @@ describe("project status handler", () => { ]), localOnly("policy-engine", "empty"), ]); - await inProject(subject, { + await inProject({ memories: [memory("shortTerm")], policyEngines: [ { name: "guards", policies: [policy("noPii")] }, @@ -251,7 +238,7 @@ describe("project status handler", () => { deployed("memory", "shortTerm", `${ARN}:memory/shortTerm-1`), localOnly("memory", "longTerm"), ]); - await inProject(subject, { memories: [memory("shortTerm"), memory("longTerm")] }); + await inProject({ memories: [memory("shortTerm"), memory("longTerm")] }); await subject.run(); @@ -269,7 +256,7 @@ describe("project status handler", () => { test("reports every resource local-only when nothing is deployed", async () => { const subject = testStatusCommand([HARNESS_ROW, localOnly("memory", "shortTerm")]); - await inProject(subject, { memories: [memory("shortTerm")] }); + await inProject({ memories: [memory("shortTerm")] }); await subject.run(); @@ -286,7 +273,7 @@ describe("project status handler", () => { test("rejects a project that declares no targets, without reaching the backend", async () => { const subject = testStatusCommand([localOnly("memory", "shortTerm")]); - await inProject(subject, { memories: [memory("shortTerm")] }, []); + await inProject({ memories: [memory("shortTerm")] }, []); await expect(subject.run()).rejects.toThrow( /No deployment targets are configured for project 'orders'\. Please deploy your project using 'agentcore project deploy'\./, @@ -296,7 +283,7 @@ describe("project status handler", () => { test("--target selects another target, and an unknown one is rejected", async () => { const subject = testStatusCommand([]); - await inProject(subject); + await inProject(); await subject.run(["--region", STAGING_TARGET.region, "--target", "staging"]); @@ -310,7 +297,7 @@ describe("project status handler", () => { test("refuses a target deployed outside the ambient region", async () => { const subject = testStatusCommand([HARNESS_ROW]); - await inProject(subject); + await inProject(); // The ambient region is the default target's (pinned above); staging's is not. const outcome = subject.run(["--target", "staging"]); @@ -331,7 +318,7 @@ describe("project status dispatch", () => { test("bare status in a TTY session opens the TUI instead of printing JSON", async () => { const tty = ttyTestIO(); const subject = testStatusCommand([HARNESS_ROW], tty.streams); - await inProject(subject); + await inProject(); // outcome never rejects, so a mid-pump failure cannot trip bun's // unhandled-rejection detection before the final assertion. @@ -360,7 +347,7 @@ describe("project status dispatch", () => { test("an explicitly passed --target stays headless even in a TTY", async () => { const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams); - await inProject(subject); + await inProject(); await subject.run(["--target", "default"]); @@ -369,7 +356,7 @@ describe("project status dispatch", () => { test("--json stays headless even in a TTY", async () => { const subject = testStatusCommand([HARNESS_ROW], ttyTestIO().streams); - await inProject(subject); + await inProject(); await subject.run(["--json"]); diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index a2515ab82..0d3b58480 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -4,15 +4,13 @@ import type { GetHarnessResponse, GetMemoryOutput, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { ProjectKey } from "../../../router"; import { RegionKey } from "../../keys"; import { cleanupScreens, flatFrame, + inTempDirectory, renderScreen, TestCoreClient, waitForFlatText, @@ -20,16 +18,9 @@ import { } from "../../../testing"; import type { Project, ResolvedProjectResource } from "../types"; -const originalCwd = process.cwd(); -const tempDirectories: string[] = []; - +const cleanups: Array<() => Promise> = []; afterEach(cleanupScreens); -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); // The target region differs from the base context's us-east-1 on purpose, so // the detail screens are linked with the region the project deployed in. @@ -301,9 +292,7 @@ describe("project status screen", () => { }); test("reports the CLI's own guidance outside a project", async () => { - const directory = await mkdtemp(join(tmpdir(), "agentcore-status-no-project-")); - tempDirectories.push(directory); - process.chdir(directory); + cleanups.push((await inTempDirectory()).cleanup); const screen = renderScreen("/agentcore/project/status", { core: core() }); await waitForFlatText(screen.lastFrame, "No AgentCore project found"); From 4f48f5fecc2659496ee4ec12b4ed82fb03b8a177 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 9 Sep 2026 18:51:49 +0000 Subject: [PATCH 3/5] test: clean up temp dir when initProject scaffolding throws --- src/testing/projects.ts | 45 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/testing/projects.ts b/src/testing/projects.ts index b89eec6b1..f20eee845 100644 --- a/src/testing/projects.ts +++ b/src/testing/projects.ts @@ -35,24 +35,29 @@ export async function initProject(options: InitProjectOptions = {}): Promise Date: Wed, 9 Sep 2026 18:52:28 +0000 Subject: [PATCH 4/5] test: hoist cleanups declaration above the tests that use it --- src/handlers/project/project.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index c89324a6b..61b188de4 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -29,6 +29,9 @@ async function run( return { io, core }; } +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + test("project status requires an AgentCore project", async () => { cleanups.push((await inTempDirectory()).cleanup); await expect(run(["status"])).rejects.toThrow(/No AgentCore project found/); @@ -39,9 +42,6 @@ test("project dev requires an AgentCore project", async () => { await expect(run(["dev"])).rejects.toThrow(/No AgentCore project found/); }); -const cleanups: Array<() => Promise> = []; -afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); - describe("project create", () => { test("--json returns the created project without human success text", async () => { const { path: directory, cleanup } = await inTempDirectory(); From b0b64b9718ce6d7ee4218301e972f6025142f683 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 10 Sep 2026 15:28:03 +0000 Subject: [PATCH 5/5] test: restore original cwd from a single anchor in inTempDirectory --- src/testing/fs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/testing/fs.ts b/src/testing/fs.ts index cc67d322f..de9301641 100644 --- a/src/testing/fs.ts +++ b/src/testing/fs.ts @@ -5,9 +5,10 @@ import { tmpdir } from "node:os"; /** A temp directory and a handler that restores the cwd and removes it. */ export type TempDirectory = { path: string; cleanup: () => Promise }; +const originalCwd = process.cwd(); + /** Creates a temp directory, cds into it, and returns its realpath plus a cleanup handler. */ export async function inTempDirectory(prefix = "agentcore-project-"): Promise { - const originalCwd = process.cwd(); const directory = await mkdtemp(join(tmpdir(), prefix)); process.chdir(directory); return {