@ronkovic/aad 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ronkovic/aad",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Autonomous Agent Development Orchestrator - Multi-agent TDD pipeline powered by Claude",
5
5
  "module": "src/main.ts",
6
6
  "type": "module",
@@ -75,3 +75,44 @@ describe("buildInstallCommand", () => {
75
75
  });
76
76
  });
77
77
  });
78
+
79
+ describe("resolveAvailableCommand", () => {
80
+ test("returns command if binary exists in PATH", async () => {
81
+ // Import the function
82
+ const { resolveAvailableCommand } = await import("../dependency-installer");
83
+
84
+ // bun should exist in PATH during tests
85
+ const result = await resolveAvailableCommand("bun");
86
+ expect(result).toBe("bun");
87
+ });
88
+
89
+ test("falls back to npx if binary does not exist in PATH", async () => {
90
+ const { resolveAvailableCommand } = await import("../dependency-installer");
91
+
92
+ // Use a non-existent binary name
93
+ const result = await resolveAvailableCommand("nonexistent-binary-xyz123");
94
+ expect(result).toBe("npx");
95
+ });
96
+ });
97
+
98
+ describe("installDependencies integration", () => {
99
+ test("integrates with resolveAvailableCommand", async () => {
100
+ // This test verifies that installDependencies calls resolveAvailableCommand
101
+ // Actual fallback behavior is tested in resolveAvailableCommand tests above
102
+ const { buildInstallCommand } = await import("../dependency-installer");
103
+
104
+ const workspace: WorkspaceInfo = {
105
+ path: "/tmp/test",
106
+ language: "javascript",
107
+ packageManager: "yarn",
108
+ framework: "react",
109
+ testFramework: "jest",
110
+ };
111
+
112
+ const command = buildInstallCommand(workspace);
113
+ expect(command).toEqual(["yarn", "install", "--frozen-lockfile"]);
114
+
115
+ // The actual fallback is applied at runtime in installDependencies
116
+ // when yarn is not available in PATH, resolveAvailableCommand returns "npx"
117
+ });
118
+ });
@@ -186,4 +186,29 @@ describe("resolveTemplateDir", () => {
186
186
  expect(result).toContain("templates");
187
187
  expect(result).not.toContain("sandbox/templates");
188
188
  });
189
+
190
+ test("checks multiple fallback paths for npm distribution", async () => {
191
+ // Create test directory structure mimicking npm installation
192
+ const npmTestDir = join(testDir, "npm-install-test");
193
+ const fakeProjectRoot = join(npmTestDir, "user-project");
194
+ const packageRoot = join(npmTestDir, "node_modules", "aad");
195
+ const npmTemplateDir = join(packageRoot, "templates");
196
+
197
+ await mkdir(fakeProjectRoot, { recursive: true });
198
+ await mkdir(npmTemplateDir, { recursive: true });
199
+ await writeFile(join(npmTemplateDir, "CLAUDE.md"), "NPM template");
200
+
201
+ // Verify the actual project has templates/ at the root for npm distribution
202
+ // src/modules/git-workspace/__tests__/ -> ../../../../templates
203
+ const { existsSync } = await import("node:fs");
204
+ const actualProjectRoot = join(import.meta.dir, "..", "..", "..", "..");
205
+ const actualNpmTemplate = join(actualProjectRoot, "templates");
206
+
207
+ expect(existsSync(actualNpmTemplate)).toBe(true);
208
+
209
+ // Verify resolveTemplateDir returns templates/ when .aad/templates doesn't exist
210
+ const result = resolveTemplateDir(fakeProjectRoot);
211
+ // Should fallback to package templates/ since no local override exists
212
+ expect(result).toContain("templates");
213
+ });
189
214
  });
@@ -109,6 +109,89 @@ describe("WorktreeManager", () => {
109
109
  worktreeManager.createParentWorktree(runId, parentBranch)
110
110
  ).rejects.toThrow(GitWorkspaceError);
111
111
  });
