Skip to content

Commit 5301d4d

Browse files
committed
Adding ability to read logs from other subsystems
1 parent c502e79 commit 5301d4d

4 files changed

Lines changed: 215 additions & 28 deletions

File tree

src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('start_sim_log_cap plugin', () => {
2323

2424
it('should have correct description', () => {
2525
expect(plugin.description).toBe(
26-
'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.',
26+
"Starts capturing logs from a specified simulator. Returns a session ID. Use subsystemFilter to control what logs are captured: 'app' (default), 'all' (everything), 'swiftui' (includes Self._printChanges()), or custom subsystems.",
2727
);
2828
});
2929

@@ -42,6 +42,38 @@ describe('start_sim_log_cap plugin', () => {
4242
);
4343
});
4444

45+
it('should validate schema with subsystemFilter parameter', () => {
46+
const schema = z.object(plugin.schema);
47+
// Valid enum values
48+
expect(
49+
schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'app' }).success,
50+
).toBe(true);
51+
expect(
52+
schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'all' }).success,
53+
).toBe(true);
54+
expect(
55+
schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'swiftui' }).success,
56+
).toBe(true);
57+
// Valid array of subsystems
58+
expect(
59+
schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: ['com.apple.UIKit'] })
60+
.success,
61+
).toBe(true);
62+
expect(
63+
schema.safeParse({
64+
bundleId: 'com.example.app',
65+
subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'],
66+
}).success,
67+
).toBe(true);
68+
// Invalid values
69+
expect(
70+
schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 'invalid' }).success,
71+
).toBe(false);
72+
expect(schema.safeParse({ bundleId: 'com.example.app', subsystemFilter: 123 }).success).toBe(
73+
false,
74+
);
75+
});
76+
4577
it('should reject invalid schema parameters', () => {
4678
const schema = z.object(plugin.schema);
4779
expect(schema.safeParse({ bundleId: null }).success).toBe(false);
@@ -110,8 +142,85 @@ describe('start_sim_log_cap plugin', () => {
110142

111143
expect(result.isError).toBeUndefined();
112144
expect(result.content[0].text).toBe(
113-
"Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Only structured logs are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
145+
"Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
146+
);
147+
});
148+
149+
it('should indicate swiftui capture when subsystemFilter is swiftui', async () => {
150+
const mockExecutor = createMockExecutor({ success: true, output: '' });
151+
const logCaptureStub = (params: any, executor: any) => {
152+
return Promise.resolve({
153+
sessionId: 'test-uuid-123',
154+
logFilePath: '/tmp/test.log',
155+
processes: [],
156+
error: undefined,
157+
});
158+
};
159+
160+
const result = await start_sim_log_capLogic(
161+
{
162+
simulatorId: 'test-uuid',
163+
bundleId: 'com.example.app',
164+
subsystemFilter: 'swiftui',
165+
},
166+
mockExecutor,
167+
logCaptureStub,
168+
);
169+
170+
expect(result.isError).toBeUndefined();
171+
expect(result.content[0].text).toContain('SwiftUI logs');
172+
expect(result.content[0].text).toContain('Self._printChanges()');
173+
});
174+
175+
it('should indicate all logs capture when subsystemFilter is all', async () => {
176+
const mockExecutor = createMockExecutor({ success: true, output: '' });
177+
const logCaptureStub = (params: any, executor: any) => {
178+
return Promise.resolve({
179+
sessionId: 'test-uuid-123',
180+
logFilePath: '/tmp/test.log',
181+
processes: [],
182+
error: undefined,
183+
});
184+
};
185+
186+
const result = await start_sim_log_capLogic(
187+
{
188+
simulatorId: 'test-uuid',
189+
bundleId: 'com.example.app',
190+
subsystemFilter: 'all',
191+
},
192+
mockExecutor,
193+
logCaptureStub,
114194
);
195+
196+
expect(result.isError).toBeUndefined();
197+
expect(result.content[0].text).toContain('all system logs');
198+
});
199+
200+
it('should indicate custom subsystems when array is provided', async () => {
201+
const mockExecutor = createMockExecutor({ success: true, output: '' });
202+
const logCaptureStub = (params: any, executor: any) => {
203+
return Promise.resolve({
204+
sessionId: 'test-uuid-123',
205+
logFilePath: '/tmp/test.log',
206+
processes: [],
207+
error: undefined,
208+
});
209+
};
210+
211+
const result = await start_sim_log_capLogic(
212+
{
213+
simulatorId: 'test-uuid',
214+
bundleId: 'com.example.app',
215+
subsystemFilter: ['com.apple.UIKit', 'com.apple.CoreData'],
216+
},
217+
mockExecutor,
218+
logCaptureStub,
219+
);
220+
221+
expect(result.isError).toBeUndefined();
222+
expect(result.content[0].text).toContain('com.apple.UIKit');
223+
expect(result.content[0].text).toContain('com.apple.CoreData');
115224
});
116225

117226
it('should indicate console capture when captureConsole is true', async () => {
@@ -135,9 +244,8 @@ describe('start_sim_log_cap plugin', () => {
135244
logCaptureStub,
136245
);
137246

138-
expect(result.content[0].text).toBe(
139-
"Log capture started successfully. Session ID: test-uuid-123.\n\nNote: Your app was relaunched to capture console output.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
140-
);
247+
expect(result.content[0].text).toContain('Your app was relaunched to capture console output');
248+
expect(result.content[0].text).toContain('test-uuid-123');
141249
});
142250

143251
it('should create correct spawn commands for console capture', async () => {

src/mcp/tools/logging/start_sim_log_cap.ts

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { z } from 'zod';
8-
import { startLogCapture } from '../../../utils/log-capture/index.ts';
8+
import { startLogCapture, SubsystemFilter } from '../../../utils/log-capture/index.ts';
99
import { CommandExecutor, getDefaultCommandExecutor } from '../../../utils/command.ts';
1010
import { ToolResponse, createTextContent } from '../../../types/common.ts';
1111
import {
@@ -24,6 +24,12 @@ const startSimLogCapSchema = z.object({
2424
.boolean()
2525
.optional()
2626
.describe('Whether to capture console output (requires app relaunch).'),
27+
subsystemFilter: z
28+
.union([z.enum(['app', 'all', 'swiftui']), z.array(z.string())])
29+
.optional()
30+
.describe(
31+
"Controls which log subsystems to capture. Options: 'app' (default, only app logs), 'all' (capture all system logs), 'swiftui' (app + SwiftUI logs for Self._printChanges()), or an array of custom subsystem strings.",
32+
),
2733
});
2834

2935
// Use z.infer for type safety
@@ -35,24 +41,49 @@ export async function start_sim_log_capLogic(
3541
logCaptureFunction: typeof startLogCapture = startLogCapture,
3642
): Promise<ToolResponse> {
3743
const captureConsole = params.captureConsole ?? false;
38-
const { sessionId, error } = await logCaptureFunction(
39-
{
40-
simulatorUuid: params.simulatorId,
41-
bundleId: params.bundleId,
42-
captureConsole,
43-
},
44-
_executor,
45-
);
44+
// Normalize subsystem filter with explicit type handling for the union type
45+
let subsystemFilter: SubsystemFilter = 'app';
46+
if (params.subsystemFilter !== undefined) {
47+
if (Array.isArray(params.subsystemFilter)) {
48+
subsystemFilter = params.subsystemFilter;
49+
} else if (
50+
params.subsystemFilter === 'app' ||
51+
params.subsystemFilter === 'all' ||
52+
params.subsystemFilter === 'swiftui'
53+
) {
54+
subsystemFilter = params.subsystemFilter;
55+
}
56+
}
57+
const logCaptureParams: Parameters<typeof startLogCapture>[0] = {
58+
simulatorUuid: params.simulatorId,
59+
bundleId: params.bundleId,
60+
captureConsole,
61+
subsystemFilter,
62+
};
63+
const { sessionId, error } = await logCaptureFunction(logCaptureParams, _executor);
4664
if (error) {
4765
return {
4866
content: [createTextContent(`Error starting log capture: ${error}`)],
4967
isError: true,
5068
};
5169
}
70+
71+
// Build subsystem filter description for the response
72+
let filterDescription: string;
73+
if (subsystemFilter === 'all') {
74+
filterDescription = 'Capturing all system logs (no subsystem filtering).';
75+
} else if (subsystemFilter === 'swiftui') {
76+
filterDescription = 'Capturing app logs + SwiftUI logs (includes Self._printChanges()).';
77+
} else if (Array.isArray(subsystemFilter)) {
78+
filterDescription = `Capturing logs from subsystems: ${subsystemFilter.join(', ')} (plus app bundle ID).`;
79+
} else {
80+
filterDescription = 'Only structured logs from the app subsystem are being captured.';
81+
}
82+
5283
return {
5384
content: [
5485
createTextContent(
55-
`Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.' : 'Note: Only structured logs are being captured.'}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`,
86+
`Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`,
5687
),
5788
],
5889
};
@@ -63,7 +94,7 @@ const publicSchemaObject = startSimLogCapSchema.omit({ simulatorId: true } as co
6394
export default {
6495
name: 'start_sim_log_cap',
6596
description:
66-
'Starts capturing logs from a specified simulator. Returns a session ID. By default, captures only structured logs.',
97+
"Starts capturing logs from a specified simulator. Returns a session ID. Use subsystemFilter to control what logs are captured: 'app' (default), 'all' (everything), 'swiftui' (includes Self._printChanges()), or custom subsystems.",
6798
schema: getSessionAwareToolSchemaShape({
6899
sessionAware: publicSchemaObject,
69100
legacy: startSimLogCapSchema,

src/utils/log-capture/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { startLogCapture, stopLogCapture } from '../log_capture.ts';
2+
export type { SubsystemFilter } from '../log_capture.ts';

src/utils/log_capture.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,39 @@ export interface LogSession {
2121
bundleId: string;
2222
}
2323

24+
/**
25+
* Subsystem filter options for log capture.
26+
* - 'app': Only capture logs from the app's bundle ID subsystem (default)
27+
* - 'all': Capture all logs (no subsystem filtering)
28+
* - 'swiftui': Capture logs from app + SwiftUI subsystem (useful for Self._printChanges())
29+
* - string[]: Custom array of subsystems to capture (always includes the app's bundle ID)
30+
*/
31+
export type SubsystemFilter = 'app' | 'all' | 'swiftui' | string[];
32+
33+
/**
34+
* Build the predicate string for log filtering based on subsystem filter option.
35+
*/
36+
function buildLogPredicate(bundleId: string, subsystemFilter: SubsystemFilter): string | null {
37+
if (subsystemFilter === 'all') {
38+
// No filtering - capture everything from this process
39+
return null;
40+
}
41+
42+
if (subsystemFilter === 'app') {
43+
return `subsystem == "${bundleId}"`;
44+
}
45+
46+
if (subsystemFilter === 'swiftui') {
47+
// Include both app logs and SwiftUI logs (for Self._printChanges())
48+
return `subsystem == "${bundleId}" OR subsystem == "com.apple.SwiftUI"`;
49+
}
50+
51+
// Custom array of subsystems - always include the app's bundle ID
52+
const subsystems = new Set([bundleId, ...subsystemFilter]);
53+
const predicates = Array.from(subsystems).map((s) => `subsystem == "${s}"`);
54+
return predicates.join(' OR ');
55+
}
56+
2457
export const activeLogSessions: Map<string, LogSession> = new Map();
2558

2659
/**
@@ -33,13 +66,20 @@ export async function startLogCapture(
3366
bundleId: string;
3467
captureConsole?: boolean;
3568
args?: string[];
69+
subsystemFilter?: SubsystemFilter;
3670
},
3771
executor: CommandExecutor = getDefaultCommandExecutor(),
3872
): Promise<{ sessionId: string; logFilePath: string; processes: ChildProcess[]; error?: string }> {
3973
// Clean up old logs before starting a new session
4074
await cleanOldLogs();
4175

42-
const { simulatorUuid, bundleId, captureConsole = false, args = [] } = params;
76+
const {
77+
simulatorUuid,
78+
bundleId,
79+
captureConsole = false,
80+
args = [],
81+
subsystemFilter = 'app',
82+
} = params;
4383
const logSessionId = uuidv4();
4484
const logFileName = `${LOG_FILE_PREFIX}${logSessionId}.log`;
4585
const logFilePath = path.join(os.tmpdir(), logFileName);
@@ -87,18 +127,25 @@ export async function startLogCapture(
87127
processes.push(stdoutLogResult.process);
88128
}
89129

130+
// Build the log stream command based on subsystem filter
131+
const logPredicate = buildLogPredicate(bundleId, subsystemFilter);
132+
const osLogCommand = [
133+
'xcrun',
134+
'simctl',
135+
'spawn',
136+
simulatorUuid,
137+
'log',
138+
'stream',
139+
'--level=debug',
140+
];
141+
142+
// Only add predicate if filtering is needed
143+
if (logPredicate) {
144+
osLogCommand.push('--predicate', logPredicate);
145+
}
146+
90147
const osLogResult = await executor(
91-
[
92-
'xcrun',
93-
'simctl',
94-
'spawn',
95-
simulatorUuid,
96-
'log',
97-
'stream',
98-
'--level=debug',
99-
'--predicate',
100-
`subsystem == "${bundleId}"`,
101-
],
148+
osLogCommand,
102149
'OS Log Capture',
103150
true, // useShell
104151
undefined, // env

0 commit comments

Comments
 (0)