Skip to content

Commit f7a2335

Browse files
authored
feat: add testmanagerd DTX service for XCTest (#148)
1 parent a23c31f commit f7a2335

File tree

13 files changed

+1577
-30
lines changed

13 files changed

+1577
-30
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"test:dvt:notification": "mocha test/integration/dvt_instruments/notifications-test.ts --exit --timeout 1m",
5555
"test:dvt:network-monitor": "mocha test/integration/dvt_instruments/network-monitor-test.ts --exit --timeout 1m",
5656
"test:dvt:process-control": "mocha test/integration/process-control-test.ts --exit --timeout 1m",
57+
"test:testmanagerd": "mocha test/integration/testmanagerd-test.ts --exit --timeout 2m",
5758
"test:tunnel-creation": "sudo tsx scripts/test-tunnel-creation.ts",
5859
"test:tunnel-creation:lsof": "sudo tsx scripts/test-tunnel-creation.ts --keep-open"
5960
},

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export type {
7171
ProcessControlService,
7272
ProcessLaunchOptions,
7373
OutputReceivedEvent,
74+
SendMessageOptions,
75+
TestmanagerdService,
76+
TestmanagerdServiceWithConnection,
7477
} from './lib/types.js';
7578
export { PowerAssertionType } from './lib/types.js';
7679
export { NetworkMessageType } from './services/ios/dvt/instruments/network-monitor.js';

src/lib/types.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,3 +1755,85 @@ export interface InstallationProxyServiceWithConnection {
17551755
/** The RemoteXPC connection for service management */
17561756
remoteXPC: RemoteXpcConnection;
17571757
}
1758+
1759+
/**
1760+
* Options for sending a DTX message
1761+
*/
1762+
export interface SendMessageOptions {
1763+
/** Optional message arguments */
1764+
args?: any | null;
1765+
/** Whether a reply is expected (default: true) */
1766+
expectsReply?: boolean;
1767+
}
1768+
1769+
/**
1770+
* Testmanagerd DTX service interface for XCTest session management
1771+
*/
1772+
export interface TestmanagerdService extends BaseService {
1773+
/**
1774+
* Connect to the testmanagerd service and perform DTX handshake
1775+
*/
1776+
connect(): Promise<void>;
1777+
1778+
/**
1779+
* Create a communication channel for a specific identifier
1780+
* @param identifier The channel identifier
1781+
* @returns The created channel
1782+
*/
1783+
makeChannel(identifier: string): Promise<any>;
1784+
1785+
/**
1786+
* Send a DTX message on a channel
1787+
* @param channel The channel code
1788+
* @param selector The ObjectiveC method selector
1789+
* @param options Optional message options
1790+
*/
1791+
sendMessage(
1792+
channel: number,
1793+
selector: string | null,
1794+
options?: SendMessageOptions,
1795+
): Promise<void>;
1796+
1797+
/**
1798+
* Receive a plist message from a channel
1799+
* @param channel The channel to receive from
1800+
* @param signal Optional AbortSignal for cancellation
1801+
* @returns Tuple of [decoded data, auxiliary values]
1802+
*/
1803+
recvPlist(channel?: number, signal?: AbortSignal): Promise<[any, any[]]>;
1804+
1805+
/**
1806+
* Receive a plist message with a timeout. Returns null on timeout instead
1807+
* of throwing, and properly cleans up socket listeners to prevent leaks.
1808+
* @param channel The channel to receive from
1809+
* @param timeoutMs Timeout in milliseconds
1810+
* @returns Tuple of [decoded data, auxiliary values], or null on timeout
1811+
*/
1812+
recvPlistWithTimeout(
1813+
channel?: number,
1814+
timeoutMs?: number,
1815+
): Promise<[any, any[]] | null>;
1816+
1817+
/**
1818+
* Send a DTX reply message for the last received message on a channel.
1819+
* Used for responding to callbacks like _XCT_testRunnerReadyWithCapabilities:.
1820+
* @param channel The channel code
1821+
* @param payload Optional archived payload to include in the reply
1822+
*/
1823+
sendReply(channel: number, payload?: Buffer | null): Promise<void>;
1824+
1825+
/**
1826+
* Close the testmanagerd service connection
1827+
*/
1828+
close(): Promise<void>;
1829+
}
1830+
1831+
/**
1832+
* Represents a TestmanagerdService instance with its associated RemoteXPC connection
1833+
*/
1834+
export interface TestmanagerdServiceWithConnection {
1835+
/** The testmanagerd service instance */
1836+
testmanagerdService: TestmanagerdService;
1837+
/** The RemoteXPC connection for service management */
1838+
remoteXPC: RemoteXpcConnection;
1839+
}

