diff --git a/CHANGELOG.md b/CHANGELOG.md index b6037b62..506e4462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` replace matching configured flags or build settings and append after non-matching defaults, while an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults. + ## [2.6.2] ### Fixed @@ -683,4 +689,3 @@ Please note that the UI automation features are an early preview and currently i - Basic support for building iOS and macOS applications - diff --git a/config.example.yaml b/config.example.yaml index fc6ab827..a1b517bf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,3 +26,4 @@ sessionDefaults: preferXcodebuild: false platform: 'iOS' bundleId: 'io.sentry.myapp' + extraArgs: ['-skipPackagePluginValidation'] diff --git a/schemas/structured-output/_defs/common.schema.json b/schemas/structured-output/_defs/common.schema.json index e6354be2..8704d9bb 100644 --- a/schemas/structured-output/_defs/common.schema.json +++ b/schemas/structured-output/_defs/common.schema.json @@ -375,6 +375,19 @@ } } ] + }, + "extraArgs": { + "anyOf": [ + { + "const": null + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } }, "required": [ diff --git a/src/cli/__tests__/session-defaults.test.ts b/src/cli/__tests__/session-defaults.test.ts index e2a99f0c..f30f298c 100644 --- a/src/cli/__tests__/session-defaults.test.ts +++ b/src/cli/__tests__/session-defaults.test.ts @@ -126,4 +126,49 @@ describe('CLI session defaults', () => { }, }); }); + + it('appends explicit extraArgs after configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-skipPackagePluginValidation'], + }, + explicitArgs: { + extraArgs: ['-quiet'], + }, + }); + + expect(merged).toEqual({ + extraArgs: ['-skipPackagePluginValidation', '-quiet'], + }); + }); + + it('lets explicit destination extraArgs replace matching configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-quiet', '-skipMacroValidation', '-destination', 'id=x'], + }, + explicitArgs: { + extraArgs: ['-quiet', '-destination', 'id=y'], + }, + }); + + expect(merged).toEqual({ + extraArgs: ['-skipMacroValidation', '-quiet', '-destination', 'id=y'], + }); + }); + + it('allows an explicit empty extraArgs array to clear configured extraArgs', () => { + const merged = mergeCliSessionDefaults({ + defaults: { + extraArgs: ['-skipPackagePluginValidation'], + }, + explicitArgs: { + extraArgs: [], + }, + }); + + expect(merged).toEqual({ + extraArgs: [], + }); + }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index 6b1f7762..86f7a537 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -63,6 +63,18 @@ describe('session-clear-defaults tool', () => { expect(current.scheme).toBe('MyScheme'); }); + it('should clear extraArgs when keys includes extraArgs', async () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const result = await runLogic(() => sessionClearDefaultsLogic({ keys: ['extraArgs'] })); + + expect(result.isError).toBeFalsy(); + + const current = sessionStore.getAll(); + expect(current.extraArgs).toBeUndefined(); + expect(current.scheme).toBe('MyScheme'); + }); + it('should clear all profiles only when all=true', async () => { sessionStore.setActiveProfile('ios'); sessionStore.setDefaults({ scheme: 'IOS' }); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 9718d207..09fd0943 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -421,6 +421,47 @@ describe('session-set-defaults tool', () => { expect(parsed.sessionDefaults?.env).toEqual(envVars); }); + it('should store extraArgs as a string array default', async () => { + const extraArgs = ['-skipPackagePluginValidation', '-skipMacroValidation']; + const result = await runLogic(() => sessionSetDefaultsLogic({ extraArgs }, createContext())); + + expect(result.isError).toBeFalsy(); + expect(sessionStore.getAll().extraArgs).toEqual(extraArgs); + }); + + it('should persist extraArgs to config when persist is true', async () => { + const yaml = ['schemaVersion: 1', 'sessionDefaults: {}', ''].join('\n'); + + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + + await initConfigStore({ cwd, fs }); + + const extraArgs = ['-skipPackagePluginValidation']; + const result = await runLogic(() => + sessionSetDefaultsLogic({ extraArgs, persist: true }, createContext()), + ); + + expect(result.isError).toBeFalsy(); + expect(writes.length).toBe(1); + + const parsed = parseYaml(writes[0].content) as { + sessionDefaults?: Record; + }; + expect(parsed.sessionDefaults?.extraArgs).toEqual(extraArgs); + }); + it('should not persist when persist is true but no defaults were provided', async () => { const writes: { path: string; content: string }[] = []; const fs = createMockFileSystemExecutor({ diff --git a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts index 3c22a5f7..f435dd8f 100644 --- a/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_show_defaults.test.ts @@ -32,5 +32,13 @@ describe('session-show-defaults tool', () => { expect(result.isError).toBeFalsy(); expect(allText(result)).toContain('scheme: IOSScheme'); }); + + it('shows extraArgs defaults', async () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const result = await callHandler(handler, {}); + expect(result.isError).toBeFalsy(); + expect(allText(result)).toContain('extraArgs: -skipPackagePluginValidation'); + }); }); }); diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index 583eefab..f98ed0e4 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -58,6 +58,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index c38f831c..eb8f3ef8 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -71,6 +71,7 @@ function createSessionDefaultsProfile(defaults: SessionDefaults): SessionDefault platform: defaults.platform ?? null, bundleId: defaults.bundleId ?? null, env: defaults.env ?? null, + extraArgs: defaults.extraArgs ?? null, }; } diff --git a/src/mcp/tools/session-management/session_show_defaults.ts b/src/mcp/tools/session-management/session_show_defaults.ts index a46327ba..48e09597 100644 --- a/src/mcp/tools/session-management/session_show_defaults.ts +++ b/src/mcp/tools/session-management/session_show_defaults.ts @@ -38,6 +38,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts index 86a2693b..fa5ab552 100644 --- a/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts +++ b/src/mcp/tools/xcode-ide/sync_xcode_defaults.ts @@ -58,6 +58,7 @@ function createSessionDefaultsProfile(profile: Record): Session platform: (profile.platform as string | undefined) ?? null, bundleId: (profile.bundleId as string | undefined) ?? null, env: (profile.env as Record | undefined) ?? null, + extraArgs: (profile.extraArgs as string[] | undefined) ?? null, }; } diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json index 6723b2e6..392233d0 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-clear-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json index f3edab99..a7239476 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--scheme.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json index 39125735..8641033e 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-set-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json index 283e872c..8d570b13 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--empty.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json index 1984cb7b..e7827f55 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-show-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null }, "MyCustomProfile": { "projectPath": null, @@ -40,7 +41,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } }, "operation": { diff --git a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json index 10aaf158..c0cc4a95 100644 --- a/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json +++ b/src/snapshot-tests/__fixtures__/mcp/json/session-management/session-sync-xcode-defaults--success.json @@ -22,7 +22,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": "io.sentry.calculatorapp", - "env": null + "env": null, + "extraArgs": null }, "MyCustomProfile": { "projectPath": null, @@ -40,7 +41,8 @@ "preferXcodebuild": null, "platform": null, "bundleId": null, - "env": null + "env": null, + "extraArgs": null } } } diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt index 7b2d2166..708e8627 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--scheme.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt index 51c8b54b..b7d2b012 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-set-defaults--success.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt index 17b08a08..2363f07f 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--empty.txt @@ -17,4 +17,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt index d6372556..1cab04b1 100644 --- a/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt +++ b/src/snapshot-tests/__fixtures__/mcp/text/session-management/session-show-defaults--success.txt @@ -17,7 +17,8 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) 📁 MyCustomProfile ├ projectPath: (not set) @@ -35,4 +36,5 @@ ├ preferXcodebuild: (not set) ├ platform: (not set) ├ bundleId: (not set) - └ env: (not set) + ├ env: (not set) + └ extraArgs: (not set) diff --git a/src/types/domain-results.ts b/src/types/domain-results.ts index e67f2f59..b7154667 100644 --- a/src/types/domain-results.ts +++ b/src/types/domain-results.ts @@ -127,6 +127,7 @@ export interface SessionDefaultsProfile { platform: string | null; bundleId: string | null; env: Record | null; + extraArgs: string[] | null; } export interface BuildLikeSummary extends StatusSummary { durationMs?: number; diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index 130d8724..aaba24b5 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -218,6 +218,8 @@ describe('project-config', () => { ' ios:', ' workspacePath: "~/Code/App.xcworkspace"', ' derivedDataPath: "~/.cache/dd"', + ' extraArgs:', + ' - "-skipPackagePluginValidation"', '', ].join('\n'); @@ -231,6 +233,9 @@ describe('project-config', () => { expect(result.config.sessionDefaultsProfiles?.ios?.derivedDataPath).toBe( path.join(homedir(), '.cache/dd'), ); + expect(result.config.sessionDefaultsProfiles?.ios?.extraArgs).toEqual([ + '-skipPackagePluginValidation', + ]); }); it('normalizes namespaced session defaults profiles and active profile', async () => { diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 0e89f941..86222cb8 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -523,4 +523,96 @@ describe('createSessionAwareTool', () => { expect(result.isError).toBe(true); expect(result.text).toContain('Parameter validation failed'); }); + + it('appends explicit extraArgs after session default extraArgs', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { extraArgs: ['-quiet'] }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual(['-skipPackagePluginValidation', '-quiet']); + }); + + it('lets explicit destination extraArgs replace matching session default extraArgs', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-destination', 'id=DEFAULT', '-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { + extraArgs: ['-destination', 'id=EXPLICIT'], + }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual(['-skipPackagePluginValidation', '-destination', 'id=EXPLICIT']); + }); + + it('allows explicit empty extraArgs to clear session default extraArgs', async () => { + const extraArgsSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + extraArgs: z.array(z.string()).optional(), + }); + + const extraArgsHandler = createSessionAwareTool>({ + internalSchema: extraArgsSchema, + logicFunction: async (params) => { + const ctx = getHandlerContext(); + ctx.emit(statusFragment('success', JSON.stringify(params.extraArgs))); + }, + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + extraArgs: ['-skipPackagePluginValidation'], + }); + + const result = await invokeAndCollect(extraArgsHandler, { extraArgs: [] }); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.text.replace(/\n/g, '').replace(/^.*?(\[.*\]).*$/, '$1')); + expect(parsed).toEqual([]); + }); }); diff --git a/src/utils/__tests__/session-default-args.test.ts b/src/utils/__tests__/session-default-args.test.ts new file mode 100644 index 00000000..c4047baa --- /dev/null +++ b/src/utils/__tests__/session-default-args.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { mergeSessionDefaultArgs } from '../session-default-args.ts'; + +function mergeExtraArgs(defaultExtraArgs: string[], explicitExtraArgs: string[]): unknown[] { + const merged = mergeSessionDefaultArgs({ + defaults: { extraArgs: defaultExtraArgs }, + explicitArgs: { extraArgs: explicitExtraArgs }, + }); + + return merged.extraArgs as unknown[]; +} + +describe('mergeSessionDefaultArgs extraArgs', () => { + it('appends explicit args after non-matching defaults', () => { + expect(mergeExtraArgs(['-skipPackagePluginValidation'], ['-quiet'])).toEqual([ + '-skipPackagePluginValidation', + '-quiet', + ]); + }); + + it('replaces matching configured options by key', () => { + expect( + mergeExtraArgs( + ['-quiet', '-skipMacroValidation', '-destination', 'id=x'], + ['-quiet', '-destination', 'id=y'], + ), + ).toEqual(['-skipMacroValidation', '-quiet', '-destination', 'id=y']); + }); + + it('clears configured args when explicit args are empty', () => { + expect(mergeExtraArgs(['-skipPackagePluginValidation'], [])).toEqual([]); + }); + + it('replaces separated value options without consuming following flags', () => { + expect( + mergeExtraArgs( + ['-configuration', 'Debug', '-sdk', 'iphonesimulator', '-quiet'], + ['-configuration', 'Release', '-sdk', 'iphoneos'], + ), + ).toEqual(['-quiet', '-configuration', 'Release', '-sdk', 'iphoneos']); + }); + + it('preserves a known flag after a malformed configured value option', () => { + expect( + mergeExtraArgs( + ['-configuration', '-skipPackagePluginValidation'], + ['-configuration', 'Release'], + ), + ).toEqual(['-skipPackagePluginValidation', '-configuration', 'Release']); + }); + + it('treats colon inline forms as the same option key', () => { + expect( + mergeExtraArgs(['-only-testing:AppTests/testA'], ['-only-testing:AppTests/testB']), + ).toEqual(['-only-testing:AppTests/testB']); + }); + + it('treats equals inline forms as the same option key', () => { + expect(mergeExtraArgs(['--foo=bar'], ['--foo=baz'])).toEqual(['--foo=baz']); + }); + + it('replaces build settings by key', () => { + expect(mergeExtraArgs(['SWIFT_VERSION=4', '-quiet'], ['SWIFT_VERSION=5'])).toEqual([ + '-quiet', + 'SWIFT_VERSION=5', + ]); + }); + + it('keeps conditional build settings with different conditions', () => { + expect( + mergeExtraArgs( + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64'], + ['EXCLUDED_ARCHS[sdk=iphoneos*]=arm64'], + ), + ).toEqual([ + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64', + 'EXCLUDED_ARCHS[sdk=iphoneos*]=arm64', + ]); + }); + + it('replaces conditional build settings with the same condition', () => { + expect( + mergeExtraArgs( + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=arm64'], + ['EXCLUDED_ARCHS[sdk=iphonesimulator*]=x86_64'], + ), + ).toEqual(['EXCLUDED_ARCHS[sdk=iphonesimulator*]=x86_64']); + }); + + it('does not reinterpret dash-prefixed option values as option keys', () => { + expect( + mergeExtraArgs( + ['-Explicit.xcresult', '-resultBundlePath', 'Default.xcresult'], + ['-resultBundlePath', '-Explicit.xcresult'], + ), + ).toEqual(['-Explicit.xcresult', '-resultBundlePath', '-Explicit.xcresult']); + }); + + it('keeps build settings separate from values of current xcodebuild options', () => { + expect(mergeExtraArgs(['FOO=default', '-scheme', 'Old'], ['-scheme', 'FOO=Bar'])).toEqual([ + 'FOO=default', + '-scheme', + 'FOO=Bar', + ]); + }); + + it('keeps build settings separate from unknown valueless options', () => { + expect(mergeExtraArgs(['-futureFlag', 'FOO=default'], ['-futureFlag=on'])).toEqual([ + 'FOO=default', + '-futureFlag=on', + ]); + }); + + it('preserves repeated explicit args verbatim', () => { + expect( + mergeExtraArgs( + ['-destination', 'id=DEFAULT'], + ['-destination', 'id=ONE', '-destination', 'id=TWO'], + ), + ).toEqual(['-destination', 'id=ONE', '-destination', 'id=TWO']); + }); +}); diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts index ec373d55..e880fb79 100644 --- a/src/utils/__tests__/session-store.test.ts +++ b/src/utils/__tests__/session-store.test.ts @@ -122,6 +122,26 @@ describe('SessionStore', () => { expect(stored.env).toEqual({ API_KEY: 'secret' }); }); + it('does not retain external extraArgs array references passed into setDefaults', () => { + const extraArgs = ['-skipPackagePluginValidation']; + sessionStore.setDefaults({ extraArgs }); + + extraArgs.push('-quiet'); + + const stored = sessionStore.getAll(); + expect(stored.extraArgs).toEqual(['-skipPackagePluginValidation']); + }); + + it('getAll returns a detached copy of extraArgs so mutations do not affect stored defaults', () => { + sessionStore.setDefaults({ extraArgs: ['-skipPackagePluginValidation'] }); + + const copy = sessionStore.getAll(); + copy.extraArgs!.push('-quiet'); + + const stored = sessionStore.getAll(); + expect(stored.extraArgs).toEqual(['-skipPackagePluginValidation']); + }); + it('does not compute derivedDataPath from workspacePath', () => { sessionStore.setDefaults({ workspacePath: '/Users/dev/clone-1/MyApp.xcworkspace' }); diff --git a/src/utils/renderers/domain-result-text.ts b/src/utils/renderers/domain-result-text.ts index 97a3a5b2..a117546e 100644 --- a/src/utils/renderers/domain-result-text.ts +++ b/src/utils/renderers/domain-result-text.ts @@ -104,6 +104,7 @@ const SESSION_DEFAULT_KEYS = [ 'platform', 'bundleId', 'env', + 'extraArgs', ] as const; type CoverageTargetFile = { diff --git a/src/utils/session-default-args.ts b/src/utils/session-default-args.ts index d16a1810..a466cd81 100644 --- a/src/utils/session-default-args.ts +++ b/src/utils/session-default-args.ts @@ -30,6 +30,263 @@ export function pickSessionDefaultsForKeys( return pickedDefaults; } +// Known options disambiguate separated values that start with '-' or contain '='. +const valuelessXcodebuildExtraArgKeys = new Set([ + '-alltargets', + '-allowProvisioningDeviceRegistration', + '-allowProvisioningUpdates', + '-checkFirstLaunchStatus', + '-create-xcframework', + '-disableAutomaticPackageResolution', + '-disablePackageRepositoryCache', + '-downloadAllPlatforms', + '-enumerate-tests', + '-exportArchive', + '-exportLocalizations', + '-exportNotarizedApp', + '-help', + '-hideShellScriptEnvironment', + '-importLocalizations', + '-includeScreenshots', + '-json', + '-license', + '-list', + '-mergeImport', + '-onlyUsePackageVersionsFromResolvedFile', + '-parallelizeTargets', + '-prepareDeviceSupport', + '-quiet', + '-resolvePackageDependencies', + '-retry-tests-on-failure', + '-runFirstLaunch', + '-run-tests-until-failure', + '-showBuildSettings', + '-showBuildSettingsForIndex', + '-showBuildTimingSummary', + '-showTestPlans', + '-showdestinations', + '-showsdks', + '-skipMacroValidation', + '-skipPackageSignatureValidation', + '-skipPackageUpdates', + '-skipPackagePluginValidation', + '-skipUnavailableActions', + '-usage', + '-verbose', + '-version', +]); + +const valueTakingXcodebuildExtraArgKeys = new Set([ + '-arch', + '-architecture', + '-architectureVariant', + '-archivePath', + '-authenticationKeyID', + '-authenticationKeyIssuerID', + '-authenticationKeyPath', + '-buildVersion', + '-clonedSourcePackagesDirPath', + '-collect-test-diagnostics', + '-configuration', + '-defaultLanguage', + '-defaultPackageRegistryURL', + '-default-test-execution-time-allowance', + '-deleteComponent', + '-derivedDataPath', + '-destination', + '-destination-timeout', + '-downloadComponent', + '-downloadPlatform', + '-enableAddressSanitizer', + '-enableCodeCoverage', + '-enablePerformanceTestsDiagnostics', + '-enableThreadSanitizer', + '-enableUndefinedBehaviorSanitizer', + '-exportLanguage', + '-exportOptionsPlist', + '-exportPath', + '-find-executable', + '-find-library', + '-framework', + '-headers', + '-importComponent', + '-importPath', + '-importPlatform', + '-jobs', + '-library', + '-localizationPath', + '-maximum-concurrent-test-device-destinations', + '-maximum-concurrent-test-simulator-destinations', + '-maximum-parallel-testing-workers', + '-maximum-test-execution-time-allowance', + '-modelCode', + '-only-test-configuration', + '-only-testing', + '-osVersion', + '-output', + '-packageAuthorizationProvider', + '-packageCachePath', + '-packageDependencySCMToRegistryTransformation', + '-packageFingerprintPolicy', + '-packageSigningEntityPolicy', + '-parallel-testing-enabled', + '-parallel-testing-worker-count', + '-platform', + '-project', + '-resultBundlePath', + '-resultBundleVersion', + '-resultStreamPath', + '-scheme', + '-scmProvider', + '-sdk', + '-showComponent', + '-skip-test-configuration', + '-skip-testing', + '-target', + '-test-enumeration-format', + '-test-enumeration-output-path', + '-test-enumeration-style', + '-test-iterations', + '-test-repetition-relaunch-enabled', + '-test-timeouts-enabled', + '-testLanguage', + '-testPlan', + '-testProductsPath', + '-testRegion', + '-toolchain', + '-workspace', + '-xcconfig', + '-xctestrun', +]); + +type ExtraArgGroup = { + key: string | null; + args: unknown[]; +}; + +function findBuildSettingAssignmentIndex(arg: string): number { + let bracketDepth = 0; + + for (let index = 0; index < arg.length; index += 1) { + const char = arg[index]; + if (char === '[') { + bracketDepth += 1; + continue; + } + + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + continue; + } + + if (char === '=' && bracketDepth === 0) { + return index; + } + } + + return -1; +} + +function getExtraArgKey(arg: string): string | null { + if (arg.startsWith('-')) { + const separatorIndexes = [arg.indexOf(':'), arg.indexOf('=')].filter((index) => index !== -1); + const separatorIndex = Math.min(...separatorIndexes); + + return separatorIndexes.length === 0 ? arg : arg.slice(0, separatorIndex); + } + + const equalsIndex = findBuildSettingAssignmentIndex(arg); + return equalsIndex === -1 ? null : arg.slice(0, equalsIndex); +} + +function isStandaloneOption(arg: string): boolean { + return arg.startsWith('-') && !arg.includes(':') && !arg.includes('='); +} + +function hasInlineOptionValue(arg: string): boolean { + return arg.startsWith('-') && (arg.includes(':') || arg.includes('=')); +} + +function isKnownXcodebuildOption(arg: string): boolean { + const key = getExtraArgKey(arg); + return ( + key !== null && + (valuelessXcodebuildExtraArgKeys.has(key) || valueTakingXcodebuildExtraArgKeys.has(key)) + ); +} + +function canTreatAsSeparatedOptionValue(optionArg: string, valueArg: string): boolean { + const key = getExtraArgKey(optionArg); + if (key === null || !isStandaloneOption(optionArg) || valuelessXcodebuildExtraArgKeys.has(key)) { + return false; + } + + if (valueTakingXcodebuildExtraArgKeys.has(key)) { + return !isKnownXcodebuildOption(valueArg); + } + + return !valueArg.startsWith('-') && findBuildSettingAssignmentIndex(valueArg) === -1; +} + +// Consume each token once so values cannot also become override keys; preserve group and token order. +function groupExtraArgs(args: readonly unknown[]): ExtraArgGroup[] { + const groups: ExtraArgGroup[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (typeof arg !== 'string') { + groups.push({ key: null, args: [arg] }); + continue; + } + + const key = getExtraArgKey(arg); + const groupedArgs: unknown[] = [arg]; + const nextArg = args[index + 1]; + if ( + key !== null && + !hasInlineOptionValue(arg) && + typeof nextArg === 'string' && + canTreatAsSeparatedOptionValue(arg, nextArg) + ) { + groupedArgs.push(nextArg); + index += 1; + } + + groups.push({ key, args: groupedArgs }); + } + + return groups; +} + +function filterOverriddenExtraArgs( + defaultExtraArgs: readonly unknown[], + overriddenKeys: Set, +): unknown[] { + return groupExtraArgs(defaultExtraArgs) + .filter((group) => group.key === null || !overriddenKeys.has(group.key)) + .flatMap((group) => group.args); +} + +function mergeExtraArgs( + defaultExtraArgs: readonly unknown[], + explicitExtraArgs: readonly unknown[], +): unknown[] { + if (explicitExtraArgs.length === 0) { + return []; + } + + const overriddenKeys = new Set( + groupExtraArgs(explicitExtraArgs) + .map((group) => group.key) + .filter((key): key is string => key !== null), + ); + if (overriddenKeys.size === 0) { + return [...defaultExtraArgs, ...explicitExtraArgs]; + } + + return [...filterOverriddenExtraArgs(defaultExtraArgs, overriddenKeys), ...explicitExtraArgs]; +} + export function mergeSessionDefaultArgs(opts: { defaults: Record; explicitArgs: Record; @@ -45,6 +302,14 @@ export function mergeSessionDefaultArgs(opts: { const merged: Record = { ...opts.defaults, ...sanitizedArgs }; + if ( + Object.prototype.hasOwnProperty.call(sanitizedArgs, 'extraArgs') && + Array.isArray(opts.defaults.extraArgs) && + Array.isArray(sanitizedArgs.extraArgs) + ) { + merged.extraArgs = mergeExtraArgs(opts.defaults.extraArgs, sanitizedArgs.extraArgs); + } + if ( opts.defaults.env && typeof opts.defaults.env === 'object' && diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index 2e99735f..d8ab1573 100644 --- a/src/utils/session-defaults-schema.ts +++ b/src/utils/session-defaults-schema.ts @@ -19,6 +19,7 @@ export const sessionDefaultKeys = [ 'platform', 'bundleId', 'env', + 'extraArgs', ] as const; export type SessionDefaultKey = (typeof sessionDefaultKeys)[number]; @@ -57,4 +58,8 @@ export const sessionDefaultsSchema = z.object({ .record(nonEmptyString, z.string()) .optional() .describe('Default environment variables to pass to launched apps.'), + extraArgs: z + .array(z.string()) + .optional() + .describe('Default extra xcodebuild arguments for tools that accept extraArgs.'), }); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 186b745a..8793b54d 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -21,6 +21,7 @@ export type SessionDefaults = { platform?: string; bundleId?: string; env?: Record; + extraArgs?: string[]; }; class SessionStore { @@ -34,6 +35,9 @@ class SessionStore { if (copy.env) { copy.env = { ...copy.env }; } + if (copy.extraArgs) { + copy.extraArgs = [...copy.extraArgs]; + } return copy; }