112
+
113
+ test("cleans up stale parent worktree before creating new one", async () => {
114
+ const runId = createRunId("run-003");
115
+ const parentBranch = "main";
116
+ const featureBranch = "feat/run-003/parent";
117
+ const calls: string[][] = [];
118
+
119
+ mockGitOps.gitExec = mock(async (args: string[]) => {
120
+ calls.push(args);
121
+ return { stdout: "", stderr: "", exitCode: 0 };
122
+ });
123
+
124
+ await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
125
+
126
+ // Should call worktree remove before worktree add
127
+ const removeIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "remove");
128
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
129
+ expect(removeIdx).toBeGreaterThanOrEqual(0);
130
+ expect(addIdx).toBeGreaterThan(removeIdx);
131
+ expect(calls[removeIdx]).toEqual(["worktree", "remove", "/test/worktrees/parent-run-003", "--force"]);
132
+ });
133
+
134
+ test("deletes stale parent branch before creating new one", async () => {
135
+ const runId = createRunId("run-004");
136
+ const parentBranch = "main";
137
+ const featureBranch = "feat/run-004/parent";
138
+ const calls: string[][] = [];
139
+
140
+ mockGitOps.gitExec = mock(async (args: string[]) => {
141
+ calls.push(args);
142
+ return { stdout: "", stderr: "", exitCode: 0 };
143
+ });
144
+
145
+ await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
146
+
147
+ const branchDeleteCall = calls.find(c => c[0] === "branch" && c[1] === "-D");
148
+ expect(branchDeleteCall).toBeDefined();
149
+ expect(branchDeleteCall![2]).toBe(featureBranch);
150
+
151
+ // Branch delete should happen before worktree add
152
+ const branchIdx = calls.indexOf(branchDeleteCall!);
153
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
154
+ expect(branchIdx).toBeLessThan(addIdx);
155
+ });
156
+
157
+ test("handles gracefully when no stale parent worktree exists", async () => {
158
+ const runId = createRunId("run-005");
159
+ const parentBranch = "main";
160
+ const featureBranch = "feat/run-005/parent";
161
+
162
+ // Simulate: worktree remove fails, rm succeeds, prune succeeds, branch -D fails, add succeeds
163
+ mockGitOps.gitExec = mock(async (args: string[]) => {
164
+ if (args[1] === "remove") throw new Error("not a worktree");
165
+ if (args[0] === "branch" && args[1] === "-D") throw new Error("branch not found");
166
+ return { stdout: "", stderr: "", exitCode: 0 };
167
+ });
168
+
169
+ // Should not throw — cleanup errors are swallowed
170
+ const result = await worktreeManager.createParentWorktree(runId, parentBranch, featureBranch);
171
+ expect(result.worktreePath).toBe("/test/worktrees/parent-run-005");
172
+ expect(result.branch).toBe(featureBranch);
173
+ });
174
+
175
+ test("calls git worktree prune during parent cleanup", async () => {
176
+ const runId = createRunId("run-006");
177
+ const parentBranch = "main";
178
+ const calls: string[][] = [];
179
+
180
+ mockGitOps.gitExec = mock(async (args: string[]) => {
181
+ calls.push(args);
182
+ return { stdout: "", stderr: "", exitCode: 0 };
183
+ });
184
+
185
+ await worktreeManager.createParentWorktree(runId, parentBranch);
186
+
187
+ const pruneCall = calls.find(c => c[0] === "worktree" && c[1] === "prune");
188
+ expect(pruneCall).toBeDefined();
189
+
190
+ // Prune should happen before worktree add
191
+ const pruneIdx = calls.indexOf(pruneCall!);
192
+ const addIdx = calls.findIndex(c => c[0] === "worktree" && c[1] === "add");
193
+ expect(pruneIdx).toBeLessThan(addIdx);
194
+ });
112
195
  });
113
196
 
