openclaw-cascade-plugin 1.0.0

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.
Files changed (98) hide show
  1. package/PHASE1_SUMMARY.md +191 -0
  2. package/PHASE3_SUMMARY.md +195 -0
  3. package/README.md +43 -0
  4. package/dist/a2a-client.d.ts +17 -0
  5. package/dist/a2a-client.d.ts.map +1 -0
  6. package/dist/a2a-client.js +47 -0
  7. package/dist/a2a-client.js.map +1 -0
  8. package/dist/cascade-client.d.ts +53 -0
  9. package/dist/cascade-client.d.ts.map +1 -0
  10. package/dist/cascade-client.js +179 -0
  11. package/dist/cascade-client.js.map +1 -0
  12. package/dist/config.d.ts +26 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +116 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/index.d.ts +29 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +136 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/python-manager.d.ts +59 -0
  21. package/dist/python-manager.d.ts.map +1 -0
  22. package/dist/python-manager.js +190 -0
  23. package/dist/python-manager.js.map +1 -0
  24. package/dist/test-utils/helpers.d.ts +20 -0
  25. package/dist/test-utils/helpers.d.ts.map +1 -0
  26. package/dist/test-utils/helpers.js +89 -0
  27. package/dist/test-utils/helpers.js.map +1 -0
  28. package/dist/test-utils/index.d.ts +3 -0
  29. package/dist/test-utils/index.d.ts.map +1 -0
  30. package/dist/test-utils/index.js +19 -0
  31. package/dist/test-utils/index.js.map +1 -0
  32. package/dist/test-utils/mocks.d.ts +51 -0
  33. package/dist/test-utils/mocks.d.ts.map +1 -0
  34. package/dist/test-utils/mocks.js +84 -0
  35. package/dist/test-utils/mocks.js.map +1 -0
  36. package/dist/tools/a2a-tools.d.ts +9 -0
  37. package/dist/tools/a2a-tools.d.ts.map +1 -0
  38. package/dist/tools/a2a-tools.js +147 -0
  39. package/dist/tools/a2a-tools.js.map +1 -0
  40. package/dist/tools/api-tools.d.ts +9 -0
  41. package/dist/tools/api-tools.d.ts.map +1 -0
  42. package/dist/tools/api-tools.js +102 -0
  43. package/dist/tools/api-tools.js.map +1 -0
  44. package/dist/tools/desktop-automation.d.ts +10 -0
  45. package/dist/tools/desktop-automation.d.ts.map +1 -0
  46. package/dist/tools/desktop-automation.js +330 -0
  47. package/dist/tools/desktop-automation.js.map +1 -0
  48. package/dist/tools/index.d.ts +12 -0
  49. package/dist/tools/index.d.ts.map +1 -0
  50. package/dist/tools/index.js +35 -0
  51. package/dist/tools/index.js.map +1 -0
  52. package/dist/tools/response-helpers.d.ts +25 -0
  53. package/dist/tools/response-helpers.d.ts.map +1 -0
  54. package/dist/tools/response-helpers.js +71 -0
  55. package/dist/tools/response-helpers.js.map +1 -0
  56. package/dist/tools/sandbox-tools.d.ts +9 -0
  57. package/dist/tools/sandbox-tools.d.ts.map +1 -0
  58. package/dist/tools/sandbox-tools.js +79 -0
  59. package/dist/tools/sandbox-tools.js.map +1 -0
  60. package/dist/tools/tool-registry.d.ts +34 -0
  61. package/dist/tools/tool-registry.d.ts.map +1 -0
  62. package/dist/tools/tool-registry.js +50 -0
  63. package/dist/tools/tool-registry.js.map +1 -0
  64. package/dist/tools/web-automation.d.ts +9 -0
  65. package/dist/tools/web-automation.d.ts.map +1 -0
  66. package/dist/tools/web-automation.js +471 -0
  67. package/dist/tools/web-automation.js.map +1 -0
  68. package/dist/types/index.d.ts +111 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/dist/types/index.js +38 -0
  71. package/dist/types/index.js.map +1 -0
  72. package/jest.setup.js +19 -0
  73. package/openclaw-cascade-plugin-1.0.0.tgz +0 -0
  74. package/openclaw.plugin.json +116 -0
  75. package/package.json +74 -0
  76. package/src/a2a-client.ts +66 -0
  77. package/src/cascade-client.test.ts +400 -0
  78. package/src/cascade-client.ts +198 -0
  79. package/src/config.test.ts +194 -0
  80. package/src/config.ts +135 -0
  81. package/src/index.ts +164 -0
  82. package/src/python-manager.test.ts +187 -0
  83. package/src/python-manager.ts +230 -0
  84. package/src/test-utils/helpers.ts +107 -0
  85. package/src/test-utils/index.ts +2 -0
  86. package/src/test-utils/mocks.ts +101 -0
  87. package/src/tools/a2a-tools.ts +162 -0
  88. package/src/tools/api-tools.ts +110 -0
  89. package/src/tools/desktop-automation.test.ts +305 -0
  90. package/src/tools/desktop-automation.ts +366 -0
  91. package/src/tools/index.ts +13 -0
  92. package/src/tools/response-helpers.ts +78 -0
  93. package/src/tools/sandbox-tools.ts +83 -0
  94. package/src/tools/tool-registry.ts +51 -0
  95. package/src/tools/web-automation.test.ts +177 -0
  96. package/src/tools/web-automation.ts +518 -0
  97. package/src/types/index.ts +132 -0
  98. package/tsconfig.json +27 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Tests for Config
