prjct-cli 0.59.0 → 0.60.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.
@@ -0,0 +1,659 @@
1
+ /**
2
+ * Command Executor Tests
3
+ * PRJ-82: Unit tests for command execution pipeline
4
+ */
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'
7
+ import fs from 'node:fs'
8
+ import fsPromises from 'node:fs/promises'
9
+ import os from 'node:os'
10
+ import path from 'node:path'
11
+ // Import dependencies to mock
12
+ import chainOfThought from '../../agentic/chain-of-thought'
13
+ // Import the module under test
14
+ import commandExecutor, {
15
+ CommandExecutor,
16
+ signalEnd,
17
+ signalStart,
18
+ } from '../../agentic/command-executor'
19
+ import contextBuilder from '../../agentic/context-builder'
20
+ import groundTruth from '../../agentic/ground-truth'
21
+ import loopDetector from '../../agentic/loop-detector'
22
+ import memorySystem from '../../agentic/memory-system'
23
+ import planMode from '../../agentic/plan-mode'
24
+ import promptBuilder from '../../agentic/prompt-builder'
25
+ import templateExecutor from '../../agentic/template-executor'
26
+ import templateLoader from '../../agentic/template-loader'
27
+ import toolRegistry from '../../agentic/tool-registry'
28
+
29
+ // =============================================================================
30
+ // Test Setup
31
+ // =============================================================================
32
+
33
+ const TEST_BASE_DIR = path.join(process.cwd(), '.tmp', 'prjct-cli-tests', 'command-executor')
34
+ const RUNNING_FILE = path.join(os.homedir(), '.prjct-cli', '.running')
35
+
36
+ let testCounter = 0
37
+ const getTestProjectId = () => `test-cmd-exec-${Date.now()}-${++testCounter}`
38
+
39
+ // Store original implementations for restoration
40
+ const originalFunctions: Record<string, unknown> = {}
41
+
42
+ // =============================================================================
43
+ // Mock Factories
44
+ // =============================================================================
45
+
46
+ function createMockTemplate(overrides = {}) {
47
+ return {
48
+ name: 'test-command',
49
+ content: '# Test Command\n\nTest content',
50
+ frontmatter: {
51
+ 'allowed-tools': ['Read', 'Write', 'Bash'],
52
+ ...overrides,
53
+ },
54
+ }
55
+ }
56
+
57
+ function createMockContext(projectId: string, overrides = {}) {
58
+ return {
59
+ projectId,
60
+ projectPath: TEST_BASE_DIR,
61
+ globalPath: TEST_BASE_DIR,
62
+ projectName: 'test-project',
63
+ ecosystem: 'node',
64
+ paths: {},
65
+ params: {},
66
+ timestamp: new Date().toISOString(),
67
+ date: new Date().toISOString().split('T')[0],
68
+ ...overrides,
69
+ } as unknown as ReturnType<typeof contextBuilder.build> extends Promise<infer T> ? T : never
70
+ }
71
+
72
+ function createMockState(overrides = {}) {
73
+ return {
74
+ currentTask: null,
75
+ pausedTasks: [],
76
+ ...overrides,
77
+ } as Record<string, unknown>
78
+ }
79
+
80
+ // =============================================================================
81
+ // Tests: signalStart / signalEnd
82
+ // =============================================================================
83
+
84
+ describe('CommandExecutor', () => {
85
+ beforeAll(async () => {
86
+ await fsPromises.mkdir(TEST_BASE_DIR, { recursive: true })
87
+ })
88
+
89
+ afterAll(async () => {
90
+ try {
91
+ await fsPromises.rm(TEST_BASE_DIR, { recursive: true, force: true })
92
+ } catch (_error) {
93
+ // Ignore cleanup errors
94
+ }
95
+ })
96
+
97
+ describe('signalStart', () => {
98
+ afterEach(() => {
99
+ // Clean up the running file after each test
100
+ try {
101
+ if (fs.existsSync(RUNNING_FILE)) {
102
+ fs.unlinkSync(RUNNING_FILE)
103
+ }
104
+ } catch (_error) {
105
+ // Ignore
106
+ }
107
+ })
108
+
109
+ it('should create status file with command name', () => {
110
+ signalStart('test-command')
111
+
112
+ expect(fs.existsSync(RUNNING_FILE)).toBe(true)
113
+ const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
114
+ expect(content).toBe('/p:test-command')
115
+ })
116
+
117
+ it('should overwrite existing status file', () => {
118
+ signalStart('first-command')
119
+ signalStart('second-command')
120
+
121
+ const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
122
+ expect(content).toBe('/p:second-command')
123
+ })
124
+
125
+ it('should handle filesystem errors gracefully', () => {
126
+ // This test verifies that errors are silently ignored
127
+ // We can't easily simulate fs errors, but we can verify the function doesn't throw
128
+ expect(() => signalStart('test-command')).not.toThrow()
129
+ })
130
+ })
131
+
132
+ describe('signalEnd', () => {
133
+ it('should remove status file if it exists', () => {
134
+ // Create the file first
135
+ signalStart('test-command')
136
+ expect(fs.existsSync(RUNNING_FILE)).toBe(true)
137
+
138
+ signalEnd()
139
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
140
+ })
141
+
142
+ it('should not throw if file does not exist', () => {
143
+ // Ensure file doesn't exist
144
+ try {
145
+ fs.unlinkSync(RUNNING_FILE)
146
+ } catch (_error) {
147
+ // Ignore
148
+ }
149
+
150
+ expect(() => signalEnd()).not.toThrow()
151
+ })
152
+ })
153
+
154
+ describe('CommandExecutor class', () => {
155
+ let executor: CommandExecutor
156
+
157
+ beforeEach(() => {
158
+ executor = new CommandExecutor()
159
+ })
160
+
161
+ it('should have signalStart method that calls module function', () => {
162
+ executor.signalStart('class-test')
163
+
164
+ expect(fs.existsSync(RUNNING_FILE)).toBe(true)
165
+ const content = fs.readFileSync(RUNNING_FILE, 'utf-8')
166
+ expect(content).toBe('/p:class-test')
167
+
168
+ // Cleanup
169
+ executor.signalEnd()
170
+ })
171
+
172
+ it('should have signalEnd method that calls module function', () => {
173
+ executor.signalStart('class-test')
174
+ executor.signalEnd()
175
+
176
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
177
+ })
178
+ })
179
+ })
180
+
181
+ // =============================================================================
182
+ // Tests: executeTool
183
+ // =============================================================================
184
+
185
+ describe('executeTool', () => {
186
+ let executor: CommandExecutor
187
+
188
+ beforeAll(() => {
189
+ // Store original toolRegistry methods
190
+ originalFunctions['toolRegistry.isAllowed'] = toolRegistry.isAllowed
191
+ originalFunctions['toolRegistry.get'] = toolRegistry.get
192
+ })
193
+
194
+ beforeEach(() => {
195
+ executor = new CommandExecutor()
196
+ })
197
+
198
+ afterEach(() => {
199
+ // Restore original methods
200
+ toolRegistry.isAllowed = originalFunctions[
201
+ 'toolRegistry.isAllowed'
202
+ ] as typeof toolRegistry.isAllowed
203
+ toolRegistry.get = originalFunctions['toolRegistry.get'] as typeof toolRegistry.get
204
+ })
205
+
206
+ it('should reject tool not in allowed list', async () => {
207
+ toolRegistry.isAllowed = mock(() => false)
208
+
209
+ await expect(executor.executeTool('DangerousTool', [], ['Read', 'Write'])).rejects.toThrow(
210
+ 'Tool DangerousTool not allowed for this command'
211
+ )
212
+ })
213
+
214
+ it('should throw if tool not found in registry', async () => {
215
+ toolRegistry.isAllowed = mock(() => true)
216
+ toolRegistry.get = mock(() => undefined)
217
+
218
+ await expect(executor.executeTool('NonExistent', [], ['NonExistent'])).rejects.toThrow(
219
+ 'Tool NonExistent not found'
220
+ )
221
+ })
222
+
223
+ it('should execute allowed tool successfully', async () => {
224
+ const mockToolFn = mock(() => Promise.resolve('tool-result'))
225
+
226
+ toolRegistry.isAllowed = mock(() => true)
227
+ toolRegistry.get = mock(() => mockToolFn)
228
+
229
+ const result = await executor.executeTool('Read', ['/path/to/file'], ['Read'])
230
+
231
+ expect(result).toBe('tool-result')
232
+ expect(mockToolFn).toHaveBeenCalledWith('/path/to/file')
233
+ })
234
+
235
+ it('should pass multiple arguments to tool', async () => {
236
+ const mockToolFn = mock(() => Promise.resolve('written'))
237
+
238
+ toolRegistry.isAllowed = mock(() => true)
239
+ toolRegistry.get = mock(() => mockToolFn)
240
+
241
+ const result = await executor.executeTool('Write', ['/path', 'content'], ['Write'])
242
+
243
+ expect(result).toBe('written')
244
+ expect(mockToolFn).toHaveBeenCalledWith('/path', 'content')
245
+ })
246
+ })
247
+
248
+ // =============================================================================
249
+ // Tests: execute (main flow)
250
+ // =============================================================================
251
+
252
+ describe('execute', () => {
253
+ let executor: CommandExecutor
254
+ let TEST_PROJECT_ID: string
255
+
256
+ beforeAll(() => {
257
+ // Store original implementations
258
+ originalFunctions['loopDetector.shouldEscalate'] = loopDetector.shouldEscalate
259
+ originalFunctions['loopDetector.getEscalationInfo'] = loopDetector.getEscalationInfo
260
+ originalFunctions['loopDetector.recordSuccess'] = loopDetector.recordSuccess
261
+ originalFunctions['loopDetector.recordAttempt'] = loopDetector.recordAttempt
262
+ originalFunctions['templateLoader.load'] = templateLoader.load
263
+ originalFunctions['contextBuilder.build'] = contextBuilder.build
264
+ originalFunctions['contextBuilder.loadState'] = contextBuilder.loadState
265
+ originalFunctions['contextBuilder.loadStateForCommand'] = contextBuilder.loadStateForCommand
266
+ originalFunctions['planMode.requiresPlanning'] = planMode.requiresPlanning
267
+ originalFunctions['planMode.isDestructive'] = planMode.isDestructive
268
+ originalFunctions['planMode.isInPlanningMode'] = planMode.isInPlanningMode
269
+ originalFunctions['planMode.getAllowedTools'] = planMode.getAllowedTools
270
+ originalFunctions['groundTruth.requiresVerification'] = groundTruth.requiresVerification
271
+ originalFunctions['chainOfThought.requiresReasoning'] = chainOfThought.requiresReasoning
272
+ originalFunctions['templateExecutor.buildContext'] = templateExecutor.buildContext
273
+ originalFunctions['templateExecutor.buildAgenticPrompt'] = templateExecutor.buildAgenticPrompt
274
+ originalFunctions['templateExecutor.requiresOrchestration'] =
275
+ templateExecutor.requiresOrchestration
276
+ originalFunctions['memorySystem.getSmartDecision'] = memorySystem.getSmartDecision
277
+ originalFunctions['memorySystem.getRelevantMemories'] = memorySystem.getRelevantMemories
278
+ originalFunctions['promptBuilder.build'] = promptBuilder.build
279
+ })
280
+
281
+ beforeEach(() => {
282
+ executor = new CommandExecutor()
283
+ TEST_PROJECT_ID = getTestProjectId()
284
+
285
+ // Reset all mocks to default behavior
286
+ loopDetector.shouldEscalate = mock(() => false)
287
+ loopDetector.getEscalationInfo = mock(() => null)
288
+ loopDetector.recordSuccess = mock(() => {})
289
+ loopDetector.recordAttempt = mock(() => ({
290
+ attemptNumber: 1,
291
+ isLooping: false,
292
+ shouldEscalate: false,
293
+ }))
294
+
295
+ // Mock planMode
296
+ planMode.requiresPlanning = mock(() => false)
297
+ planMode.isDestructive = mock(() => false)
298
+ planMode.isInPlanningMode = mock(() => false)
299
+ planMode.getAllowedTools = mock(() => ['Read', 'Write', 'Bash'])
300
+
301
+ // Mock groundTruth and chainOfThought
302
+ groundTruth.requiresVerification = mock(() => false)
303
+ chainOfThought.requiresReasoning = mock(() => false)
304
+
305
+ // Mock templateExecutor
306
+ templateExecutor.buildContext = mock(() =>
307
+ Promise.resolve({
308
+ projectPath: TEST_BASE_DIR,
309
+ projectId: TEST_PROJECT_ID,
310
+ globalPath: TEST_BASE_DIR,
311
+ command: 'test-cmd',
312
+ args: '',
313
+ agentName: 'test-agent',
314
+ agentSettingsPath: '',
315
+ paths: {
316
+ orchestrator: '',
317
+ agentRouting: '',
318
+ taskFragmentation: '',
319
+ commandTemplate: '',
320
+ repoAnalysis: '',
321
+ agentsDir: '',
322
+ skillsDir: '',
323
+ stateJson: '',
324
+ },
325
+ })
326
+ )
327
+ templateExecutor.buildAgenticPrompt = mock(() => ({
328
+ prompt: 'test prompt',
329
+ context: {} as never,
330
+ requiresOrchestration: false,
331
+ }))
332
+ templateExecutor.requiresOrchestration = mock(() => false)
333
+
334
+ // Mock memorySystem
335
+ memorySystem.getSmartDecision = mock(() => Promise.resolve(null))
336
+ memorySystem.getRelevantMemories = mock(() => Promise.resolve([]))
337
+
338
+ // Mock promptBuilder
339
+ promptBuilder.build = mock(() => 'built prompt')
340
+ })
341
+
342
+ afterEach(() => {
343
+ // Restore original implementations
344
+ loopDetector.shouldEscalate = originalFunctions[
345
+ 'loopDetector.shouldEscalate'
346
+ ] as typeof loopDetector.shouldEscalate
347
+ loopDetector.getEscalationInfo = originalFunctions[
348
+ 'loopDetector.getEscalationInfo'
349
+ ] as typeof loopDetector.getEscalationInfo
350
+ loopDetector.recordSuccess = originalFunctions[
351
+ 'loopDetector.recordSuccess'
352
+ ] as typeof loopDetector.recordSuccess
353
+ loopDetector.recordAttempt = originalFunctions[
354
+ 'loopDetector.recordAttempt'
355
+ ] as typeof loopDetector.recordAttempt
356
+ templateLoader.load = originalFunctions['templateLoader.load'] as typeof templateLoader.load
357
+ contextBuilder.build = originalFunctions['contextBuilder.build'] as typeof contextBuilder.build
358
+ contextBuilder.loadState = originalFunctions[
359
+ 'contextBuilder.loadState'
360
+ ] as typeof contextBuilder.loadState
361
+ contextBuilder.loadStateForCommand = originalFunctions[
362
+ 'contextBuilder.loadStateForCommand'
363
+ ] as typeof contextBuilder.loadStateForCommand
364
+ planMode.requiresPlanning = originalFunctions[
365
+ 'planMode.requiresPlanning'
366
+ ] as typeof planMode.requiresPlanning
367
+ planMode.isDestructive = originalFunctions[
368
+ 'planMode.isDestructive'
369
+ ] as typeof planMode.isDestructive
370
+ planMode.isInPlanningMode = originalFunctions[
371
+ 'planMode.isInPlanningMode'
372
+ ] as typeof planMode.isInPlanningMode
373
+ planMode.getAllowedTools = originalFunctions[
374
+ 'planMode.getAllowedTools'
375
+ ] as typeof planMode.getAllowedTools
376
+ groundTruth.requiresVerification = originalFunctions[
377
+ 'groundTruth.requiresVerification'
378
+ ] as typeof groundTruth.requiresVerification
379
+ chainOfThought.requiresReasoning = originalFunctions[
380
+ 'chainOfThought.requiresReasoning'
381
+ ] as typeof chainOfThought.requiresReasoning
382
+ templateExecutor.buildContext = originalFunctions[
383
+ 'templateExecutor.buildContext'
384
+ ] as typeof templateExecutor.buildContext
385
+ templateExecutor.buildAgenticPrompt = originalFunctions[
386
+ 'templateExecutor.buildAgenticPrompt'
387
+ ] as typeof templateExecutor.buildAgenticPrompt
388
+ templateExecutor.requiresOrchestration = originalFunctions[
389
+ 'templateExecutor.requiresOrchestration'
390
+ ] as typeof templateExecutor.requiresOrchestration
391
+ memorySystem.getSmartDecision = originalFunctions[
392
+ 'memorySystem.getSmartDecision'
393
+ ] as typeof memorySystem.getSmartDecision
394
+ memorySystem.getRelevantMemories = originalFunctions[
395
+ 'memorySystem.getRelevantMemories'
396
+ ] as typeof memorySystem.getRelevantMemories
397
+ promptBuilder.build = originalFunctions['promptBuilder.build'] as typeof promptBuilder.build
398
+
399
+ // Clean up running file
400
+ try {
401
+ if (fs.existsSync(RUNNING_FILE)) {
402
+ fs.unlinkSync(RUNNING_FILE)
403
+ }
404
+ } catch (_error) {
405
+ // Ignore
406
+ }
407
+ })
408
+
409
+ describe('loop detection', () => {
410
+ it('should return escalation when loop detected before execution', async () => {
411
+ loopDetector.shouldEscalate = mock(() => true)
412
+ loopDetector.getEscalationInfo = mock(() => ({
413
+ message: 'Command stuck in loop',
414
+ suggestion: 'Try a different approach',
415
+ attemptCount: 3,
416
+ })) as unknown as typeof loopDetector.getEscalationInfo
417
+
418
+ const result = await executor.execute('test-cmd', {}, TEST_BASE_DIR)
419
+
420
+ expect(result.success).toBe(false)
421
+ expect(result.isLoopDetected).toBe(true)
422
+ expect(result.error).toBe('Command stuck in loop')
423
+ expect(result.suggestion).toBe('Try a different approach')
424
+ })
425
+
426
+ it('should record successful execution', async () => {
427
+ const mockTemplate = createMockTemplate()
428
+ const mockContext = createMockContext(TEST_PROJECT_ID)
429
+ const mockState = createMockState()
430
+
431
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
432
+ contextBuilder.build = mock(() => Promise.resolve(mockContext)) as typeof contextBuilder.build
433
+ contextBuilder.loadState = mock(() =>
434
+ Promise.resolve(mockState)
435
+ ) as typeof contextBuilder.loadState
436
+ contextBuilder.loadStateForCommand = mock(() =>
437
+ Promise.resolve(mockState)
438
+ ) as typeof contextBuilder.loadStateForCommand
439
+
440
+ await executor.execute('test-cmd', { task: 'test task' }, TEST_BASE_DIR)
441
+
442
+ expect(loopDetector.recordSuccess).toHaveBeenCalled()
443
+ })
444
+ })
445
+
446
+ describe('error handling', () => {
447
+ it('should handle template loading errors', async () => {
448
+ templateLoader.load = mock(() => Promise.reject(new Error('Template not found')))
449
+
450
+ const result = await executor.execute('nonexistent', {}, TEST_BASE_DIR)
451
+
452
+ expect(result.success).toBe(false)
453
+ expect(result.error).toBe('Template not found')
454
+ })
455
+
456
+ it('should handle context building errors', async () => {
457
+ const mockTemplate = createMockTemplate()
458
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
459
+ contextBuilder.build = mock(() => Promise.reject(new Error('Context build failed')))
460
+
461
+ const result = await executor.execute('test-cmd', {}, TEST_BASE_DIR)
462
+
463
+ expect(result.success).toBe(false)
464
+ expect(result.error).toBe('Context build failed')
465
+ })
466
+
467
+ it('should record failed attempts for loop detection', async () => {
468
+ templateLoader.load = mock(() => Promise.reject(new Error('Some error')))
469
+
470
+ await executor.execute('test-cmd', { task: 'test task' }, TEST_BASE_DIR)
471
+
472
+ expect(loopDetector.recordAttempt).toHaveBeenCalled()
473
+ })
474
+
475
+ it('should escalate after repeated failures', async () => {
476
+ templateLoader.load = mock(() => Promise.reject(new Error('Repeated error')))
477
+ loopDetector.recordAttempt = mock(() => ({
478
+ attemptNumber: 3,
479
+ isLooping: true,
480
+ shouldEscalate: true,
481
+ }))
482
+ loopDetector.getEscalationInfo = mock(() => ({
483
+ message: 'Too many failures',
484
+ suggestion: 'Check your setup',
485
+ attemptCount: 3,
486
+ })) as unknown as typeof loopDetector.getEscalationInfo
487
+
488
+ const result = await executor.execute('test-cmd', {}, TEST_BASE_DIR)
489
+
490
+ expect(result.success).toBe(false)
491
+ expect(result.isLoopDetected).toBe(true)
492
+ expect(result.error).toBe('Too many failures')
493
+ })
494
+ })
495
+
496
+ describe('signal lifecycle', () => {
497
+ it('should call signalStart at beginning and signalEnd on success', async () => {
498
+ const mockTemplate = createMockTemplate()
499
+ const mockContext = createMockContext(TEST_PROJECT_ID)
500
+ const mockState = createMockState()
501
+
502
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
503
+ contextBuilder.build = mock(() => Promise.resolve(mockContext)) as typeof contextBuilder.build
504
+ contextBuilder.loadState = mock(() =>
505
+ Promise.resolve(mockState)
506
+ ) as typeof contextBuilder.loadState
507
+ contextBuilder.loadStateForCommand = mock(() =>
508
+ Promise.resolve(mockState)
509
+ ) as typeof contextBuilder.loadStateForCommand
510
+
511
+ // File shouldn't exist before
512
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
513
+
514
+ await executor.execute('test-cmd', {}, TEST_BASE_DIR)
515
+
516
+ // File should be cleaned up after
517
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
518
+ })
519
+
520
+ it('should call signalEnd on error', async () => {
521
+ templateLoader.load = mock(() => Promise.reject(new Error('Test error')))
522
+
523
+ await executor.execute('test-cmd', {}, TEST_BASE_DIR)
524
+
525
+ // File should be cleaned up even after error
526
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
527
+ })
528
+
529
+ it('should call signalEnd on loop detection', async () => {
530
+ loopDetector.shouldEscalate = mock(() => true)
531
+ loopDetector.getEscalationInfo = mock(() => ({
532
+ message: 'Loop detected',
533
+ suggestion: 'Stop',
534
+ attemptCount: 3,
535
+ })) as unknown as typeof loopDetector.getEscalationInfo
536
+
537
+ await executor.execute('test-cmd', {}, TEST_BASE_DIR)
538
+
539
+ expect(fs.existsSync(RUNNING_FILE)).toBe(false)
540
+ })
541
+ })
542
+ })
543
+
544
+ // =============================================================================
545
+ // Tests: executeSimple
546
+ // =============================================================================
547
+
548
+ describe('executeSimple', () => {
549
+ let executor: CommandExecutor
550
+ let TEST_PROJECT_ID: string
551
+
552
+ beforeAll(() => {
553
+ originalFunctions['templateLoader.load'] = templateLoader.load
554
+ originalFunctions['contextBuilder.build'] = contextBuilder.build
555
+ originalFunctions['toolRegistry.isAllowed'] = toolRegistry.isAllowed
556
+ originalFunctions['toolRegistry.get'] = toolRegistry.get
557
+ })
558
+
559
+ beforeEach(() => {
560
+ executor = new CommandExecutor()
561
+ TEST_PROJECT_ID = getTestProjectId()
562
+ })
563
+
564
+ afterEach(() => {
565
+ templateLoader.load = originalFunctions['templateLoader.load'] as typeof templateLoader.load
566
+ contextBuilder.build = originalFunctions['contextBuilder.build'] as typeof contextBuilder.build
567
+ toolRegistry.isAllowed = originalFunctions[
568
+ 'toolRegistry.isAllowed'
569
+ ] as typeof toolRegistry.isAllowed
570
+ toolRegistry.get = originalFunctions['toolRegistry.get'] as typeof toolRegistry.get
571
+ })
572
+
573
+ it('should execute function with tools proxy', async () => {
574
+ const mockTemplate = createMockTemplate({ 'allowed-tools': ['Read'] })
575
+ const mockContext = createMockContext(TEST_PROJECT_ID)
576
+
577
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
578
+ contextBuilder.build = mock(() => Promise.resolve(mockContext)) as typeof contextBuilder.build
579
+ toolRegistry.isAllowed = mock(() => true)
580
+ toolRegistry.get = mock(() => mock(() => Promise.resolve('file content')))
581
+
582
+ const executionFn = mock(async (tools: { read: (path: string) => Promise<unknown> }) => {
583
+ const content = await tools.read('/some/file')
584
+ return { content }
585
+ })
586
+
587
+ const result = await executor.executeSimple('test-cmd', executionFn, TEST_BASE_DIR)
588
+
589
+ expect(result.success).toBe(true)
590
+ expect(result.result).toEqual({ content: 'file content' })
591
+ })
592
+
593
+ it('should check tool permissions in proxy', async () => {
594
+ const mockTemplate = createMockTemplate({ 'allowed-tools': ['Read'] }) // Only Read allowed
595
+ const mockContext = createMockContext(TEST_PROJECT_ID)
596
+
597
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
598
+ contextBuilder.build = mock(() => Promise.resolve(mockContext)) as typeof contextBuilder.build
599
+ toolRegistry.isAllowed = mock((tool: string, allowed: string[]) => allowed.includes(tool))
600
+
601
+ const executionFn = mock(
602
+ async (tools: { write: (path: string, content: string) => Promise<unknown> }) => {
603
+ await tools.write('/some/file', 'content') // Try to use Write (not allowed)
604
+ return {}
605
+ }
606
+ )
607
+
608
+ const result = await executor.executeSimple('test-cmd', executionFn, TEST_BASE_DIR)
609
+
610
+ expect(result.success).toBe(false)
611
+ expect(result.error).toContain('not allowed')
612
+ })
613
+
614
+ it('should handle execution function errors', async () => {
615
+ const mockTemplate = createMockTemplate()
616
+ const mockContext = createMockContext(TEST_PROJECT_ID)
617
+
618
+ templateLoader.load = mock(() => Promise.resolve(mockTemplate))
619
+ contextBuilder.build = mock(() => Promise.resolve(mockContext)) as typeof contextBuilder.build
620
+
621
+ const executionFn = mock(async () => {
622
+ throw new Error('Execution failed')
623
+ })
624
+
625
+ const result = await executor.executeSimple('test-cmd', executionFn, TEST_BASE_DIR)
626
+
627
+ expect(result.success).toBe(false)
628
+ expect(result.error).toBe('Execution failed')
629
+ })
630
+
631
+ it('should handle template loading errors', async () => {
632
+ templateLoader.load = mock(() => Promise.reject(new Error('Template not found')))
633
+
634
+ const executionFn = mock(async () => ({}))
635
+
636
+ const result = await executor.executeSimple('nonexistent', executionFn, TEST_BASE_DIR)
637
+
638
+ expect(result.success).toBe(false)
639
+ expect(result.error).toBe('Template not found')
640
+ })
641
+ })
642
+
643
+ // =============================================================================
644
+ // Tests: Default Export
645
+ // =============================================================================
646
+
647
+ describe('default export', () => {
648
+ it('should export singleton instance', () => {
649
+ expect(commandExecutor).toBeInstanceOf(CommandExecutor)
650
+ })
651
+
652
+ it('should have all expected methods', () => {
653
+ expect(typeof commandExecutor.signalStart).toBe('function')
654
+ expect(typeof commandExecutor.signalEnd).toBe('function')
655
+ expect(typeof commandExecutor.execute).toBe('function')
656
+ expect(typeof commandExecutor.executeTool).toBe('function')
657
+ expect(typeof commandExecutor.executeSimple).toBe('function')
658
+ })
659
+ })