114
197
  describe("removeWorktree", () => {
@@ -5,6 +5,10 @@
5
5
 
6
6
  import type { WorkspaceInfo } from "../../shared/types";
7
7
  import type { Logger } from "pino";
8
+ import { resolveAvailableCommand } from "../../shared/utils";
9
+
10
+ // Re-export for external use
11
+ export { resolveAvailableCommand };
8
12
 
9
13
  export interface InstallResult {
10
14
  success: boolean;
@@ -72,11 +76,14 @@ export async function installDependencies(
72
76
  return { success: true, output: "", duration: 0, skipped: true };
73
77
  }
74
78
 
75
- const [cmd, ...args] = command;
79
+ const [rawCmd, ...args] = command;
80
+
81
+ // Resolve command with fallback (e.g., yarn → npx if yarn not found)
82
+ const cmd = await resolveAvailableCommand(rawCmd!);
76
83
  logger.info({ cmd, args, cwd: workspace.path }, "Installing dependencies");
77
84
 
78
85
  // Bun.spawn で実行(default-spawner.ts と同パターン)
79
- const proc = Bun.spawn([cmd!, ...args], {
86
+ const proc = Bun.spawn([cmd, ...args], {
80
87
  cwd: workspace.path,
81
88
  stdout: "pipe",
82
89
  stderr: "pipe",
@@ -16,7 +16,8 @@ const PROJECT_CONTEXT_HEADER = "# プロジェクト固有コンテキスト\n\n
16
16
  /**
17
17
  * Resolve template directory path:
18
18
  * 1. Project local override: {projectRoot}/.aad/templates/ (if exists)
19
- * 2. Package bundled: {packageRoot}/templates/
19
+ * 2. Package bundled (dev): {packageRoot}/.aad/templates/ (if exists)
20
+ * 3. Package bundled (npm): {packageRoot}/templates/ (for npm distribution)
20
21
  */
21
22
  export function resolveTemplateDir(projectRoot: string): string {
22
23
  const localTemplateDir = join(projectRoot, ".aad", "templates");
@@ -27,9 +28,17 @@ export function resolveTemplateDir(projectRoot: string): string {
27
28
  }
28
29
 
29
30
  // Fallback to package bundled templates
30
- // src/modules/git-workspace/ -> ../../../.aad/templates
31
+ // src/modules/git-workspace/ -> ../../../
31
32
  const packageRoot = join(import.meta.dir, "..", "..", "..");
32
- return join(packageRoot, ".aad", "templates");
33
+
34
+ // Try .aad/templates first (dev environment)
35
+ const devTemplateDir = join(packageRoot, ".aad", "templates");
36
+ if (existsSync(devTemplateDir)) {
37
+ return devTemplateDir;
38
+ }
39
+
40
+ // Fallback to templates/ (npm distribution)
41
+ return join(packageRoot, "templates");
33
42
  }
34
43
 
35
44
  /**
@@ -127,6 +127,9 @@ export class WorktreeManager {
127
127
  try {
128
128
  await this.fsOps.mkdir(this.worktreeBase, { recursive: true });
129
129
 
130
+ // Clean up stale worktree/branch from previous runs
131
+ await this.cleanupStaleWorktree(worktreePath, branch);
132
+
130
133
  // Create worktree with a new branch based on parentBranch
131
134
  // e.g. git worktree add -b feat/auth-feature/parent <path> main
132
135
  await this.gitOps.gitExec(
@@ -1,13 +1,13 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import {
3
- buildTestCommand,
3
+ buildTestCommandWithFallback,
4
4
  runTests,
5
5
  type ProcessSpawner,
6
6
  } from "../phases/tester-verify";
7
7
  import type { WorkspaceInfo } from "@aad/shared/types";
8
8
 
9
- describe("buildTestCommand", () => {
10
- test("builds bun test command", () => {
9
+ describe("buildTestCommandWithFallback", () => {
10
+ test("builds bun test command", async () => {
11
11
  const workspace: WorkspaceInfo = {
12
12
  path: "/path/to/workspace",
13
13
  language: "typescript",
@@ -16,10 +16,10 @@ describe("buildTestCommand", () => {
16
16
  testFramework: "bun-test",
17
17
  };
18
18
 
19
- expect(buildTestCommand(workspace)).toEqual(["bun", "test"]);
19
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["bun", "test"]);
20
20
  });
21
21
 
22
- test("builds vitest command", () => {
22
+ test("builds vitest command", async () => {
23
23
  const workspace: WorkspaceInfo = {
24
24
  path: "/path/to/workspace",
25
25
  language: "typescript",
@@ -28,10 +28,10 @@ describe("buildTestCommand", () => {
28
28
  testFramework: "vitest",
29
29
  };
30
30
 
31
- expect(buildTestCommand(workspace)).toEqual(["npm", "run", "test"]);
31
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npm", "run", "test"]);
32
32
  });
33
33
 
34
- test("builds jest command", () => {
34
+ test("builds jest command", async () => {
35
35
  const workspace: WorkspaceInfo = {
36
36
  path: "/path/to/workspace",
37
37
  language: "javascript",
@@ -40,10 +40,10 @@ describe("buildTestCommand", () => {
40
40
  testFramework: "jest",
41
41
  };
42
42
 
43
- expect(buildTestCommand(workspace)).toEqual(["yarn", "test"]);
43
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["yarn", "test"]);
44
44
  });
45
45
 
46
- test("builds pytest command", () => {
46
+ test("builds pytest command", async () => {
47
47
  const workspace: WorkspaceInfo = {
48
48
  path: "/path/to/workspace",
49
49
  language: "python",
@@ -52,10 +52,10 @@ describe("buildTestCommand", () => {
52
52
  testFramework: "pytest",
53
53
  };
54
54
 
55
- expect(buildTestCommand(workspace)).toEqual(["pytest", "-v"]);
55
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["pytest", "-v"]);
56
56
  });
57
57
 
58
- test("builds go test command", () => {
58
+ test("builds go test command", async () => {
59
59
  const workspace: WorkspaceInfo = {
60
60
  path: "/path/to/workspace",
61
61
  language: "go",
@@ -64,10 +64,10 @@ describe("buildTestCommand", () => {
64
64
  testFramework: "go-test",
65
65
  };
66
66
 
67
- expect(buildTestCommand(workspace)).toEqual(["go", "test", "./..."]);
67
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["go", "test", "./..."]);
68
68
  });
69
69
 
70
- test("builds cargo test command", () => {
70
+ test("builds cargo test command", async () => {
71
71
  const workspace: WorkspaceInfo = {
72
72
  path: "/path/to/workspace",
73
73
  language: "rust",
@@ -76,10 +76,10 @@ describe("buildTestCommand", () => {
76
76
  testFramework: "cargo",
77
77
  };
78
78
 
79
- expect(buildTestCommand(workspace)).toEqual(["cargo", "test"]);
79
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["cargo", "test"]);
80
80
  });
81
81
 
82
- test("builds vitest command with yarn", () => {
82
+ test("builds vitest command with yarn", async () => {
83
83
  const workspace: WorkspaceInfo = {
84
84
  path: "/path",
85
85
  language: "typescript",
@@ -87,10 +87,10 @@ describe("buildTestCommand", () => {
87
87
  framework: "vite",
88
88
  testFramework: "vitest",
89
89
  };
90
- expect(buildTestCommand(workspace)).toEqual(["yarn", "test"]);
90
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["yarn", "test"]);
91
91
  });
92
92
 
93
- test("builds vitest command with pnpm", () => {
93
+ test("builds vitest command with pnpm", async () => {
94
94
  const workspace: WorkspaceInfo = {
95
95
  path: "/path",
96
96
  language: "typescript",
@@ -98,10 +98,10 @@ describe("buildTestCommand", () => {
98
98
  framework: "vite",
99
99
  testFramework: "vitest",
100
100
  };
101
- expect(buildTestCommand(workspace)).toEqual(["pnpm", "test"]);
101
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["pnpm", "test"]);
102
102
  });
103
103
 
104
- test("builds vitest command with default (npx)", () => {
104
+ test("builds vitest command with default (npx)", async () => {
105
105
  const workspace: WorkspaceInfo = {
106
106
  path: "/path",
107
107
  language: "typescript",
@@ -109,10 +109,10 @@ describe("buildTestCommand", () => {
109
109
  framework: "vite",
110
110
  testFramework: "vitest",
111
111
  };
112
- expect(buildTestCommand(workspace)).toEqual(["npx", "vitest", "run"]);
112
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npx", "vitest", "run"]);
113
113
  });
114
114
 
115
- test("builds jest command with npm", () => {
115
+ test("builds jest command with npm", async () => {
116
116
  const workspace: WorkspaceInfo = {
117
117
  path: "/path",
118
118
  language: "javascript",
@@ -120,10 +120,10 @@ describe("buildTestCommand", () => {
120
120
  framework: "react",
121
121
  testFramework: "jest",
122
122
  };
123
- expect(buildTestCommand(workspace)).toEqual(["npm", "test"]);
123
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npm", "test"]);
124
124
  });
125
125
 
126
- test("builds jest command with pnpm", () => {
126
+ test("builds jest command with pnpm", async () => {
127
127
  const workspace: WorkspaceInfo = {
128
128
  path: "/path",
129
129
  language: "javascript",
@@ -131,10 +131,10 @@ describe("buildTestCommand", () => {
131
131
  framework: "react",
132
132
  testFramework: "jest",
133
133
  };
134
- expect(buildTestCommand(workspace)).toEqual(["pnpm", "test"]);
134
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["pnpm", "test"]);
135
135
  });
136
136
 
137
- test("builds jest command with default (npx)", () => {
137
+ test("builds jest command with default (npx)", async () => {
138
138
  const workspace: WorkspaceInfo = {
139
139
  path: "/path",
140
140
  language: "javascript",
@@ -142,10 +142,10 @@ describe("buildTestCommand", () => {
142
142
  framework: "react",
143
143
  testFramework: "jest",
144
144
  };
145
- expect(buildTestCommand(workspace)).toEqual(["npx", "jest"]);
145
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npx", "jest"]);
146
146
  });
147
147
 
148
- test("builds mocha command with npm", () => {
148
+ test("builds mocha command with npm", async () => {
149
149
  const workspace: WorkspaceInfo = {
150
150
  path: "/path",
151
151
  language: "javascript",
@@ -153,10 +153,10 @@ describe("buildTestCommand", () => {
153
153
  framework: "express",
154
154
  testFramework: "mocha",
155
155
  };
156
- expect(buildTestCommand(workspace)).toEqual(["npm", "test"]);
156
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npm", "test"]);
157
157
  });
158
158
 
159
- test("builds mocha command with yarn", () => {
159
+ test("builds mocha command with yarn", async () => {
160
160
  const workspace: WorkspaceInfo = {
161
161
  path: "/path",
162
162
  language: "javascript",
@@ -164,10 +164,10 @@ describe("buildTestCommand", () => {
164
164
  framework: "express",
165
165
  testFramework: "mocha",
166
166
  };
167
- expect(buildTestCommand(workspace)).toEqual(["yarn", "test"]);
167
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["yarn", "test"]);
168
168
  });
169
169
 
170
- test("builds mocha command with default (npx)", () => {
170
+ test("builds mocha command with default (npx)", async () => {
171
171
  const workspace: WorkspaceInfo = {
172
172
  path: "/path",
173
173
  language: "javascript",
@@ -175,10 +175,10 @@ describe("buildTestCommand", () => {
175
175
  framework: "express",
176
176
  testFramework: "mocha",
177
177
  };
178
- expect(buildTestCommand(workspace)).toEqual(["npx", "mocha"]);
178
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npx", "mocha"]);
179
179
  });
180
180
 
181
- test("builds maven test command", () => {
181
+ test("builds maven test command", async () => {
182
182
  const workspace: WorkspaceInfo = {
183
183
  path: "/path",
184
184
  language: "java",
@@ -186,10 +186,10 @@ describe("buildTestCommand", () => {
186
186
  framework: "spring",
187
187
  testFramework: "maven",
188
188
  };
189
- expect(buildTestCommand(workspace)).toEqual(["mvn", "test"]);
189
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["mvn", "test"]);
190
190
  });
191
191
 
192
- test("builds gradle test command", () => {
192
+ test("builds gradle test command", async () => {
193
193
  const workspace: WorkspaceInfo = {
194
194
  path: "/path",
195
195
  language: "java",
@@ -197,10 +197,10 @@ describe("buildTestCommand", () => {
197
197
  framework: "spring",
198
198
  testFramework: "gradle",
199
199
  };
200
- expect(buildTestCommand(workspace)).toEqual(["./gradlew", "test"]);
200
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["./gradlew", "test"]);
201
201
  });
202
202
 
203
- test("returns fallback for unknown test framework", () => {
203
+ test("returns fallback for unknown test framework", async () => {
204
204
  const workspace: WorkspaceInfo = {
205
205
  path: "/path/to/workspace",
206
206
  language: "unknown",
@@ -210,7 +210,54 @@ describe("buildTestCommand", () => {
210
210
  };
211
211
 
212
212
  // After fallback implementation, unknown should return npm test
213
- expect(buildTestCommand(workspace)).toEqual(["npm", "test"]);
213
+ expect(await buildTestCommandWithFallback(workspace)).toEqual(["npm", "test"]);
214
+ });
215
+
216
+ test("builds vitest with fallback when yarn is unavailable", async () => {
217
+ const workspace: WorkspaceInfo = {
218
+ path: "/path",
219
+ language: "typescript",
220
+ packageManager: "yarn",
221
+ framework: "vite",
222
+ testFramework: "vitest",
223
+ };
224
+
225
+ // Mock Bun.which to simulate yarn not available
226
+ const originalWhich = Bun.which;
227
+ Bun.which = (cmd: string) => {
228
+ if (cmd === "yarn") return null;
229
+ return originalWhich(cmd);
230
+ };
231
+
232
+ try {
233
+ const result = await buildTestCommandWithFallback(workspace);
234
+ expect(result).toEqual(["npx", "yarn", "test"]);
235
+ } finally {
236
+ Bun.which = originalWhich;
237
+ }
238
+ });
239
+
240
+ test("builds jest with fallback when pnpm is unavailable", async () => {
241
+ const workspace: WorkspaceInfo = {
242
+ path: "/path",
243
+ language: "javascript",
244
+ packageManager: "pnpm",
245
+ framework: "react",
246
+ testFramework: "jest",
247
+ };
248
+
249
+ const originalWhich = Bun.which;
250
+ Bun.which = (cmd: string) => {
251
+ if (cmd === "pnpm") return null;
252
+ return originalWhich(cmd);
253
+ };
254
+
255
+ try {
256
+ const result = await buildTestCommandWithFallback(workspace);
257
+ expect(result).toEqual(["npx", "pnpm", "test"]);
258
+ } finally {
259
+ Bun.which = originalWhich;
260
+ }
214
261
  });
215
262
  });
216
263
 
@@ -14,7 +14,7 @@ export {
14
14
  } from "./phases/implementer-green";
15
15
  export type { ImplementerGreenOptions } from "./phases/implementer-green";
16
16
 
17
- export { runTests, buildTestCommand } from "./phases/tester-verify";
17
+ export { runTests, buildTestCommandWithFallback } from "./phases/tester-verify";
18
18
  export type {
19
19
  TestResult,
20
20
  ProcessSpawner,
@@ -1,5 +1,6 @@
1
1
  import type { WorkspaceInfo } from "@aad/shared/types";
2
2
  import { TestRunnerError } from "@aad/shared/errors";
3
+ import { resolveAvailableCommand } from "@aad/shared/utils";
3
4
 
4
5
  export interface ProcessResult {
5
6
  exitCode: number;
@@ -22,34 +23,51 @@ export interface TestResult {
22
23
  }
23
24
 
24
25
  /**
25
- * Build test command based on detected test framework
26
+ * Build test command with fallback support for unavailable package managers
26
27
  */
27
- export function buildTestCommand(workspace: WorkspaceInfo): string[] {
28
+ export async function buildTestCommandWithFallback(workspace: WorkspaceInfo): Promise<string[]> {
28
29
  const { testFramework, packageManager } = workspace;
29
30
 
30
31
  switch (testFramework) {
31
32
  case "bun-test":
32
33
  return ["bun", "test"];
33
34
 
34
- case "vitest":
35
+ case "vitest": {
35
36
  if (packageManager === "npm") return ["npm", "run", "test"];
36
- if (packageManager === "yarn") return ["yarn", "test"];
37
- if (packageManager === "pnpm") return ["pnpm", "test"];
37
+ if (packageManager === "yarn") {
38
+ const cmd = await resolveAvailableCommand("yarn");
39
+ return cmd === "npx" ? ["npx", "yarn", "test"] : ["yarn", "test"];
40
+ }
41
+ if (packageManager === "pnpm") {
42
+ const cmd = await resolveAvailableCommand("pnpm");
43
+ return cmd === "npx" ? ["npx", "pnpm", "test"] : ["pnpm", "test"];
44
+ }
38
45
  return ["npx", "vitest", "run"];
46
+ }
39
47
 
40
- case "jest":
48
+ case "jest": {
41
49
  if (packageManager === "npm") return ["npm", "test"];
42
- if (packageManager === "yarn") return ["yarn", "test"];
43
- if (packageManager === "pnpm") return ["pnpm", "test"];
50
+ if (packageManager === "yarn") {
51
+ const cmd = await resolveAvailableCommand("yarn");
52
+ return cmd === "npx" ? ["npx", "yarn", "test"] : ["yarn", "test"];
53
+ }
54
+ if (packageManager === "pnpm") {
55
+ const cmd = await resolveAvailableCommand("pnpm");
56
+ return cmd === "npx" ? ["npx", "pnpm", "test"] : ["pnpm", "test"];
57
+ }
44
58
  return ["npx", "jest"];
59
+ }
45
60
 
46
- case "mocha":
61
+ case "mocha": {
47
62
  if (packageManager === "npm") return ["npm", "test"];
48
- if (packageManager === "yarn") return ["yarn", "test"];
63
+ if (packageManager === "yarn") {
64
+ const cmd = await resolveAvailableCommand("yarn");
65
+ return cmd === "npx" ? ["npx", "yarn", "test"] : ["yarn", "test"];
66
+ }
49
67
  return ["npx", "mocha"];
68
+ }
50
69
 
51
70
  case "pytest": {
52
- const { packageManager } = workspace;
53
71
  if (packageManager === "uv") return ["uv", "run", "pytest", "-v"];
54
72
  if (packageManager === "poetry") return ["poetry", "run", "pytest", "-v"];
55
73
  return ["pytest", "-v"];
@@ -74,8 +92,6 @@ export function buildTestCommand(workspace: WorkspaceInfo): string[] {
74
92
  return ["terraform", "validate"];
75
93
 
76
94
  case "unknown": {
77
- // Fallback: use package manager-based test command
78
- const { packageManager } = workspace;
79
95
  if (packageManager === "bun") return ["bun", "test"];
80
96
  if (packageManager === "npm") return ["npm", "test"];
81
97
  if (packageManager === "yarn") return ["yarn", "test"];
@@ -95,6 +111,7 @@ export function buildTestCommand(workspace: WorkspaceInfo): string[] {
95
111
  }
96
112
  }
97
113
 
114
+
98
115
  /**
99
116
  * Run tests in workspace and return result
100
117
  * @param workspace - Workspace information with test framework
@@ -106,7 +123,7 @@ export async function runTests(
106
123
  spawner?: ProcessSpawner,
107
124
  timeout = 300000
108
125
  ): Promise<TestResult> {
109
- const command = buildTestCommand(workspace);
126
+ const command = await buildTestCommandWithFallback(workspace);
110
127
  const [cmd, ...args] = command;
111
128
 
112
129
  if (!cmd) {
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { capitalize, toLowerCase, trim, reverse, toUpperCase } from "../utils";
2
+ import { capitalize, toLowerCase, trim, reverse, toUpperCase, resolveAvailableCommand } from "../utils";
3
3
 
4
4
  describe("capitalize", () => {
5
5
  test.each([
@@ -292,3 +292,23 @@ describe("toUpperCase", () => {
292
292
  expect(toUpperCase(input)).toBe(expected);
293
293
  });
294
294
  });
295
+
296
+ describe("resolveAvailableCommand", () => {
297
+ test("returns command if binary exists in PATH", async () => {
298
+ // bun should exist in PATH during tests
299
+ const result = await resolveAvailableCommand("bun");
300
+ expect(result).toBe("bun");
301
+ });
302
+
303
+ test("falls back to npx if binary does not exist in PATH", async () => {
304
+ // Use a non-existent binary name
305
+ const result = await resolveAvailableCommand("nonexistent-binary-xyz123");
306
+ expect(result).toBe("npx");
307
+ });
308
+
309
+ test("handles common package managers", async () => {
310
+ // npm should always be available if bun is available
311
+ const npmResult = await resolveAvailableCommand("npm");
312
+ expect(npmResult).toBe("npm");
313
+ });
314
+ });
@@ -1,5 +1,16 @@
1
1
  // Utility functions for shared functionality
2
2
 
3
+ /**
4
+ * コマンドのバイナリが $PATH に存在するか確認し、
5
+ * 存在しない場合は npx にフォールバックする
6
+ * @param command - The command to check
7
+ * @returns The command if available, otherwise "npx"
8
+ */
9
+ export async function resolveAvailableCommand(command: string): Promise<string> {
10
+ const available = await Bun.which(command);
11
+ return available ? command : "npx";
12
+ }
13
+
3
14
  /**
4
15
  * Capitalizes the first character of a string.
5
16
  * @param str - The string to capitalize