src/services.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
PowerAssertionServiceWithConnection,
1717
SpringboardServiceWithConnection,
1818
SyslogService as SyslogServiceType,
19+
TestmanagerdServiceWithConnection,
1920
WebInspectorServiceWithConnection,
2021
} from './lib/types.js';
2122
import AfcService from './services/ios/afc/index.js';
@@ -40,6 +41,7 @@ import { NotificationProxyService } from './services/ios/notification-proxy/inde
4041
import { PowerAssertionService } from './services/ios/power-assertion/index.js';
4142
import { SpringBoardService } from './services/ios/springboard-service/index.js';
4243
import SyslogService from './services/ios/syslog-service/index.js';
44+
import { DvtTestmanagedProxyService } from './services/ios/testmanagerd/index.js';
4345
import { WebInspectorService } from './services/ios/webinspector/index.js';
4446

4547
const APPIUM_XCUITEST_DRIVER_NAME = 'appium-xcuitest-driver';
@@ -293,6 +295,27 @@ export async function startDVTService(
293295
};
294296
}
295297

298+
export async function startTestmanagerdService(
299+
udid: string,
300+
): Promise<TestmanagerdServiceWithConnection> {
301+
const { remoteXPC, tunnelConnection } = await createRemoteXPCConnection(udid);
302+
const testmanagerdDescriptor = remoteXPC.findService(
303+
DvtTestmanagedProxyService.RSD_SERVICE_NAME,
304+
);
305+
306+
const testmanagerdService = new DvtTestmanagedProxyService([
307+
tunnelConnection.host,
308+
parseInt(testmanagerdDescriptor.port, 10),
309+
]);
310+
311+
await testmanagerdService.connect();
312+
313+
return {
314+
remoteXPC: remoteXPC as RemoteXpcConnection,
315+
testmanagerdService,
316+
};
317+
}
318+
296319
export async function createRemoteXPCConnection(udid: string) {
297320
const tunnelConnection = await getTunnelInformation(udid);
298321
const remoteXPC = await startService(

src/services/ios/base-service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type net from 'node:net';
2+
13
import { getLogger } from '../../lib/logger.js';
24
import { ServiceConnection } from '../../service-connection.js';
35

@@ -77,4 +79,14 @@ export class BaseService {
7779
}
7880
}
7981

82+
/**
83+
* Remove any SSL wrapper from the socket so raw binary protocols (DTX)
84+
* can read/write directly. Both DVT and testmanagerd services require this.
85+
*/
86+
export function stripSSL(socket: net.Socket): void {
87+
if ('_sslobj' in socket) {
88+
(socket as any)._sslobj = null;
89+
}
90+
}
91+
8092
export default BaseService;

src/services/ios/dvt/channel.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
import type { SendMessageOptions } from '../../../lib/types.js';
12
import type { MessageAux } from './dtx-message.js';
2-
import type { DVTSecureSocketProxyService } from './index.js';
3+
4+
/**
5+
* Interface for DTX services that can be used with Channel.
6+
* Both DVTSecureSocketProxyService and DvtTestmanagedProxyService implement this.
7+
*/
8+
export interface DTXServiceProvider {
9+
recvPlist(channel: number, signal?: AbortSignal): Promise<[any, any[]]>;
10+
sendMessage(
11+
channel: number,
12+
selector: string | null,
13+
options?: SendMessageOptions,
14+
): Promise<void>;
15+
}
316

417
export type ChannelMethodCall = (
518
args?: MessageAux,
@@ -12,9 +25,16 @@ export type ChannelMethodCall = (
1225
export class Channel {
1326
constructor(
1427
private readonly channelCode: number,
15-
private readonly service: DVTSecureSocketProxyService,
28+
private readonly service: DTXServiceProvider,
1629
) {}
1730

31+
/**
32+
* Get the channel code
33+
*/
34+
getCode(): number {
35+
return this.channelCode;
36+
}
37+
1838
/**
1939
* Receive a plist response from the channel
2040
*/
@@ -45,12 +65,10 @@ export class Channel {
4565
call(methodName: string): ChannelMethodCall {
4666
const selector = this.convertToSelector(methodName);
4767
return (async (args, expectsReply = true) => {
48-
await this.service.sendMessage(
49-
this.channelCode,
50-
selector,
68+
await this.service.sendMessage(this.channelCode, selector, {
5169
args,
5270
expectsReply,
53-
);
71+
});
5472
}) as ChannelMethodCall;
5573
}
5674

src/services/ios/dvt/dtx-message.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,24 @@ export const DTX_CONSTANTS = {
4242

4343
// Message types
4444
INSTRUMENTS_MESSAGE_TYPE: 2,
45+
REPLY_TYPE: 3,
4546
EXPECTS_REPLY_MASK: 0x1000,
4647

48+
// Compression
49+
COMPRESSION_MASK: 0xff000,
50+
COMPRESSION_SHIFT: 12,
51+
4752
// Auxiliary value types
4853
AUX_TYPE_OBJECT: 2,
4954
AUX_TYPE_INT32: 3,
5055
AUX_TYPE_INT64: 6,
56+
57+
// PrimitiveDictionary value types
58+
PRIMITIVE_TYPE_STRING: 0x01,
59+
PRIMITIVE_TYPE_BYTEARRAY: 0x02,
60+
PRIMITIVE_TYPE_UINT32: 0x03,
61+
PRIMITIVE_TYPE_INT64: 0x06,
62+
PRIMITIVE_TYPE_NULL: 0x0a,
5163
} as const;
5264

5365
/**

src/services/ios/dvt/index.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import net from 'node:net';
1+
import type net from 'node:net';
22

33
import { getLogger } from '../../../lib/logger.js';
44
import {
55
PlistUID,
66
createBinaryPlist,
77
parseBinaryPlist,
88
} from '../../../lib/plist/index.js';
9-
import type { PlistDictionary } from '../../../lib/types.js';
9+
import type {
10+
PlistDictionary,
11+
SendMessageOptions,
12+
} from '../../../lib/types.js';
1013
import { ServiceConnection } from '../../../service-connection.js';
11-
import { BaseService, type Service } from '../base-service.js';
14+
import { BaseService, type Service, stripSSL } from '../base-service.js';
1215
import { ChannelFragmenter } from './channel-fragmenter.js';
1316
import { Channel } from './channel.js';
1417
import { DTXMessage, DTX_CONSTANTS, MessageAux } from './dtx-message.js';
@@ -68,11 +71,7 @@ export class DVTSecureSocketProxyService extends BaseService {
6871
// DVT uses DTX binary protocol, connect without plist-based RSDCheckin
6972
this.connection = await this.startLockdownWithoutCheckin(service);
7073
this.socket = this.connection.getSocket();
71-
72-
// Remove SSL context if present for raw DTX communication
73-
if ('_sslobj' in this.socket) {
74-
(this.socket as any)._sslobj = null;
75-
}
74+
stripSSL(this.socket);
7675

7776
await this.performHandshake();
7877
}
@@ -105,7 +104,7 @@ export class DVTSecureSocketProxyService extends BaseService {
105104
args.appendInt(channelCode);
106105
args.appendObj(identifier);
107106

108-
await this.sendMessage(0, '_requestChannelWithCode:identifier:', args);
107+
await this.sendMessage(0, '_requestChannelWithCode:identifier:', { args });
109108

110109
const [ret] = await this.recvPlist();
111110

@@ -123,15 +122,14 @@ export class DVTSecureSocketProxyService extends BaseService {
123122
* Send a DTX message on a channel
124123
* @param channel The channel code
125124
* @param selector The ObjectiveC method selector
126-
* @param args Optional message arguments
127-
* @param expectsReply Whether a reply is expected
125+
* @param options Optional message options
128126
*/
129127
async sendMessage(
130128
channel: number,
131129
selector: string | null = null,
132-
args: MessageAux | null = null,
133-
expectsReply: boolean = true,
130+
options: SendMessageOptions = {},
134131
): Promise<void> {
132+
const { args = null, expectsReply = true } = options;
135133
if (!this.socket) {
136134
throw new Error('Not connected to DVT service');
137135
}
@@ -273,8 +271,7 @@ export class DVTSecureSocketProxyService extends BaseService {
273271
await this.sendMessage(
274272
DVTSecureSocketProxyService.BROADCAST_CHANNEL,
275273
'_channelCanceled:',
276-
args,
277-
false,
274+
{ args, expectsReply: false },
278275
);
279276
} catch (error) {
280277
log.debug('Error sending channel canceled message:', error);
@@ -302,7 +299,10 @@ export class DVTSecureSocketProxyService extends BaseService {
302299
'com.apple.private.DTXBlockCompression': 0,
303300
'com.apple.private.DTXConnection': 1,
304301
});
305-
await this.sendMessage(0, '_notifyOfPublishedCapabilities:', args, false);
302+
await this.sendMessage(0, '_notifyOfPublishedCapabilities:', {
303+
args,
304+
expectsReply: false,
305+
});
306306

307307
const [retData, aux] = await this.recvMessage();
308308
const ret = retData ? parseBinaryPlist(retData) : null;

src/services/ios/dvt/nskeyedarchiver-encoder.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ const log = getLogger('NSKeyedArchiverEncoder');
88
* capable of satisfying NSSecureCoding requirements.
99
*/
1010
export class NSKeyedArchiverEncoder {
11-
private objects: any[] = ['$null'];
12-
private objectCache = new Map<any, number>(); // Cache for object identity/deduplication
13-
private classes = new Map<string, number>(); // Cache for class definitions
11+
protected objects: any[] = ['$null'];
12+
protected objectCache = new Map<any, number>(); // Cache for object identity/deduplication
13+
protected classes = new Map<string, number>(); // Cache for class definitions
1414

1515
/**
1616
* Encode the root value into NSKeyedArchiver format
@@ -26,7 +26,7 @@ export class NSKeyedArchiverEncoder {
2626
};
2727
}
2828

29-
private archiveObject(value: any): number {
29+
protected archiveObject(value: any): number {
3030
if (value === null || value === undefined) {
3131
return 0; // $null is always at index 0
3232
}
@@ -75,7 +75,7 @@ export class NSKeyedArchiverEncoder {
7575
return index;
7676
}
7777

78-
private archiveArray(array: any[]): number {
78+
protected archiveArray(array: any[]): number {
7979
const index = this.objects.length;
8080
this.objects.push(null); // Placeholder
8181
this.objectCache.set(array, index);
@@ -97,7 +97,7 @@ export class NSKeyedArchiverEncoder {
9797
return index;
9898
}
9999

100-
private archiveDictionary(dict: Record<string, any>): number {
100+
protected archiveDictionary(dict: Record<string, any>): number {
101101
const index = this.objects.length;
102102
this.objects.push(null); // Placeholder
103103
this.objectCache.set(dict, index);
@@ -117,7 +117,7 @@ export class NSKeyedArchiverEncoder {
117117
return index;
118118
}
119119

120-
private archiveBuffer(buffer: Buffer): number {
120+
protected archiveBuffer(buffer: Buffer): number {
121121
const index = this.objects.length;
122122
this.objects.push(null);
123123
this.objectCache.set(buffer, index);
@@ -132,7 +132,7 @@ export class NSKeyedArchiverEncoder {
132132
return index;
133133
}
134134

135-
private getClassUid(classname: string, ...superclasses: string[]): number {
135+
protected getClassUid(classname: string, ...superclasses: string[]): number {
136136
if (this.classes.has(classname)) {
137137
return this.classes.get(classname)!;
138138
}

0 commit comments

Comments
 (0)