|
| 1 | +import yargs from 'yargs'; |
| 2 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 3 | +import * as z from 'zod'; |
| 4 | +import type { ToolCatalog, ToolDefinition } from '../../runtime/types.ts'; |
| 5 | +import { DefaultToolInvoker } from '../../runtime/tool-invoker.ts'; |
| 6 | +import { createTextContent } from '../../types/common.ts'; |
| 7 | +import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts'; |
| 8 | +import { registerToolCommands } from '../register-tool-commands.ts'; |
| 9 | + |
| 10 | +function createTool(overrides: Partial<ToolDefinition> = {}): ToolDefinition { |
| 11 | + return { |
| 12 | + cliName: 'run-tool', |
| 13 | + mcpName: 'run_tool', |
| 14 | + workflow: 'simulator', |
| 15 | + description: 'Run test tool', |
| 16 | + annotations: { readOnlyHint: true }, |
| 17 | + cliSchema: { |
| 18 | + workspacePath: z.string().describe('Workspace path'), |
| 19 | + scheme: z.string().optional(), |
| 20 | + }, |
| 21 | + mcpSchema: { |
| 22 | + workspacePath: z.string().describe('Workspace path'), |
| 23 | + scheme: z.string().optional(), |
| 24 | + }, |
| 25 | + stateful: false, |
| 26 | + handler: vi.fn(async () => ({ |
| 27 | + content: [createTextContent('ok')], |
| 28 | + isError: false, |
| 29 | + })), |
| 30 | + ...overrides, |
| 31 | + }; |
| 32 | +} |
| 33 | + |
| 34 | +function createCatalog(tools: ToolDefinition[]): ToolCatalog { |
| 35 | + return { |
| 36 | + tools, |
| 37 | + getByCliName: (name) => tools.find((tool) => tool.cliName === name) ?? null, |
| 38 | + getByMcpName: (name) => tools.find((tool) => tool.mcpName === name) ?? null, |
| 39 | + getByToolId: (toolId) => tools.find((tool) => tool.id === toolId) ?? null, |
| 40 | + resolve: (input) => { |
| 41 | + const tool = tools.find((candidate) => candidate.cliName === input); |
| 42 | + return tool ? { tool } : { notFound: true }; |
| 43 | + }, |
| 44 | + }; |
| 45 | +} |
| 46 | + |
| 47 | +const baseRuntimeConfig: ResolvedRuntimeConfig = { |
| 48 | + enabledWorkflows: [], |
| 49 | + customWorkflows: {}, |
| 50 | + debug: false, |
| 51 | + sentryDisabled: false, |
| 52 | + experimentalWorkflowDiscovery: false, |
| 53 | + disableSessionDefaults: true, |
| 54 | + disableXcodeAutoSync: false, |
| 55 | + uiDebuggerGuardMode: 'error', |
| 56 | + incrementalBuildsEnabled: false, |
| 57 | + dapRequestTimeoutMs: 30_000, |
| 58 | + dapLogEvents: false, |
| 59 | + launchJsonWaitMs: 8_000, |
| 60 | + debuggerBackend: 'dap', |
| 61 | + sessionDefaults: { |
| 62 | + workspacePath: 'App.xcworkspace', |
| 63 | + }, |
| 64 | + sessionDefaultsProfiles: { |
| 65 | + ios: { |
| 66 | + workspacePath: 'Profile.xcworkspace', |
| 67 | + }, |
| 68 | + }, |
| 69 | + activeSessionDefaultsProfile: 'ios', |
| 70 | +}; |
| 71 | + |
| 72 | +function createApp(catalog: ToolCatalog, runtimeConfig: ResolvedRuntimeConfig = baseRuntimeConfig) { |
| 73 | + const app = yargs() |
| 74 | + .scriptName('xcodebuildmcp') |
| 75 | + .exitProcess(false) |
| 76 | + .fail((message, error) => { |
| 77 | + throw error ?? new Error(message); |
| 78 | + }); |
| 79 | + |
| 80 | + registerToolCommands(app, catalog, { |
| 81 | + workspaceRoot: '/repo', |
| 82 | + runtimeConfig, |
| 83 | + cliExposedWorkflowIds: ['simulator'], |
| 84 | + workflowNames: ['simulator'], |
| 85 | + }); |
| 86 | + |
| 87 | + return app; |
| 88 | +} |
| 89 | + |
| 90 | +describe('registerToolCommands', () => { |
| 91 | + const originalArgv = process.argv; |
| 92 | + |
| 93 | + afterEach(() => { |
| 94 | + vi.restoreAllMocks(); |
| 95 | + process.exitCode = undefined; |
| 96 | + process.argv = originalArgv; |
| 97 | + }); |
| 98 | + |
| 99 | + it('hydrates required args from the active defaults profile', async () => { |
| 100 | + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ |
| 101 | + content: [createTextContent('ok')], |
| 102 | + isError: false, |
| 103 | + }); |
| 104 | + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); |
| 105 | + |
| 106 | + const tool = createTool(); |
| 107 | + const app = createApp(createCatalog([tool])); |
| 108 | + |
| 109 | + await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined(); |
| 110 | + |
| 111 | + expect(invokeDirect).toHaveBeenCalledTimes(1); |
| 112 | + expect(invokeDirect).toHaveBeenCalledWith( |
| 113 | + tool, |
| 114 | + { |
| 115 | + workspacePath: 'Profile.xcworkspace', |
| 116 | + }, |
| 117 | + expect.objectContaining({ |
| 118 | + runtime: 'cli', |
| 119 | + workspaceRoot: '/repo', |
| 120 | + }), |
| 121 | + ); |
| 122 | + |
| 123 | + stdoutWrite.mockRestore(); |
| 124 | + }); |
| 125 | + |
| 126 | + it('hydrates required args from the explicit --profile override', async () => { |
| 127 | + process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa']; |
| 128 | + |
| 129 | + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ |
| 130 | + content: [createTextContent('ok')], |
| 131 | + isError: false, |
| 132 | + }); |
| 133 | + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); |
| 134 | + |
| 135 | + const tool = createTool(); |
| 136 | + const app = createApp(createCatalog([tool]), { |
| 137 | + ...baseRuntimeConfig, |
| 138 | + sessionDefaultsProfiles: { |
| 139 | + ...baseRuntimeConfig.sessionDefaultsProfiles, |
| 140 | + qa: { |
| 141 | + workspacePath: 'QA.xcworkspace', |
| 142 | + }, |
| 143 | + }, |
| 144 | + }); |
| 145 | + |
| 146 | + await expect( |
| 147 | + app.parseAsync(['simulator', 'run-tool', '--profile', 'qa']), |
| 148 | + ).resolves.toBeDefined(); |
| 149 | + |
| 150 | + expect(invokeDirect).toHaveBeenCalledWith( |
| 151 | + tool, |
| 152 | + { |
| 153 | + workspacePath: 'QA.xcworkspace', |
| 154 | + }, |
| 155 | + expect.any(Object), |
| 156 | + ); |
| 157 | + |
| 158 | + stdoutWrite.mockRestore(); |
| 159 | + }); |
| 160 | + |
| 161 | + it('keeps the normal missing-argument error when no hydrated default exists', async () => { |
| 162 | + const tool = createTool(); |
| 163 | + const app = createApp(createCatalog([tool]), { |
| 164 | + ...baseRuntimeConfig, |
| 165 | + sessionDefaults: undefined, |
| 166 | + sessionDefaultsProfiles: undefined, |
| 167 | + activeSessionDefaultsProfile: undefined, |
| 168 | + }); |
| 169 | + |
| 170 | + let error: Error | undefined; |
| 171 | + try { |
| 172 | + await app.parseAsync(['simulator', 'run-tool']); |
| 173 | + } catch (thrown) { |
| 174 | + error = thrown as Error; |
| 175 | + } |
| 176 | + |
| 177 | + expect(error?.message).toContain('Missing required argument: workspace-path'); |
| 178 | + expect(error?.message).not.toMatch(/session defaults/i); |
| 179 | + }); |
| 180 | + |
| 181 | + it('hydrates args before daemon-routed invocation', async () => { |
| 182 | + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ |
| 183 | + content: [createTextContent('ok')], |
| 184 | + isError: false, |
| 185 | + }); |
| 186 | + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); |
| 187 | + |
| 188 | + const tool = createTool({ stateful: true }); |
| 189 | + const app = createApp(createCatalog([tool])); |
| 190 | + |
| 191 | + await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined(); |
| 192 | + |
| 193 | + expect(invokeDirect).toHaveBeenCalledWith( |
| 194 | + tool, |
| 195 | + { |
| 196 | + workspacePath: 'Profile.xcworkspace', |
| 197 | + }, |
| 198 | + expect.any(Object), |
| 199 | + ); |
| 200 | + |
| 201 | + stdoutWrite.mockRestore(); |
| 202 | + }); |
| 203 | + |
| 204 | + it('lets explicit args override conflicting defaults before invocation', async () => { |
| 205 | + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ |
| 206 | + content: [createTextContent('ok')], |
| 207 | + isError: false, |
| 208 | + }); |
| 209 | + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); |
| 210 | + |
| 211 | + const tool = createTool({ |
| 212 | + cliSchema: { |
| 213 | + projectPath: z.string().describe('Project path'), |
| 214 | + workspacePath: z.string().optional(), |
| 215 | + }, |
| 216 | + mcpSchema: { |
| 217 | + projectPath: z.string().describe('Project path'), |
| 218 | + workspacePath: z.string().optional(), |
| 219 | + }, |
| 220 | + }); |
| 221 | + const app = createApp(createCatalog([tool])); |
| 222 | + |
| 223 | + await expect( |
| 224 | + app.parseAsync(['simulator', 'run-tool', '--project-path', 'App.xcodeproj']), |
| 225 | + ).resolves.toBeDefined(); |
| 226 | + |
| 227 | + expect(invokeDirect).toHaveBeenCalledWith( |
| 228 | + tool, |
| 229 | + { |
| 230 | + projectPath: 'App.xcodeproj', |
| 231 | + }, |
| 232 | + expect.any(Object), |
| 233 | + ); |
| 234 | + |
| 235 | + stdoutWrite.mockRestore(); |
| 236 | + }); |
| 237 | + |
| 238 | + it('errors clearly when --profile references an unknown profile', async () => { |
| 239 | + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); |
| 240 | + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 241 | + |
| 242 | + const tool = createTool(); |
| 243 | + const app = createApp(createCatalog([tool])); |
| 244 | + |
| 245 | + await expect( |
| 246 | + app.parseAsync(['simulator', 'run-tool', '--profile', 'missing']), |
| 247 | + ).resolves.toBeDefined(); |
| 248 | + |
| 249 | + expect(consoleError).toHaveBeenCalledWith("Error: Unknown defaults profile 'missing'"); |
| 250 | + expect(process.exitCode).toBe(1); |
| 251 | + |
| 252 | + stderrWrite.mockRestore(); |
| 253 | + }); |
| 254 | + |
| 255 | + it('lets --json override configured defaults', async () => { |
| 256 | + const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({ |
| 257 | + content: [createTextContent('ok')], |
| 258 | + isError: false, |
| 259 | + }); |
| 260 | + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); |
| 261 | + |
| 262 | + const tool = createTool(); |
| 263 | + const app = createApp(createCatalog([tool])); |
| 264 | + |
| 265 | + await expect( |
| 266 | + app.parseAsync([ |
| 267 | + 'simulator', |
| 268 | + 'run-tool', |
| 269 | + '--json', |
| 270 | + JSON.stringify({ workspacePath: 'Json.xcworkspace' }), |
| 271 | + ]), |
| 272 | + ).resolves.toBeDefined(); |
| 273 | + |
| 274 | + expect(invokeDirect).toHaveBeenCalledWith( |
| 275 | + tool, |
| 276 | + { |
| 277 | + workspacePath: 'Json.xcworkspace', |
| 278 | + }, |
| 279 | + expect.any(Object), |
| 280 | + ); |
| 281 | + |
| 282 | + stdoutWrite.mockRestore(); |
| 283 | + }); |
| 284 | +}); |
0 commit comments