3
+ */
4
+
5
+ import { loadConfig, validateConfig, getDefaults } from './config';
6
+ import { CascadePluginConfig } from './types';
7
+
8
+ describe('Config', () => {
9
+ const originalEnv = process.env;
10
+
11
+ beforeEach(() => {
12
+ process.env = { ...originalEnv };
13
+ });
14
+
15
+ afterEach(() => {
16
+ process.env = originalEnv;
17
+ });
18
+
19
+ describe('getDefaults', () => {
20
+ test('should return default configuration', () => {
21
+ // Act
22
+ const defaults = getDefaults();
23
+
24
+ // Assert
25
+ expect(defaults).toEqual({
26
+ cascadeGrpcEndpoint: 'localhost:50051',
27
+ headless: false,
28
+ actionTimeoutMs: 8000,
29
+ enableA2A: true,
30
+ verbose: false,
31
+ screenshotMode: 'auto'
32
+ });
33
+ });
34
+ });
35
+
36
+ describe('validateConfig', () => {
37
+ test('should pass with valid config', () => {
38
+ // Arrange
39
+ const config: CascadePluginConfig = {
40
+ cascadeGrpcEndpoint: 'localhost:50051'
41
+ };
42
+
43
+ // Act & Assert
44
+ expect(() => validateConfig(config)).not.toThrow();
45
+ });
46
+
47
+ test('should throw when cascadeGrpcEndpoint is missing', () => {
48
+ // Arrange
49
+ const config: CascadePluginConfig = {} as any;
50
+
51
+ // Act & Assert
52
+ expect(() => validateConfig(config)).toThrow('cascadeGrpcEndpoint is required');
53
+ });
54
+
55
+ test('should throw when cascadeGrpcEndpoint is empty', () => {
56
+ // Arrange
57
+ const config: CascadePluginConfig = {
58
+ cascadeGrpcEndpoint: ''
59
+ };
60
+
61
+ // Act & Assert
62
+ expect(() => validateConfig(config)).toThrow('cascadeGrpcEndpoint is required');
63
+ });
64
+
65
+ test('should validate firestore credentials path if provided', () => {
66
+ // Arrange
67
+ const config: CascadePluginConfig = {
68
+ cascadeGrpcEndpoint: 'localhost:50051',
69
+ firestoreCredentialsPath: '/path/to/creds.json'
70
+ };
71
+
72
+ // Act & Assert
73
+ expect(() => validateConfig(config)).not.toThrow();
74
+ });
75
+
76
+ test('should validate allowed agents', () => {
77
+ // Arrange
78
+ const config: CascadePluginConfig = {
79
+ cascadeGrpcEndpoint: 'localhost:50051',
80
+ allowedAgents: ['explorer', 'worker', 'orchestrator']
81
+ };
82
+
83
+ // Act & Assert
84
+ expect(() => validateConfig(config)).not.toThrow();
85
+ });
86
+
87
+ test('should throw for invalid agent in allowedAgents', () => {
88
+ // Arrange
89
+ const config: any = {
90
+ cascadeGrpcEndpoint: 'localhost:50051',
91
+ allowedAgents: ['explorer', 'invalid-agent']
92
+ };
93
+
94
+ // Act & Assert
95
+ expect(() => validateConfig(config)).toThrow('Invalid agent');
96
+ });
97
+
98
+ test('should validate screenshotMode', () => {
99
+ // Arrange
100
+ const config: CascadePluginConfig = {
101
+ cascadeGrpcEndpoint: 'localhost:50051',
102
+ screenshotMode: 'embed'
103
+ };
104
+
105
+ // Act & Assert
106
+ expect(() => validateConfig(config)).not.toThrow();
107
+ });
108
+
109
+ test('should throw for invalid screenshotMode', () => {
110
+ // Arrange
111
+ const config: any = {
112
+ cascadeGrpcEndpoint: 'localhost:50051',
113
+ screenshotMode: 'invalid'
114
+ };
115
+
116
+ // Act & Assert
117
+ expect(() => validateConfig(config)).toThrow('Invalid screenshotMode');
118
+ });
119
+ });
120
+
121
+ describe('loadConfig', () => {
122
+ test('should load config with defaults', () => {
123
+ // Arrange
124
+ const input = {
125
+ cascadeGrpcEndpoint: 'localhost:50051'
126
+ };
127
+
128
+ // Act
129
+ const config = loadConfig(input);
130
+
131
+ // Assert
132
+ expect(config.cascadeGrpcEndpoint).toBe('localhost:50051');
133
+ expect(config.headless).toBe(false);
134
+ expect(config.actionTimeoutMs).toBe(8000);
135
+ });
136
+
137
+ test('should merge with provided values', () => {
138
+ // Arrange
139
+ const input = {
140
+ cascadeGrpcEndpoint: '192.168.1.100:50051',
141
+ headless: true,
142
+ actionTimeoutMs: 15000
143
+ };
144
+
145
+ // Act
146
+ const config = loadConfig(input);
147
+
148
+ // Assert
149
+ expect(config.cascadeGrpcEndpoint).toBe('192.168.1.100:50051');
150
+ expect(config.headless).toBe(true);
151
+ expect(config.actionTimeoutMs).toBe(15000);
152
+ });
153
+
154
+ test('should expand environment variables in paths', () => {
155
+ // Arrange
156
+ process.env.HOME = '/home/user';
157
+ const input = {
158
+ cascadeGrpcEndpoint: 'localhost:50051',
159
+ firestoreCredentialsPath: '$HOME/creds.json'
160
+ };
161
+
162
+ // Act
163
+ const config = loadConfig(input);
164
+
165
+ // Assert
166
+ expect(config.firestoreCredentialsPath).toBe('/home/user/creds.json');
167
+ });
168
+
169
+ test('should handle Windows environment variables', () => {
170
+ // Arrange
171
+ process.env.USERPROFILE = 'C:\\Users\\TestUser';
172
+ const input = {
173
+ cascadeGrpcEndpoint: 'localhost:50051',
174
+ cascadePythonPath: '%USERPROFILE%\\Python\\python.exe'
175
+ };
176
+
177
+ // Act
178
+ const config = loadConfig(input);
179
+
180
+ // Assert
181
+ expect(config.cascadePythonPath).toBe('C:\\Users\\TestUser\\Python\\python.exe');
182
+ });
183
+
184
+ test('should validate loaded config', () => {
185
+ // Arrange
186
+ const input = {
187
+ headless: true
188
+ };
189
+
190
+ // Act & Assert
191
+ expect(() => loadConfig(input as any)).toThrow('cascadeGrpcEndpoint is required');
192
+ });
193
+ });
194
+ });
package/src/config.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Configuration management for OpenClaw Cascade Plugin
3
+ */
4
+
5
+ import { CascadePluginConfig } from './types';
6
+
7
+ const VALID_AGENTS = ['explorer', 'worker', 'orchestrator'];
8
+ const VALID_SCREENSHOT_MODES = ['embed', 'disk', 'auto'];
9
+
10
+ /**
11
+ * Get default configuration values
12
+ */
13
+ export function getDefaults(): Partial<CascadePluginConfig> {
14
+ return {
15
+ cascadeGrpcEndpoint: 'localhost:50051',
16
+ headless: false,
17
+ actionTimeoutMs: 8000,
18
+ enableA2A: true,
19
+ verbose: false,
20
+ screenshotMode: 'auto'
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Validate configuration object
26
+ */
27
+ export function validateConfig(config: CascadePluginConfig): void {
28
+ // Required fields
29
+ if (!config.cascadeGrpcEndpoint || config.cascadeGrpcEndpoint.trim() === '') {
30
+ throw new Error('cascadeGrpcEndpoint is required');
31
+ }
32
+
33
+ // Validate gRPC endpoint format
34
+ const endpointPattern = /^[\w.-]+:\d+$/;
35
+ if (!endpointPattern.test(config.cascadeGrpcEndpoint)) {
36
+ throw new Error(
37
+ `Invalid cascadeGrpcEndpoint format: ${config.cascadeGrpcEndpoint}. ` +
38
+ 'Expected format: host:port (e.g., localhost:50051)'
39
+ );
40
+ }
41
+
42
+ // Validate allowed agents if provided
43
+ if (config.allowedAgents) {
44
+ for (const agent of config.allowedAgents) {
45
+ if (!VALID_AGENTS.includes(agent)) {
46
+ throw new Error(`Invalid agent in allowedAgents: ${agent}. Valid agents: ${VALID_AGENTS.join(', ')}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Validate screenshot mode if provided
52
+ if (config.screenshotMode && !VALID_SCREENSHOT_MODES.includes(config.screenshotMode)) {
53
+ throw new Error(
54
+ `Invalid screenshotMode: ${config.screenshotMode}. ` +
55
+ `Valid modes: ${VALID_SCREENSHOT_MODES.join(', ')}`
56
+ );
57
+ }
58
+
59
+ // Validate action timeout
60
+ if (config.actionTimeoutMs !== undefined && config.actionTimeoutMs < 1000) {
61
+ throw new Error('actionTimeoutMs must be at least 1000ms');
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Expand environment variables in a string
67
+ * Supports both $VAR and %VAR% syntax
68
+ */
69
+ export function expandEnvVars(value: string): string {
70
+ if (!value) return value;
71
+
72
+ // Unix-style: $VAR or ${VAR}
73
+ let expanded = value.replace(/\$\{(\w+)\}/g, (match, varName) => {
74
+ return process.env[varName] || match;
75
+ });
76
+
77
+ expanded = expanded.replace(/\$(\w+)/g, (match, varName) => {
78
+ return process.env[varName] || match;
79
+ });
80
+
81
+ // Windows-style: %VAR%
82
+ expanded = expanded.replace(/%(\w+)%/g, (match, varName) => {
83
+ return process.env[varName] || match;
84
+ });
85
+
86
+ return expanded;
87
+ }
88
+
89
+ /**
90
+ * Load and validate configuration
91
+ */
92
+ export function loadConfig(input: Partial<CascadePluginConfig>): CascadePluginConfig {
93
+ // Check required field before merging
94
+ if (!input.cascadeGrpcEndpoint) {
95
+ throw new Error('cascadeGrpcEndpoint is required');
96
+ }
97
+
98
+ // Expand environment variables in string fields
99
+ const expanded: Partial<CascadePluginConfig> = {};
100
+
101
+ for (const [key, value] of Object.entries(input)) {
102
+ if (typeof value === 'string') {
103
+ (expanded as any)[key] = expandEnvVars(value);
104
+ } else {
105
+ (expanded as any)[key] = value;
106
+ }
107
+ }
108
+
109
+ // Merge with defaults
110
+ const config: CascadePluginConfig = {
111
+ ...getDefaults(),
112
+ ...expanded
113
+ } as CascadePluginConfig;
114
+
115
+ // Validate
116
+ validateConfig(config);
117
+
118
+ return config;
119
+ }
120
+
121
+ /**
122
+ * Load configuration from OpenClaw API
123
+ */
124
+ export function loadConfigFromOpenClaw(api: any): CascadePluginConfig {
125
+ const pluginConfig = api.config?.plugins?.entries?.cascade?.config;
126
+
127
+ if (!pluginConfig) {
128
+ throw new Error(
129
+ 'Cascade plugin configuration not found. ' +
130
+ 'Please add cascade configuration to your OpenClaw config.'
131
+ );
132
+ }
133
+
134
+ return loadConfig(pluginConfig);
135
+ }
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * OpenClaw Plugin Entry Point
3
+ *
4
+ * This is the main entry point for the @cascade/openclaw-plugin
5
+ * It initializes the plugin and registers all tools with OpenClaw
6
+ */
7
+
8
+ import { PythonManager } from './python-manager';
9
+ import { CascadeMcpClient } from './cascade-client';
10
+ import { CascadeA2AClient } from './a2a-client';
11
+ import { loadConfig } from './config';
12
+ import { CascadeError } from './types';
13
+ import {
14
+ registerDesktopTools,
15
+ registerWebTools,
16
+ registerApiTools,
17
+ registerSandboxTools,
18
+ registerA2ATools
19
+ } from './tools';
20
+ import { ToolRegistry } from './tools/tool-registry';
21
+
22
+ // Placeholder for OpenClaw API type
23
+ interface OpenClawApi {
24
+ config: {
25
+ plugins: {
26
+ entries: {
27
+ cascade?: {
28
+ config?: any;
29
+ };
30
+ };
31
+ };
32
+ };
33
+ registerTool: (tool: any) => void;
34
+ registerGatewayMethod: (name: string, handler: Function) => void;
35
+ registerCli: (handler: Function) => void;
36
+ notify: (message: string) => void;
37
+ }
38
+
39
+ export default async function register(api: OpenClawApi) {
40
+ let mcpClient: CascadeMcpClient | null = null;
41
+ let a2aClient: CascadeA2AClient | null = null;
42
+
43
+ try {
44
+ // Load and validate configuration
45
+ const config = loadConfig(api.config.plugins.entries.cascade?.config || {});
46
+
47
+ console.log('Initializing Cascade plugin...');
48
+ console.log(`gRPC Endpoint: ${config.cascadeGrpcEndpoint}`);
49
+
50
+ // Initialize Python Manager
51
+ const pythonManager = new PythonManager(config);
52
+ const pythonPath = await pythonManager.findOrInstallPython();
53
+ console.log(`Python: ${pythonPath}`);
54
+
55
+ // Initialize MCP Client
56
+ mcpClient = new CascadeMcpClient(pythonPath, {
57
+ CASCADE_GRPC_ENDPOINT: config.cascadeGrpcEndpoint,
58
+ CASCADE_APP_ID: config.firestoreProjectId || 'openclaw',
59
+ CASCADE_USER_ID: 'openclaw-user',
60
+ ...(config.firestoreCredentialsPath && {
61
+ GOOGLE_APPLICATION_CREDENTIALS: config.firestoreCredentialsPath
62
+ })
63
+ });
64
+
65
+ await mcpClient.start();
66
+ console.log('Cascade MCP client connected');
67
+
68
+ // Initialize A2A Client if enabled
69
+ if (config.enableA2A) {
70
+ a2aClient = new CascadeA2AClient(
71
+ config.cascadeGrpcEndpoint,
72
+ 'openclaw-user',
73
+ config.firestoreProjectId || 'openclaw',
74
+ '' // auth token would come from config
75
+ );
76
+ await a2aClient.initialize();
77
+ console.log('Cascade A2A client initialized');
78
+ }
79
+
80
+ // Create tool registry
81
+ const toolRegistry = new ToolRegistry();
82
+
83
+ // Register all tools
84
+ console.log('Registering tools...');
85
+ registerDesktopTools(toolRegistry, mcpClient, config);
86
+ registerWebTools(toolRegistry, mcpClient);
87
+ registerApiTools(toolRegistry, mcpClient);
88
+ registerSandboxTools(toolRegistry, mcpClient);
89
+
90
+ if (a2aClient) {
91
+ registerA2ATools(toolRegistry, a2aClient);
92
+ }
93
+
94
+ // Register tools with OpenClaw
95
+ const tools = toolRegistry.getAll();
96
+ console.log(`Registering ${tools.length} tools with OpenClaw`);
97
+
98
+ for (const tool of tools) {
99
+ api.registerTool({
100
+ name: tool.name,
101
+ description: tool.description,
102
+ inputSchema: tool.inputSchema,
103
+ handler: tool.handler
104
+ });
105
+ }
106
+
107
+ // Register status check
108
+ api.registerGatewayMethod('cascade.status', () => ({
109
+ connected: mcpClient?.isConnected() || false,
110
+ toolsRegistered: tools.length,
111
+ pythonPath,
112
+ grpcEndpoint: config.cascadeGrpcEndpoint,
113
+ a2aEnabled: config.enableA2A
114
+ }));
115
+
116
+ // Register CLI command
117
+ api.registerCli(({ program }: { program: any }) => {
118
+ program
119
+ .command('cascade:status')
120
+ .description('Check Cascade plugin status')
121
+ .action(() => {
122
+ console.log('Cascade Plugin Status:');
123
+ console.log(' Connected:', mcpClient?.isConnected() || false);
124
+ console.log(' Tools:', tools.length);
125
+ console.log(' Python:', pythonPath);
126
+ console.log(' gRPC:', config.cascadeGrpcEndpoint);
127
+ console.log(' A2A:', config.enableA2A ? 'enabled' : 'disabled');
128
+ });
129
+
130
+ program
131
+ .command('cascade:tools')
132
+ .description('List all registered tools')
133
+ .action(() => {
134
+ console.log('Registered Tools:');
135
+ tools.forEach((tool, index) => {
136
+ console.log(` ${index + 1}. ${tool.name}`);
137
+ });
138
+ });
139
+ });
140
+
141
+ console.log(`Cascade plugin initialized successfully with ${tools.length} tools`);
142
+
143
+ } catch (error) {
144
+ console.error('Failed to initialize Cascade plugin:', error);
145
+
146
+ // Cleanup on error
147
+ if (mcpClient) {
148
+ mcpClient.stop();
149
+ }
150
+
151
+ if (error instanceof CascadeError) {
152
+ throw error;
153
+ }
154
+
155
+ throw new Error(
156
+ `Cascade plugin initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`
157
+ );
158
+ }
159
+ }
160
+
161
+ // Export types for TypeScript users
162
+ export * from './types';
163
+ export { PythonManager, CascadeMcpClient, CascadeA2AClient, loadConfig };
164
+ // Note: ToolRegistry is internal use only - not exported to avoid conflicts
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for PythonManager
3
+ *
4
+ * Test-driven development: Write tests first, then implement
5
+ */
6
+
7
+ import { PythonManager } from './python-manager';
8
+ import { createMockConfig } from './test-utils';
9
+
10
+ describe('PythonManager', () => {
11
+ let pythonManager: PythonManager;
12
+ let mockExec: jest.Mock;
13
+
14
+ beforeEach(() => {
15
+ jest.clearAllMocks();
16
+ mockExec = jest.fn();
17
+
18
+ // Mock the exec method directly on the module
19
+ jest.doMock('child_process', () => ({
20
+ exec: mockExec
21
+ }));
22
+
23
+ pythonManager = new PythonManager(createMockConfig());
24
+ });
25
+
26
+ afterEach(() => {
27
+ jest.dontMock('child_process');
28
+ });
29
+
30
+ describe('findOrInstallPython', () => {
31
+ test('should return configured path when cascadePythonPath is set', async () => {
32
+ // Arrange
33
+ const config = createMockConfig({ cascadePythonPath: '/custom/python' });
34
+ pythonManager = new PythonManager(config);
35
+
36
+ // Mock the execAsync method directly
37
+ const execSpy = jest.spyOn(pythonManager as any, 'execAsync')
38
+ .mockResolvedValue({ stdout: 'Python 3.12.0', stderr: '' });
39
+
40
+ // Act
41
+ const result = await pythonManager.findOrInstallPython();
42
+
43
+ // Assert
44
+ expect(result).toBe('/custom/python');
45
+ expect(execSpy).toHaveBeenCalledWith('/custom/python --version');
46
+ });
47
+
48
+ test('should auto-detect python3.12 in common locations', async () => {
49
+ // Arrange
50
+ let callCount = 0;
51
+ const execSpy = jest.spyOn(pythonManager as any, 'execAsync')
52
+ .mockImplementation(() => {
53
+ callCount++;
54
+ if (callCount <= 2) {
55
+ return Promise.reject(new Error('not found'));
56
+ }
57
+ return Promise.resolve({ stdout: 'Python 3.10.0', stderr: '' });
58
+ });
59
+
60
+ // Act
61
+ const result = await pythonManager.findOrInstallPython();
62
+
63
+ // Assert
64
+ expect(result).toBe('python3.10');
65
+ expect(execSpy).toHaveBeenCalledWith('python3.12 --version');
66
+ expect(execSpy).toHaveBeenCalledWith('python3.11 --version');
67
+ expect(execSpy).toHaveBeenCalledWith('python3.10 --version');
68
+ });
69
+
70
+ test('should fallback through version candidates in order', async () => {
71
+ // Arrange
72
+ jest.spyOn(pythonManager as any, 'execAsync')
73
+ .mockResolvedValue({ stdout: 'Python 3.12.1', stderr: '' });
74
+
75
+ // Act
76
+ const result = await pythonManager.findOrInstallPython();
77
+
78
+ // Assert
79
+ expect(result).toBe('python3.12');
80
+ });
81
+
82
+ test('should throw error when no Python found and auto-install fails', async () => {
83
+ // Arrange
84
+ jest.spyOn(pythonManager as any, 'execAsync').mockRejectedValue(new Error('not found'));
85
+
86
+ // Mock install to fail
87
+ jest.spyOn(pythonManager as any, 'installPython').mockRejectedValue(
88
+ new Error('Installation failed')
89
+ );
90
+
91
+ // Act & Assert
92
+ await expect(pythonManager.findOrInstallPython()).rejects.toThrow('Installation failed');
93
+ });
94
+ });
95
+
96
+ describe('isValidPython', () => {
97
+ test('should return true for Python 3.10+', async () => {
98
+ // Arrange
99
+ jest.spyOn(pythonManager as any, 'execAsync')
100
+ .mockResolvedValue({ stdout: 'Python 3.12.0', stderr: '' });
101
+
102
+ // Act
103
+ const result = await (pythonManager as any).isValidPython('python3');
104
+
105
+ // Assert
106
+ expect(result).toBe(true);
107
+ });
108
+
109
+ test('should return true for Python 3.10.x', async () => {
110
+ // Arrange
111
+ jest.spyOn(pythonManager as any, 'execAsync')
112
+ .mockResolvedValue({ stdout: 'Python 3.10.5', stderr: '' });
113
+
114
+ // Act
115
+ const result = await (pythonManager as any).isValidPython('python3');
116
+
117
+ // Assert
118
+ expect(result).toBe(true);
119
+ });
120
+
121
+ test('should return false for Python 3.9', async () => {
122
+ // Arrange
123
+ jest.spyOn(pythonManager as any, 'execAsync')
124
+ .mockResolvedValue({ stdout: 'Python 3.9.7', stderr: '' });
125
+
126
+ // Act
127
+ const result = await (pythonManager as any).isValidPython('python3');
128
+
129
+ // Assert
130
+ expect(result).toBe(false);
131
+ });
132
+
133
+ test('should return false for Python 2.x', async () => {
134
+ // Arrange
135
+ jest.spyOn(pythonManager as any, 'execAsync')
136
+ .mockResolvedValue({ stdout: 'Python 2.7.18', stderr: '' });
137
+
138
+ // Act
139
+ const result = await (pythonManager as any).isValidPython('python');
140
+
141
+ // Assert
142
+ expect(result).toBe(false);
143
+ });
144
+
145
+ test('should return false for non-Python executables', async () => {
146
+ // Arrange
147
+ jest.spyOn(pythonManager as any, 'execAsync')
148
+ .mockResolvedValue({ stdout: 'not python output', stderr: '' });
149
+
150
+ // Act
151
+ const result = await (pythonManager as any).isValidPython('notpython');
152
+
153
+ // Assert
154
+ expect(result).toBe(false);
155
+ });
156
+
157
+ test('should return false when command not found', async () => {
158
+ // Arrange
159
+ jest.spyOn(pythonManager as any, 'execAsync')
160
+ .mockRejectedValue(new Error('command not found'));
161
+
162
+ // Act
163
+ const result = await (pythonManager as any).isValidPython('fakepython');
164
+
165
+ // Assert
166
+ expect(result).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe('parsePythonVersion', () => {
171
+ test('should parse standard version output', () => {
172
+ // Act & Assert
173
+ expect((pythonManager as any).parsePythonVersion('Python 3.12.0')).toBe(3.12);
174
+ expect((pythonManager as any).parsePythonVersion('Python 3.10.5')).toBe(3.10);
175
+ expect((pythonManager as any).parsePythonVersion('Python 3.9.0')).toBe(3.09);
176
+ });
177
+
178
+ test('should handle version without patch', () => {
179
+ expect((pythonManager as any).parsePythonVersion('Python 3.12')).toBe(3.12);
180
+ });
181
+
182
+ test('should return 0 for invalid output', () => {
183
+ expect((pythonManager as any).parsePythonVersion('not python')).toBe(0);
184
+ expect((pythonManager as any).parsePythonVersion('')).toBe(0);
185
+ });
186
+ });
187
+ });