diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 3e8d23de6f..c808c8f0e0 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -33,6 +33,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--emulator` - If set, runs the app in all available and configured ios simulators. It will start a simulator if none are already running. * `--sdk` - Specifies the target simulator's sdk. * `--justlaunch` - If set, does not print the application output in the console. +* `--log ` - Also controls how much of the application output is printed. By default only its JavaScript output is shown. `debug` adds everything else the app logs - its native code, `print()` output and the system frameworks it uses, like Xcode's console - and `trace` adds debug level entries as well. * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. diff --git a/lib/common/constants.ts b/lib/common/constants.ts index 447efc7ca0..bfa8170818 100644 --- a/lib/common/constants.ts +++ b/lib/common/constants.ts @@ -62,9 +62,12 @@ export class EmulatorDiscoveryNames { } export const DEVICE_LOG_EVENT_NAME = "deviceLogData"; +export const DEVICE_LOG_ENTRY_EVENT_NAME = "deviceLogEntry"; export const IOS_LOG_PREDICATE = 'senderImagePath contains "NativeScript"'; -export const IOS_APP_CRASH_LOG_REG_EXP = /Fatal JavaScript exception \- application has been terminated/; -export const FAIL_LIVESYNC_LOG_REGEX = /Failed to refresh the application with RefreshRequest./; +export const IOS_APP_CRASH_LOG_REG_EXP = + /Fatal JavaScript exception \- application has been terminated/; +export const FAIL_LIVESYNC_LOG_REGEX = + /Failed to refresh the application with RefreshRequest./; export const TARGET_FRAMEWORK_IDENTIFIERS = { Cordova: "Cordova", diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index bb522079fa..12e8de36a6 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -154,6 +154,29 @@ declare global { /** * Describes log stream options */ + /** + * A structured unified logging (os_log) entry. + */ + interface IiOSLogEntry { + /** + * Together with the thread, tells apart messages logged at the same time. + */ + pid?: number; + thread?: number; + /** + * Path of the executable of the process that logged the entry. + */ + process: string; + /** + * Path of the binary (executable, framework or library) that logged the entry. + */ + image: string; + level: string; + message: string; + subsystem?: string; + category?: string; + } + interface IiOSLogStreamOptions { /** * This is the --predicate option which will be passed to `log stream` command @@ -213,6 +236,21 @@ declare global { */ logData(line: string, platform: string, deviceIdentifier: string): void; + /** + * Logs a structured iOS log entry, when it belongs to the application + * whose logs are shown on the device. + */ + logEntry( + entry: IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void; + + /** + * Returns the project name set for the device, if any. + */ + getProjectNameForDevice(deviceIdentifier: string): string; + /** * Sets the level of logging that will be used. * @param {string} level The level of logs - could be INFO or FULL. @@ -1174,6 +1212,11 @@ declare global { shouldReturnImmediateResult: boolean; platform: string; fullDiscovery?: boolean; + /** + * Ends the wait for physical devices once the device requested on the + * command line has been found by any discovery. + */ + isRequestedDeviceFound?: () => boolean; } interface IAndroidDeviceHashService { @@ -1326,6 +1369,15 @@ declare global { startDeviceLog(deviceIdentifier: string): void; + /** + * Streams structured logs of the process with the given executable name. + * Returns false when the installed ios-device-lib cannot stream them. + */ + startDeviceLogStream( + deviceIdentifier: string, + processName: string, + ): boolean; + apps( deviceIdentifiers: string[], errorHandler?: DeviceOperationErrorHandler, diff --git a/lib/common/mobile/device-log-emitter.ts b/lib/common/mobile/device-log-emitter.ts index dbb59149a5..e4659a780d 100644 --- a/lib/common/mobile/device-log-emitter.ts +++ b/lib/common/mobile/device-log-emitter.ts @@ -2,13 +2,14 @@ import { DeviceLogProviderBase } from "./device-log-provider-base"; import { DEVICE_LOG_EVENT_NAME } from "../constants"; import { injector } from "../yok"; import * as _ from "lodash"; +import { getIOSLogEntryText, getIOSLogScope } from "./ios/ios-log-entry"; export class DeviceLogEmitter extends DeviceLogProviderBase { constructor( protected $logFilter: Mobile.ILogFilter, $logger: ILogger, private $loggingLevels: Mobile.ILoggingLevels, - protected $logSourceMapService: Mobile.ILogSourceMapService + protected $logSourceMapService: Mobile.ILogSourceMapService, ) { super($logFilter, $logger, $logSourceMapService); } @@ -16,20 +17,57 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { public logData( line: string, platform: string, - deviceIdentifier: string + deviceIdentifier: string, ): void { this.setDefaultLogLevelForDevice(deviceIdentifier); const loggingOptions = this.getDeviceLogOptionsForDevice( - deviceIdentifier + deviceIdentifier, ) || { logLevel: this.$loggingLevels.info, projectDir: null }; let data = this.$logFilter.filterData(platform, line, loggingOptions); data = this.$logSourceMapService.replaceWithOriginalFileLocations( platform, data, - loggingOptions + loggingOptions, ); + this.emitData(data, platform, deviceIdentifier); + } + + public logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + this.stitchIOSLogEntry(entry, deviceIdentifier, (completeEntry) => { + const loggingOptions = + this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(completeEntry, { + processName: loggingOptions && loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions?.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { + this.emitData( + this.$logSourceMapService.replaceWithOriginalFileLocations( + platform, + text + "\n", + loggingOptions, + ), + platform, + deviceIdentifier, + ); + } + }); + } + + private emitData( + data: string, + platform: string, + deviceIdentifier: string, + ): void { if (data) { this.emit("data", deviceIdentifier, data); this.emit(DEVICE_LOG_EVENT_NAME, data, deviceIdentifier, platform); @@ -42,7 +80,7 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.logLevel, - logLevel.toUpperCase() + logLevel.toUpperCase(), ); } else { this.$logFilter.loggingLevel = logLevel.toUpperCase(); @@ -50,9 +88,8 @@ export class DeviceLogEmitter extends DeviceLogProviderBase { _.keys(this.devicesLogOptions).forEach((deviceId) => { this.devicesLogOptions[deviceId] = this.devicesLogOptions[deviceId] || {}; - this.devicesLogOptions[ - deviceId - ].logLevel = this.$logFilter.loggingLevel; + this.devicesLogOptions[deviceId].logLevel = + this.$logFilter.loggingLevel; }); } } diff --git a/lib/common/mobile/device-log-provider-base.ts b/lib/common/mobile/device-log-provider-base.ts index 1329cde6da..eee704c96f 100644 --- a/lib/common/mobile/device-log-provider-base.ts +++ b/lib/common/mobile/device-log-provider-base.ts @@ -1,27 +1,29 @@ import { EventEmitter } from "events"; import { getPropertyName } from "../helpers"; import { IDictionary } from "../declarations"; +import { IOSLogChunkStitcher, isIOSRuntimeLogEntry } from "./ios/ios-log-entry"; export abstract class DeviceLogProviderBase extends EventEmitter implements Mobile.IDeviceLogProvider { protected devicesLogOptions: IDictionary = {}; + private iOSLogChunkStitcher = new IOSLogChunkStitcher(); constructor( protected $logFilter: Mobile.ILogFilter, protected $logger: ILogger, - protected $logSourceMapService: Mobile.ILogSourceMapService + protected $logSourceMapService: Mobile.ILogSourceMapService, ) { super(); } public async setSourceFileLocation( - pathToOriginalFile: string + pathToOriginalFile: string, ): Promise { try { await this.$logSourceMapService.setSourceMapConsumerForFile( - pathToOriginalFile + pathToOriginalFile, ); } catch (err) { this.$logger.trace("Error while trying to set source map file", err); @@ -31,23 +33,46 @@ export abstract class DeviceLogProviderBase public abstract logData( lineText: string, platform: string, - deviceIdentifier: string + deviceIdentifier: string, + ): void; + + public abstract logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, ): void; public abstract setLogLevel( logLevel: string, - deviceIdentifier?: string + deviceIdentifier?: string, ): void; + protected stitchIOSLogEntry( + entry: Mobile.IiOSLogEntry, + deviceIdentifier: string, + emit: (entry: Mobile.IiOSLogEntry) => void, + ): void { + if (!isIOSRuntimeLogEntry(entry)) { + emit(entry); + return; + } + + this.iOSLogChunkStitcher.add( + `${deviceIdentifier}:${entry.pid}`, + entry, + emit, + ); + } + public setApplicationPidForDevice( deviceIdentifier: string, - pid: string + pid: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.applicationPid, - pid + pid, ); } @@ -56,31 +81,31 @@ export abstract class DeviceLogProviderBase deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.applicationId, - appId + appId, ); } public setProjectNameForDevice( deviceIdentifier: string, - projectName: string + projectName: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.projectName, - projectName + projectName, ); } public setProjectDirForDevice( deviceIdentifier: string, - projectDir: string + projectDir: string, ): void { this.setDeviceLogOptionsProperty( deviceIdentifier, (deviceLogOptions: Mobile.IDeviceLogOptions) => deviceLogOptions.projectDir, - projectDir + projectDir, ); } @@ -99,6 +124,13 @@ export abstract class DeviceLogProviderBase ); } + public getProjectNameForDevice(deviceIdentifier: string): string { + return ( + this.devicesLogOptions[deviceIdentifier] && + this.devicesLogOptions[deviceIdentifier].projectName + ); + } + getApplicationIdForDevice(deviceIdentifier: string) { return ( this.devicesLogOptions[deviceIdentifier] && @@ -107,7 +139,7 @@ export abstract class DeviceLogProviderBase } protected getDeviceLogOptionsForDevice( - deviceIdentifier: string + deviceIdentifier: string, ): Mobile.IDeviceLogOptions { const loggingOptions = this.devicesLogOptions[deviceIdentifier]; if (!loggingOptions) { @@ -120,7 +152,7 @@ export abstract class DeviceLogProviderBase protected setDeviceLogOptionsProperty( deviceIdentifier: string, propNameFunction: Function, - propertyValue: string | boolean + propertyValue: string | boolean, ): void { const propertyName = getPropertyName(propNameFunction); diff --git a/lib/common/mobile/device-log-provider.ts b/lib/common/mobile/device-log-provider.ts index d5c548f780..5872f0f0e0 100644 --- a/lib/common/mobile/device-log-provider.ts +++ b/lib/common/mobile/device-log-provider.ts @@ -7,6 +7,7 @@ import { IOptions } from "../../declarations"; import { color, StyleFormat } from "../../color"; import { ITimelineProfilerService } from "../../services/timeline-profiler-service"; +import { getIOSLogEntryText, getIOSLogScope } from "./ios/ios-log-entry"; export class DeviceLogProvider extends DeviceLogProviderBase { constructor( @@ -15,6 +16,7 @@ export class DeviceLogProvider extends DeviceLogProviderBase { protected $logSourceMapService: Mobile.ILogSourceMapService, protected $timelineProfilerService: ITimelineProfilerService, protected $options: IOptions, + private $loggingLevels: Mobile.ILoggingLevels, ) { super($logFilter, $logger, $logSourceMapService); } @@ -24,13 +26,42 @@ export class DeviceLogProvider extends DeviceLogProviderBase { platform: string, deviceIdentifier: string, ): void { - // console.log(lineText) const loggingOptions = this.getDeviceLogOptionsForDevice(deviceIdentifier); - let data = this.$logFilter.filterData(platform, lineText, loggingOptions); + const data = this.$logFilter.filterData(platform, lineText, loggingOptions); + this.printData(data, lineText, platform, deviceIdentifier); + } + + public logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + this.stitchIOSLogEntry(entry, deviceIdentifier, (completeEntry) => { + const loggingOptions = + this.getDeviceLogOptionsForDevice(deviceIdentifier); + const text = getIOSLogEntryText(completeEntry, { + processName: loggingOptions.projectName, + ...getIOSLogScope( + this.$logger.getLevel(), + loggingOptions.logLevel?.toUpperCase() === this.$loggingLevels.full, + ), + }); + if (text) { + this.printData(text + "\n", text, platform, deviceIdentifier); + } + }); + } + + private printData( + data: string, + lineText: string, + platform: string, + deviceIdentifier: string, + ): void { data = this.$logSourceMapService.replaceWithOriginalFileLocations( platform, data, - loggingOptions, + this.getDeviceLogOptionsForDevice(deviceIdentifier), ); if (data) { diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index e0675096c7..b0e2a8a469 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -1,6 +1,9 @@ import { IOSDeviceLib as IOSDeviceLibModule } from "ios-device-lib"; import { cache } from "../../../decorators"; -import { DEVICE_LOG_EVENT_NAME } from "../../../constants"; +import { + DEVICE_LOG_ENTRY_EVENT_NAME, + DEVICE_LOG_EVENT_NAME, +} from "../../../constants"; import * as _ from "lodash"; import * as assert from "assert"; import { EventEmitter } from "events"; @@ -11,6 +14,22 @@ import { } from "../../../declarations"; import { injector } from "../../../yok"; +// Other devices can still be arriving by then, and device indexes follow the +// order they were discovered in. +const MIN_DISCOVERY_WAIT_MS = 2000; + +export function isDeviceDiscoveryDone( + foundDevice: boolean, + waitedMs: number, + options?: Mobile.IDeviceLookingOptions, +): boolean { + if (options?.isRequestedDeviceFound) { + return options.isRequestedDeviceFound(); + } + + return foundDevice && !options?.fullDiscovery && waitedMs >= MIN_DISCOVERY_WAIT_MS; +} + export class IOSDeviceOperations extends EventEmitter implements IIOSDeviceOperations, IDisposable, IShouldDispose @@ -86,21 +105,20 @@ export class IOSDeviceOperations // We need this because we need to make sure that we have devices. await new Promise((resolve, reject) => { - let iterationsCount = 0; - const maxIterationsCount = 3; + const pollInterval = 100; + const maxWait = 6000; + let waited = 0; const intervalHandle: NodeJS.Timer = setInterval(() => { - if (foundDevice && !options.fullDiscovery) { - resolve(); - return clearInterval(intervalHandle as unknown as number); - } - - iterationsCount++; - if (iterationsCount >= maxIterationsCount) { + waited += pollInterval; + if ( + waited >= maxWait || + isDeviceDiscoveryDone(foundDevice, waited, options) + ) { clearInterval(intervalHandle as unknown as number); return resolve(); } - }, 2000); + }, pollInterval); }); } } @@ -118,6 +136,28 @@ export class IOSDeviceOperations this.deviceLib.startDeviceLog([deviceIdentifier]); } + public startDeviceLogStream( + deviceIdentifier: string, + processName: string, + ): boolean { + this.assertIsInitialized(); + if (typeof this.deviceLib.startDeviceLogStream !== "function") { + return false; + } + + this.setShouldDispose(false); + this.$logger.trace( + `Streaming structured logs of ${processName} on device with identifier: ${deviceIdentifier}.`, + ); + + this.attachDeviceLogEntryHandler(); + + this.deviceLib.startDeviceLogStream([ + { deviceId: deviceIdentifier, processName }, + ]); + return true; + } + public async apps( deviceIdentifiers: string[], errorHandler?: DeviceOperationErrorHandler, @@ -365,6 +405,16 @@ export class IOSDeviceOperations assert.ok(this.isInitialized, "iOS device operations not initialized."); } + @cache() + private attachDeviceLogEntryHandler(): void { + this.deviceLib.on( + DEVICE_LOG_ENTRY_EVENT_NAME, + (response: IOSDeviceLib.IDeviceLogEntryData) => { + this.emit(DEVICE_LOG_ENTRY_EVENT_NAME, response); + }, + ); + } + @cache() private attacheDeviceLogDataHandler(): void { this.deviceLib.on( diff --git a/lib/common/mobile/ios/device/ios-device.ts b/lib/common/mobile/ios/device/ios-device.ts index 90dd12e7bc..7aef9a2df4 100644 --- a/lib/common/mobile/ios/device/ios-device.ts +++ b/lib/common/mobile/ios/device/ios-device.ts @@ -17,6 +17,7 @@ export class IOSDevice extends IOSDeviceBase { public fileSystem: Mobile.IDeviceFileSystem; public deviceInfo: Mobile.IDeviceInfo; private _deviceLogHandler: (...args: any[]) => void; + private _deviceLogEntryHandler: (...args: any[]) => void; constructor( private deviceActionInfo: IOSDeviceLib.IDeviceActionInfo, @@ -30,16 +31,16 @@ export class IOSDevice extends IOSDeviceBase { private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $iOSDeviceProductNameMapper: Mobile.IiOSDeviceProductNameMapper, private $iosDeviceOperations: IIOSDeviceOperations, - private $mobileHelper: Mobile.IMobileHelper + private $mobileHelper: Mobile.IMobileHelper, ) { super(); this.applicationManager = this.$injector.resolve( applicationManagerPath.IOSApplicationManager, - { device: this, devicePointer: this.deviceActionInfo } + { device: this, devicePointer: this.deviceActionInfo }, ); this.fileSystem = this.$injector.resolve( fileSystemPath.IOSDeviceFileSystem, - { device: this, devicePointer: this.deviceActionInfo } + { device: this, devicePointer: this.deviceActionInfo }, ); const productType = deviceActionInfo.productType; const isTablet = this.$mobileHelper.isiOSTablet(productType); @@ -58,7 +59,7 @@ export class IOSDevice extends IOSDeviceBase { isTablet: isTablet, displayName: this.$iOSDeviceProductNameMapper.resolveProductName( - deviceActionInfo.deviceName + deviceActionInfo.deviceName, ) || deviceActionInfo.deviceName, model: this.$iOSDeviceProductNameMapper.resolveProductName(productType), version: deviceActionInfo.productVersion, @@ -81,28 +82,63 @@ export class IOSDevice extends IOSDeviceBase { public get isOnlyWiFiConnected(): boolean { const result = this.deviceInfo.connectionTypes.every( - (connectionType) => connectionType === constants.DeviceConnectionType.Wifi + (connectionType) => + connectionType === constants.DeviceConnectionType.Wifi, ); return result; } @cache() public async openDeviceLogStream(): Promise { - if (this.deviceInfo.status !== commonConstants.UNREACHABLE_STATUS) { - this._deviceLogHandler = this.actionOnDeviceLog.bind(this); - this.$iosDeviceOperations.on( - commonConstants.DEVICE_LOG_EVENT_NAME, - this._deviceLogHandler - ); - this.$iosDeviceOperations.startDeviceLog(this.deviceInfo.identifier); + if (this.deviceInfo.status === commonConstants.UNREACHABLE_STATUS) { + return; + } + + // Structured logs need the app's executable name to select its entries; + // without one (e.g. `ns device log`) the whole device log is shown. + const processName = this.$deviceLogProvider.getProjectNameForDevice( + this.deviceInfo.identifier, + ); + if (processName && this.startStructuredDeviceLog(processName)) { + return; + } + + this.startSyslogDeviceLog(); + } + + private startStructuredDeviceLog(processName: string): boolean { + this._deviceLogEntryHandler = this.actionOnDeviceLogEntry.bind(this); + this.$iosDeviceOperations.on( + commonConstants.DEVICE_LOG_ENTRY_EVENT_NAME, + this._deviceLogEntryHandler, + ); + if ( + this.$iosDeviceOperations.startDeviceLogStream( + this.deviceInfo.identifier, + processName, + ) + ) { + return true; } + + this.removeDeviceLogEntryHandler(); + return false; + } + + private startSyslogDeviceLog(): void { + this._deviceLogHandler = this.actionOnDeviceLog.bind(this); + this.$iosDeviceOperations.on( + commonConstants.DEVICE_LOG_EVENT_NAME, + this._deviceLogHandler, + ); + this.$iosDeviceOperations.startDeviceLog(this.deviceInfo.identifier); } protected async getDebugSocketCore(appId: string): Promise { await this.$iOSSocketRequestExecutor.executeAttachRequest( this, constants.AWAIT_NOTIFICATION_TIMEOUT_SECONDS, - appId + appId, ); const port = await super.getDebuggerPort(appId); const deviceId = this.deviceInfo.identifier; @@ -113,7 +149,7 @@ export class IOSDevice extends IOSDeviceBase { await this.$iosDeviceOperations.connectToPort([ { deviceId: deviceId, port: port }, ]) - )[deviceId] + )[deviceId], ); const _socket = new net.Socket(); _socket.connect(deviceResponse.port, deviceResponse.host); @@ -128,8 +164,42 @@ export class IOSDevice extends IOSDeviceBase { this.$deviceLogProvider.logData( response.message, this.$devicePlatformsConstants.iOS, - this.deviceInfo.identifier + this.deviceInfo.identifier, + ); + } + } + + private actionOnDeviceLogEntry( + response: IOSDeviceLib.IDeviceLogEntryData, + ): void { + if (response.deviceId !== this.deviceInfo.identifier) { + return; + } + + if (response.error) { + this.$logger.trace( + `Structured device logs are not available for ${this.deviceInfo.identifier}, falling back to the syslog relay.`, + response.error, + ); + this.removeDeviceLogEntryHandler(); + this.startSyslogDeviceLog(); + return; + } + + this.$deviceLogProvider.logEntry( + response.entry, + this.$devicePlatformsConstants.iOS, + this.deviceInfo.identifier, + ); + } + + private removeDeviceLogEntryHandler(): void { + if (this._deviceLogEntryHandler) { + this.$iosDeviceOperations.removeListener( + commonConstants.DEVICE_LOG_ENTRY_EVENT_NAME, + this._deviceLogEntryHandler, ); + this._deviceLogEntryHandler = null; } } @@ -137,9 +207,10 @@ export class IOSDevice extends IOSDeviceBase { if (this._deviceLogHandler) { this.$iosDeviceOperations.removeListener( commonConstants.DEVICE_LOG_EVENT_NAME, - this._deviceLogHandler + this._deviceLogHandler, ); } + this.removeDeviceLogEntryHandler(); } private getActiveArchitecture(productType: string): string { diff --git a/lib/common/mobile/ios/ios-log-entry.ts b/lib/common/mobile/ios/ios-log-entry.ts new file mode 100644 index 0000000000..146735d82d --- /dev/null +++ b/lib/common/mobile/ios/ios-log-entry.ts @@ -0,0 +1,182 @@ +import * as path from "path"; + +export interface IIOSLogEntryFilterOptions { + /** + * Name of the application's executable. Entries are matched by name rather + * than pid, so the logs follow the app when it is relaunched. + */ + processName: string; + /** + * Include everything the app's process logs - its own native code and the + * system frameworks it uses - like Xcode's console does. + */ + includeNative?: boolean; + /** + * Include debug level entries as well. + */ + includeDebug?: boolean; +} + +/** + * Which of the app's logs to show, from the CLI's --log level: JS output by + * default, everything but debug entries (like Xcode) with "debug", and + * everything with "trace" or "all". A full device log level shows everything. + */ +export function getIOSLogScope( + cliLogLevel: string, + fullDeviceLog: boolean, +): Pick { + const level = (cliLogLevel || "").toUpperCase(); + const includeDebug = fullDeviceLog || level === "TRACE" || level === "ALL"; + return { + includeNative: includeDebug || level === "DEBUG", + includeDebug, + }; +} + +export function isIOSRuntimeLogEntry(entry: Mobile.IiOSLogEntry): boolean { + return path.basename(entry.image || "") === "NativeScript"; +} + +// The runtime forwards the app's stdout and stderr under this subsystem, when +// the CLI asks for it through the forwardStdio key of the app's package.json. +const STDIO_SUBSYSTEM = "org.nativescript.stdio"; + +// Logged by the runtime itself on every launch, not by the app. +const RUNTIME_NOISE = ["NativeScript loaded bundle"]; + +/** + * Returns the text to print for a structured iOS log entry, or null when the + * entry should not be shown. + * + * JS console output and runtime errors are the entries logged by the + * NativeScript framework; everything else in the app's process is native and + * printed prefixed with the binary that logged it. + */ +export function getIOSLogEntryText( + entry: Mobile.IiOSLogEntry, + options: IIOSLogEntryFilterOptions, +): string | null { + if (!entry || path.basename(entry.process || "") !== options.processName) { + return null; + } + + if (entry.subsystem === STDIO_SUBSYSTEM) { + return options.includeNative ? `${entry.category}: ${entry.message}` : null; + } + + const imageName = path.basename(entry.image || ""); + if (isIOSRuntimeLogEntry(entry)) { + return RUNTIME_NOISE.some((noise) => entry.message.includes(noise)) + ? null + : entry.message; + } + + if ( + !options.includeNative || + (entry.level === "debug" && !options.includeDebug) + ) { + return null; + } + + return `${imageName}: ${entry.message}`; +} + +/** + * The newest log format the CLI understands, written to the logFormat key of + * the app's package.json. The runtime uses the lower of this and its own. + */ +export const IOS_LOG_FORMAT = 1; + +// Format 1: each piece of a message the runtime had to split is logged as +// "[ns /] ". The id is unique per message in the +// process, and the pieces are consecutive slices of the message. A piece of +// nothing but whitespace arrives as the bare header: the unified log strips +// whitespace from the end of every entry. +export const IOS_LOG_PIECE_HEADER = /^\[ns ([0-9a-f]+) (\d+)\/(\d+)\](?: |$)/; + +// Where a piece that never arrived belongs; os_log marks truncated text alike. +const LOST_PIECE = "<…lost…>"; + +// Long enough to ride out a backlog of pieces, while a message whose pieces +// never all arrive, because os_log dropped some or the app crashed, still gets +// printed. +const PIECE_WAIT_MS = 500; + +interface IPendingLogMessage { + entry: Mobile.IiOSLogEntry; + pieces: string[]; + received: number; + timer: NodeJS.Timeout; +} + +/** + * Reassembles messages the runtime split to get them through os_log. + */ +export class IOSLogChunkStitcher { + private pending = new Map(); + + /** + * @param source Identifies the logging process, whose message ids are unique. + * @param emit Called with each complete entry, in place of its pieces. + */ + public add( + source: string, + entry: Mobile.IiOSLogEntry, + emit: (entry: Mobile.IiOSLogEntry) => void, + ): void { + const header = (entry.message || "").match(IOS_LOG_PIECE_HEADER); + const index = header ? Number(header[2]) - 1 : -1; + const count = header ? Number(header[3]) : 0; + if (!header || index < 0 || index >= count) { + emit(entry); + return; + } + + const key = `${source}:${header[1]}`; + let message = this.pending.get(key); + if (!message || message.pieces.length !== count) { + message = { + entry, + pieces: new Array(count), + received: 0, + timer: null, + }; + this.pending.set(key, message); + } + if (message.pieces[index] === undefined) { + message.pieces[index] = entry.message.slice(header[0].length); + message.received++; + } + if (index === 0) { + message.entry = entry; + } + + clearTimeout(message.timer); + const finish = () => { + this.pending.delete(key); + emit({ ...message.entry, message: joinPieces(message) }); + }; + if (message.received === count) { + finish(); + return; + } + + message.timer = setTimeout(finish, PIECE_WAIT_MS); + message.timer.unref(); + } +} + +function joinPieces(message: IPendingLogMessage): string { + let text = ""; + for (let i = 0; i < message.pieces.length; i++) { + if (message.pieces[i] === undefined) { + if (!text.endsWith(LOST_PIECE)) { + text += LOST_PIECE; + } + continue; + } + text += message.pieces[i]; + } + return text; +} diff --git a/lib/common/mobile/ios/ios-log-filter.ts b/lib/common/mobile/ios/ios-log-filter.ts deleted file mode 100644 index e5f1188659..0000000000 --- a/lib/common/mobile/ios/ios-log-filter.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { injector } from "../../yok"; - -export class IOSLogFilter implements Mobile.IPlatformLogFilter { - protected infoFilterRegex = /^.*?(AppBuilder|Cordova|NativeScript).*?(:.*?|:.*?|:.*?)$/im; - - constructor(private $loggingLevels: Mobile.ILoggingLevels) {} - - public filterData( - data: string, - loggingOptions: Mobile.IDeviceLogOptions = {} - ): string { - const specifiedLogLevel = (loggingOptions.logLevel || "").toUpperCase(); - const pid = loggingOptions && loggingOptions.applicationPid; - - if (specifiedLogLevel === this.$loggingLevels.info && data) { - if (pid) { - return data.indexOf(`[${pid}]`) !== -1 ? data.trim() : null; - } - - const matchingInfoMessage = data.match(this.infoFilterRegex); - return matchingInfoMessage ? matchingInfoMessage[2] : null; - } - - return data; - } -} - -injector.register("iOSLogFilter", IOSLogFilter); diff --git a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts index a13dd2055e..cb1e1e48f0 100644 --- a/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts +++ b/lib/common/mobile/ios/simulator/ios-simulator-log-provider.ts @@ -1,4 +1,5 @@ import { ChildProcess } from "child_process"; +import * as readline from "readline"; import { EventEmitter } from "events"; import * as _ from "lodash"; import { @@ -7,10 +8,13 @@ import { IDisposable, } from "../../../declarations"; import { injector } from "../../../yok"; +import { IChildProcess } from "../../../declarations"; +import { getIOSLogScope } from "../ios-log-entry"; export class IOSSimulatorLogProvider extends EventEmitter - implements Mobile.IiOSSimulatorLogProvider, IDisposable, IShouldDispose { + implements Mobile.IiOSSimulatorLogProvider, IDisposable, IShouldDispose +{ public shouldDispose: boolean; private simulatorsLoggingEnabled: IDictionary = {}; private simulatorsLogProcess: IDictionary = {}; @@ -19,7 +23,8 @@ export class IOSSimulatorLogProvider private $iOSSimResolver: Mobile.IiOSSimResolver, private $logger: ILogger, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, - private $deviceLogProvider: Mobile.IDeviceLogProvider + private $deviceLogProvider: Mobile.IDeviceLogProvider, + private $childProcess: IChildProcess, ) { super(); this.shouldDispose = true; @@ -31,20 +36,26 @@ export class IOSSimulatorLogProvider public async startLogProcess( deviceId: string, - options?: Mobile.IiOSLogStreamOptions + options?: Mobile.IiOSLogStreamOptions, ): Promise { if (!this.simulatorsLoggingEnabled[deviceId]) { - const deviceLogChildProcess: ChildProcess = await this.$iOSSimResolver.iOSSim.getDeviceLogProcess( - deviceId, - options ? options.predicate : null - ); + // Structured logs need the app's executable name to select its + // entries; without one the predicate decides what is shown. + const processName = + this.$deviceLogProvider.getProjectNameForDevice(deviceId); + const deviceLogChildProcess: ChildProcess = processName + ? this.startStructuredLogProcess(deviceId, processName) + : await this.$iOSSimResolver.iOSSim.getDeviceLogProcess( + deviceId, + options ? options.predicate : null, + ); const action = (data: Buffer | string) => { const message = data.toString(); this.$deviceLogProvider.logData( message, this.$devicePlatformsConstants.iOS, - deviceId + deviceId, ); }; @@ -55,18 +66,24 @@ export class IOSSimulatorLogProvider deviceLogChildProcess.once("error", (err) => { this.$logger.trace( - `Error is thrown for device with identifier ${deviceId}. More info: ${err.message}.` + `Error is thrown for device with identifier ${deviceId}. More info: ${err.message}.`, ); this.simulatorsLoggingEnabled[deviceId] = false; }); } - if (deviceLogChildProcess.stdout) { - deviceLogChildProcess.stdout.on("data", action.bind(this)); - } + if (processName) { + readline + .createInterface({ input: deviceLogChildProcess.stdout }) + .on("line", (line) => this.logStructuredLine(deviceId, line)); + } else { + if (deviceLogChildProcess.stdout) { + deviceLogChildProcess.stdout.on("data", action.bind(this)); + } - if (deviceLogChildProcess.stderr) { - deviceLogChildProcess.stderr.on("data", action.bind(this)); + if (deviceLogChildProcess.stderr) { + deviceLogChildProcess.stderr.on("data", action.bind(this)); + } } this.simulatorsLoggingEnabled[deviceId] = true; @@ -74,6 +91,66 @@ export class IOSSimulatorLogProvider } } + private startStructuredLogProcess( + deviceId: string, + processName: string, + ): ChildProcess { + // Matched by name rather than pid, so the logs follow the app when it is + // relaunched. log stream leaves out info and debug entries by default. + const scope = getIOSLogScope(this.$logger.getLevel(), false); + const args = [ + "simctl", + "spawn", + deviceId, + "log", + "stream", + "--style", + "ndjson", + "--predicate", + `process == ${JSON.stringify(processName)}`, + ]; + if (scope.includeNative) { + args.push("--level", scope.includeDebug ? "debug" : "info"); + } + + return this.$childProcess.spawn("xcrun", args); + } + + private logStructuredLine(deviceId: string, line: string): void { + // log stream prints a non-JSON "Filtering the log data..." header first. + if (!line.startsWith("{")) { + return; + } + + let event: any; + try { + event = JSON.parse(line); + } catch (err) { + this.$logger.trace(`Unable to parse simulator log entry: ${line}`); + return; + } + + // Activities and signposts are streamed too, but are not log messages. + if (event.eventType && event.eventType !== "logEvent") { + return; + } + + this.$deviceLogProvider.logEntry( + { + pid: event.processID, + thread: event.threadID, + process: event.processImagePath, + image: event.senderImagePath, + level: (event.messageType || "").toLowerCase(), + message: event.eventMessage, + subsystem: event.subsystem, + category: event.category, + }, + this.$devicePlatformsConstants.iOS, + deviceId, + ); + } + public dispose(signal?: any) { if (this.shouldDispose) { _.each( @@ -82,7 +159,7 @@ export class IOSSimulatorLogProvider if (logProcess) { logProcess.kill(signal); } - } + }, ); } } diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 15bf11787f..ed6a65950e 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -415,13 +415,23 @@ export class DevicesService deviceInitOpts?: Mobile.IDeviceLookingOptions, ): Promise { const options = this.getDeviceLookingOptions(deviceInitOpts); - - for (const deviceDiscovery of this._allDeviceDiscoveries) { + const startLooking = async (deviceDiscovery: Mobile.IDeviceDiscovery) => { try { await deviceDiscovery.startLookingForDevices(options); } catch (err) { this.$logger.trace("Error while checking for devices.", err); } + }; + + // Concurrently, so a requested simulator ends the wait for physical + // devices. Otherwise in order, as device indexes follow discovery order. + if (options.isRequestedDeviceFound) { + await Promise.all(this._allDeviceDiscoveries.map(startLooking)); + return; + } + + for (const deviceDiscovery of this._allDeviceDiscoveries) { + await startLooking(deviceDiscovery); } } @@ -516,6 +526,18 @@ export class DevicesService * The identifier is expected to be the same as the running device declares it (emulator-5554 for android or GUID for ios). * @param identifier running emulator or device identifier */ + private findDevice(deviceOption: string): Mobile.IDevice { + return _.find( + this.getDeviceInstances(), + (d) => + (d.deviceInfo.identifier && d.deviceInfo.identifier === deviceOption) || + (d.deviceInfo.displayName && + d.deviceInfo.displayName === deviceOption) || + (d.deviceInfo.imageIdentifier && + d.deviceInfo.imageIdentifier === deviceOption), + ); + } + public getDeviceByIdentifier(identifier: string): Mobile.IDevice { const searchedDevice = _.find( this.getDeviceInstances(), @@ -581,20 +603,7 @@ export class DevicesService * @param deviceOption parameter passed by the user to --device flag. Can be name, identifier or imageIdentifier. */ public async getDevice(deviceOption: string): Promise { - let device: Mobile.IDevice = null; - - if (!device) { - device = _.find( - this.getDeviceInstances(), - (d) => - (d.deviceInfo.identifier && - d.deviceInfo.identifier === deviceOption) || - (d.deviceInfo.displayName && - d.deviceInfo.displayName === deviceOption) || - (d.deviceInfo.imageIdentifier && - d.deviceInfo.imageIdentifier === deviceOption), - ); - } + let device = this.findDevice(deviceOption); if (!device && helpers.isNumberWithoutExponent(deviceOption)) { device = this.getDeviceByIndex(parseInt(deviceOption, 10)); @@ -907,6 +916,13 @@ export class DevicesService } deviceInitOpts = deviceInitOpts || {}; + const deviceOption = deviceInitOpts.deviceId; + if (deviceOption && !helpers.isNumberWithoutExponent(deviceOption)) { + deviceInitOpts = { + ...deviceInitOpts, + isRequestedDeviceFound: () => !!this.findDevice(deviceOption), + }; + } this._data = deviceInitOpts; if (!deviceInitOpts.skipEmulatorStart) { @@ -915,13 +931,13 @@ export class DevicesService } const platform = deviceInitOpts.platform; - const deviceOption = deviceInitOpts.deviceId; const deviceLookingOptions: Mobile.IDeviceLookingOptions = { emulator: deviceInitOpts.emulator, platform: deviceInitOpts.platform, shouldReturnImmediateResult: deviceInitOpts.shouldReturnImmediateResult, detectionInterval: deviceInitOpts.detectionInterval, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; if (platform && deviceOption) { @@ -1198,6 +1214,7 @@ export class DevicesService shouldReturnImmediateResult: !!shouldReturnImmediateResult, emulator: !!emulator, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; } diff --git a/lib/common/services/net-service.ts b/lib/common/services/net-service.ts index d519265ab6..2c08ee218c 100644 --- a/lib/common/services/net-service.ts +++ b/lib/common/services/net-service.ts @@ -48,35 +48,32 @@ export class Net implements INet { } public async isPortAvailable(port: number): Promise { - return new Promise((resolve, reject) => { - let isResolved = false; - const server = net.createServer(); + // A listener on one address does not stop a bind on another (e.g. the + // wildcard next to the loopback), so each of them has to be free. + for (const host of ["127.0.0.1", "::1", "0.0.0.0", "::"]) { + if (!(await this.canListen(port, host))) { + return false; + } + } - server.on("error", (err: Error) => { - if (!isResolved) { - isResolved = true; - resolve(false); - } - }); + return true; + } - server.once("close", () => { - if (!isResolved) { - // "close" will be emitted right after "error" - isResolved = true; - resolve(true); - } - }); + private canListen(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = net.createServer(); - server.on("listening", (err: Error) => { - if (err && !isResolved) { - isResolved = true; - resolve(true); - } + server.once("error", (err: NodeJS.ErrnoException) => { + // The host has no such address (e.g. IPv6 is disabled), so nothing + // can be listening on it either. + resolve(err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT"); + }); - server.close(); + server.once("listening", () => { + server.close(() => resolve(true)); }); - server.listen(port, "localhost"); + server.listen(port, host); }); } diff --git a/lib/common/test/unit-tests/ios-log-filter.ts b/lib/common/test/unit-tests/ios-log-filter.ts deleted file mode 100644 index ed07a29e11..0000000000 --- a/lib/common/test/unit-tests/ios-log-filter.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { IOSLogFilter } from "../../mobile/ios/ios-log-filter"; -import { LoggingLevels } from "../../mobile/logging-levels"; -import { Yok } from "../../yok"; -import * as assert from "assert"; -import * as _ from "lodash"; - -const iosTestData = [ - { - input: - "Dec 29 08:46:04 Dragons-iPhone iaptransportd[65] : CIapPortAppleIDBus: Auth timer timeout completed on pAIDBPort:0x135d09410, portID:01 downstream port", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:46:06 Dragons-iPhone kernel[0] : AppleARMPMUCharger: AppleUSBCableDetect 1", - output: null, - pid13309Output: null, - }, - { - input: - 'Dec 29 08:47:24 Dragons-iPhone bird[131] : unable to determine evictable space: Error Domain=LibrarianErrorDomain Code=10 "The operation couldn’t be completed. (LibrarianErrorDomain error 10 - Unable to configure the collection.)" UserInfo=0x137528190 {NSDescription=Unable to configure the collection.}', - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:47:43 Dragons-iPhone syslog_relay[179] : syslog_relay found the ASL prompt. Starting...", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone com.apple.xpc.launchd[1] (com.apple.WebKit.Networking.08B3A589-3D68-492A-BA8D-A812EC55FDEB[13306]) : Service exited with abnormal code: 1", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone ReportCrash[13308] : Saved report to /var/mobile/Library/Logs/CrashReporter/Cordova370_2015-12-29-084847_Dragons-iPhone.ips", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:48:47 Dragons-iPhone com.apple.WebKit.Networking[13306] : Faild to obtain sandbox extension for path=/private/var/mobile/Containers/Data/Application/047BB8F2-B8C8-405F-A820-8719EE207E6F/Library/Caches/com.telerik.BlankJS. Errno:1", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Apache Cordova native platform version 3.7.0 is starting.", - output: - ": Apache Cordova native platform version 3.7.0 is starting.", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Apache Cordova native platform version 3.7.0 is starting.", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Multi-tasking -> Device: YES, App: YES", - output: ": Multi-tasking -> Device: YES, App: YES", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Multi-tasking -> Device: YES, App: YES", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Unlimited access to network resources", - output: ": Unlimited access to network resources", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Unlimited access to network resources", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - output: - ": Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : Finished load of: file:///var/mobile/Containers/Data/Application/0746156D-3C83-402E-8B4E-2B3063F42F76/Documents/index.html", - }, - { - input: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : ---------------------------------- LOG FROM MY APP", - output: ": ---------------------------------- LOG FROM MY APP", - pid13309Output: - "Dec 29 08:49:06 Dragons-iPhone Cordova370[13309] : ---------------------------------- LOG FROM MY APP", - }, - { - input: - "Dec 29 08:50:31 Dragons-iPhone NativeScript143[13314] : assertion failed: 12F70: libxpc.dylib + 71768 [B870B51D-AA85-3686-A7D9-ACD48C5FE153]: 0x7d", - output: - ": assertion failed: 12F70: libxpc.dylib + 71768 [B870B51D-AA85-3686-A7D9-ACD48C5FE153]: 0x7d", - pid13309Output: null, - }, - { - input: "Dec 29 08:50:31 Dragons-iPhone Unknown[13314] :", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:50:31 Dragons-iPhone locationd[57] : Gesture EnabledForTopCLient: 0, EnabledInDaemonSettings: 0", - output: null, - pid13309Output: null, - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41\n", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309] : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309]: : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41\n", - output: - ": file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - pid13309Output: - "Dec 29 08:55:24 Dragons-iPhone NativeScript143[13309]: : file:///app/main-view-model.js:11:14: CONSOLE LOG COUNTER: 41", - }, - { - input: - "Oct 4 08:53:46 bd-airtestmac com.apple.CoreSimulator.SimDevice.3616FC55-9CAB-47D4-8C58-5E5F0BE99C8E.launchd_sim[30337] (UIKitApplication:org.nativescript.ap1[0x76c5][13309]): Service exited due to signal: Terminated: 15", - output: null, - pid13309Output: - "Oct 4 08:53:46 bd-airtestmac com.apple.CoreSimulator.SimDevice.3616FC55-9CAB-47D4-8C58-5E5F0BE99C8E.launchd_sim[30337] (UIKitApplication:org.nativescript.ap1[0x76c5][13309]): Service exited due to signal: Terminated: 15", - }, - { - input: - "Oct 4 08:52:44 bd-airtestmac assertiond[13309]: assertion failed: 15G31 13A344: assertiond + 12188 [93893311-6962-33A7-A734-E36F946D8EBA]: 0x1", - output: null, - pid13309Output: - "Oct 4 08:52:44 bd-airtestmac assertiond[13309]: assertion failed: 15G31 13A344: assertiond + 12188 [93893311-6962-33A7-A734-E36F946D8EBA]: 0x1", - }, -]; - -describe("iOSLogFilter", () => { - const assertFiltering = ( - inputData: string, - expectedOutput: string, - _logLevel?: string, - _pid?: string - ) => { - const testInjector = new Yok(); - testInjector.register("loggingLevels", LoggingLevels); - const iOSLogFilter = ( - testInjector.resolve(IOSLogFilter) - ); - const filteredData = iOSLogFilter.filterData(inputData, { - logLevel: _logLevel, - applicationPid: _pid, - projectDir: null, - }); - assert.deepStrictEqual( - filteredData, - expectedOutput, - `The actual result '${filteredData}' did NOT match expected output '${expectedOutput}'.` - ); - }; - - let logLevel = "INFO"; - const pid = "13309"; - - describe("filterData", () => { - describe("when PID is not provided", () => { - it("when log level is full returns full data", () => { - logLevel = "FULL"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel); - }); - }); - - it("when log level is INFO filters data", () => { - logLevel = "INFO"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.output, logLevel); - }); - }); - - it("when log level is not specified returns full data", () => { - logLevel = null; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input); - }); - }); - }); - - describe("when PID is provided", () => { - it("when log level is full returns full data", () => { - logLevel = "FULL"; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel, pid); - }); - }); - - it("when log level is INFO filters data", () => { - logLevel = "INFO"; - _.each(iosTestData, (testData) => { - assertFiltering( - testData.input, - testData.pid13309Output, - logLevel, - pid - ); - }); - }); - - it("when log level is not specified returns full data", () => { - logLevel = null; - _.each(iosTestData, (testData) => { - assertFiltering(testData.input, testData.input, logLevel, pid); - }); - }); - }); - }); -}); diff --git a/lib/common/test/unit-tests/mobile/device-log-provider.ts b/lib/common/test/unit-tests/mobile/device-log-provider.ts index 46f6929a90..b86fb8f855 100644 --- a/lib/common/test/unit-tests/mobile/device-log-provider.ts +++ b/lib/common/test/unit-tests/mobile/device-log-provider.ts @@ -1671,4 +1671,50 @@ at promiseReactionJob\n`, }); }); }); + describe("structured iOS log entries", () => { + const structuredDevice = "structuredDevice"; + const appDir = "/private/var/containers/Bundle/Application/1/app.app"; + + it("prints the app's JS output and drops other entries", () => { + deviceLogProvider.setProjectNameForDevice(structuredDevice, "app"); + const log = (image: string, message: string, process = `${appDir}/app`) => + deviceLogProvider.logEntry( + { process, image, level: "notice", message }, + "iOS", + structuredDevice, + ); + + log( + `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + "CONSOLE LOG: hello", + ); + log("/System/Library/Frameworks/Network.framework/Network", "nw noise"); + log("", "wlan0: noise", "/kernel"); + + assertData(logger.output, "CONSOLE LOG: hello\n"); + }); + + it("prints a split message once, when all of its pieces are in", () => { + deviceLogProvider.setProjectNameForDevice(structuredDevice, "app"); + const piece = (message: string) => + deviceLogProvider.logEntry( + { + pid: 1, + thread: 2, + process: `${appDir}/app`, + image: `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + level: "notice", + message, + }, + "iOS", + structuredDevice, + ); + + piece("[ns 3 1/2] CONSOLE LOG: first"); + assertData(logger.output, ""); + piece("[ns 3 2/2] \nsecond"); + + assertData(logger.output, "CONSOLE LOG: first\nsecond\n"); + }); + }); }); diff --git a/lib/common/test/unit-tests/mobile/devices-service.ts b/lib/common/test/unit-tests/mobile/devices-service.ts index ae319ce0b9..0f70915973 100644 --- a/lib/common/test/unit-tests/mobile/devices-service.ts +++ b/lib/common/test/unit-tests/mobile/devices-service.ts @@ -1237,6 +1237,39 @@ describe("devicesService", () => { ); }); + it("stops waiting for physical devices once the requested simulator is found", async () => { + let waitedForRequestedDevice = false; + (iOSDeviceDiscovery).startLookingForDevices = ( + options: Mobile.IDeviceLookingOptions, + ) => + new Promise((resolve) => { + const poll = setInterval(() => { + if (options.isRequestedDeviceFound?.()) { + waitedForRequestedDevice = true; + clearInterval(poll); + resolve(); + } + }, 5); + }); + (iOSSimulatorDiscovery).startLookingForDevices = async () => { + iOSSimulatorDiscovery.emit( + DeviceDiscoveryEventNames.DEVICE_FOUND, + iOSSimulator, + ); + }; + + await devicesService.initialize({ + platform: "ios", + deviceId: iOSSimulator.deviceInfo.identifier, + skipEmulatorStart: true, + }); + + assert.isTrue(waitedForRequestedDevice); + assert.deepStrictEqual(devicesService.getDeviceInstances(), [ + iOSSimulator, + ]); + }); + describe("when only deviceIdentifier is passed", () => { const assertAllMethodsResults = async (deviceId: string) => { assert.isFalse( diff --git a/lib/common/test/unit-tests/mobile/ios-device-operations.ts b/lib/common/test/unit-tests/mobile/ios-device-operations.ts new file mode 100644 index 0000000000..1e2482d92b --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-device-operations.ts @@ -0,0 +1,39 @@ +import { assert } from "chai"; +import { isDeviceDiscoveryDone } from "../../../mobile/ios/device/ios-device-operations"; + +describe("isDeviceDiscoveryDone", () => { + const options = (extra: object = {}) => { ...extra }; + + it("ends as soon as the requested device is found", () => { + const isRequestedDeviceFound = () => true; + assert.isTrue( + isDeviceDiscoveryDone(false, 100, options({ isRequestedDeviceFound })), + ); + }); + + it("keeps waiting for the requested device when another one shows up", () => { + const isRequestedDeviceFound = () => false; + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ isRequestedDeviceFound })), + ); + }); + + it("gives other devices time to arrive when none was requested", () => { + assert.isFalse(isDeviceDiscoveryDone(true, 100, options())); + assert.isTrue(isDeviceDiscoveryDone(true, 2000, options())); + }); + + it("keeps waiting while no device was found", () => { + assert.isFalse(isDeviceDiscoveryDone(false, 5000, options())); + }); + + it("doesn't end early on a full discovery", () => { + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ fullDiscovery: true })), + ); + }); + + it("works without options", () => { + assert.isTrue(isDeviceDiscoveryDone(true, 2000)); + }); +}); diff --git a/lib/common/test/unit-tests/mobile/ios-log-entry.ts b/lib/common/test/unit-tests/mobile/ios-log-entry.ts new file mode 100644 index 0000000000..3ed2425981 --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-log-entry.ts @@ -0,0 +1,274 @@ +import { assert } from "chai"; +import * as sinon from "sinon"; +import { + getIOSLogEntryText, + getIOSLogScope, + IOSLogChunkStitcher, +} from "../../../mobile/ios/ios-log-entry"; + +const appDir = + "/private/var/containers/Bundle/Application/CFC7170F-ACC6-429C-942C-FF90EDC34D47/app.app"; + +const entry = (image: string, message: string, process = `${appDir}/app`) => ({ + process, + image, + level: "notice", + message, +}); + +const jsLog = entry( + `${appDir}/Frameworks/NativeScript.framework/NativeScript`, + 'CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]', +); +const appFrameworkLog = entry( + `${appDir}/Frameworks/LightLib.framework/LightLib`, + "universe 1 updated", +); +const systemFrameworkLog = entry( + "/System/Library/Frameworks/Network.framework/Network", + "nw_connection_report_state_with_handler_on_nw_queue [C2] reporting state cancelled", +); +const kernelLog = entry( + "", + "wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1", + "/kernel", +); + +describe("getIOSLogEntryText", () => { + const debugLog = { ...systemFrameworkLog, level: "debug" }; + + it("returns JS console output of the app", () => { + assert.equal( + getIOSLogEntryText(jsLog, { processName: "app" }), + jsLog.message, + ); + }); + + it("drops entries of other processes", () => { + assert.isNull(getIOSLogEntryText(kernelLog, { processName: "app" })); + assert.isNull( + getIOSLogEntryText(kernelLog, { + processName: "app", + includeNative: true, + includeDebug: true, + }), + ); + assert.isNull(getIOSLogEntryText(jsLog, { processName: "otherApp" })); + }); + + it("drops native entries unless they are requested", () => { + assert.isNull(getIOSLogEntryText(appFrameworkLog, { processName: "app" })); + assert.isNull( + getIOSLogEntryText(systemFrameworkLog, { processName: "app" }), + ); + }); + + it("includes everything the app's process logs, prefixed with the binary, when native logs are requested", () => { + const options = { processName: "app", includeNative: true }; + + assert.equal( + getIOSLogEntryText(appFrameworkLog, options), + "LightLib: universe 1 updated", + ); + assert.equal( + getIOSLogEntryText(systemFrameworkLog, options), + `Network: ${systemFrameworkLog.message}`, + ); + assert.isNull(getIOSLogEntryText(debugLog, options)); + }); + + it("includes debug entries only when requested", () => { + assert.equal( + getIOSLogEntryText(debugLog, { + processName: "app", + includeNative: true, + includeDebug: true, + }), + `Network: ${debugLog.message}`, + ); + }); + + it("drops the runtime's own startup message", () => { + assert.isNull( + getIOSLogEntryText( + { ...jsLog, message: "NativeScript loaded bundle file:///app/" }, + { processName: "app" }, + ), + ); + }); +}); + +describe("getIOSLogEntryText for forwarded stdio", () => { + const entry = { + process: "/var/containers/Bundle/Application/X/app.app/app", + image: "/private/Frameworks/NativeScript.framework/NativeScript", + level: "default", + message: "printed", + subsystem: "org.nativescript.stdio", + category: "stdout", + }; + + it("is hidden by default, unlike the runtime's console output", () => { + assert.isNull(getIOSLogEntryText(entry, { processName: "app" })); + }); + + it("is labeled with its stream along with native output", () => { + assert.equal( + getIOSLogEntryText( + { ...entry, category: "stderr" }, + { processName: "app", includeNative: true }, + ), + "stderr: printed", + ); + }); + + it("is labeled once when a long line was split", () => { + const stitcher = new IOSLogChunkStitcher(); + const printed: string[] = []; + for (const message of ["[ns 4 1/2] one", "[ns 4 2/2] two"]) { + stitcher.add("process", { ...entry, message }, (complete) => + printed.push( + getIOSLogEntryText(complete, { + processName: "app", + includeNative: true, + }), + ), + ); + } + assert.deepEqual(printed, ["stdout: one two"]); + }); +}); + +describe("getIOSLogScope", () => { + it("maps the CLI log level to the app logs that are shown", () => { + assert.deepEqual(getIOSLogScope("INFO", false), { + includeNative: false, + includeDebug: false, + }); + assert.deepEqual(getIOSLogScope("DEBUG", false), { + includeNative: true, + includeDebug: false, + }); + assert.deepEqual(getIOSLogScope("TRACE", false), { + includeNative: true, + includeDebug: true, + }); + assert.deepEqual(getIOSLogScope("ALL", false), { + includeNative: true, + includeDebug: true, + }); + }); + + it("shows everything for a full device log", () => { + assert.deepEqual(getIOSLogScope("INFO", true), { + includeNative: true, + includeDebug: true, + }); + }); +}); + +describe("IOSLogChunkStitcher", () => { + let stitcher: IOSLogChunkStitcher; + let messages: string[]; + let clock: sinon.SinonFakeTimers; + const add = (message: string, source = "process") => + stitcher.add( + source, + { process: "app", image: "NativeScript", level: "default", message }, + (entry) => messages.push(entry.message), + ); + + beforeEach(() => { + clock = sinon.useFakeTimers(); + stitcher = new IOSLogChunkStitcher(); + messages = []; + }); + + afterEach(() => { + clock.restore(); + }); + + it("passes through messages that were not split", () => { + add("CONSOLE LOG: hello"); + assert.deepEqual(messages, ["CONSOLE LOG: hello"]); + }); + + it("joins the pieces of a message as they are", () => { + add('[ns 1f 1/3] CONSOLE LOG: {"a":'); + add('[ns 1f 2/3] 1,"b":'); + assert.isEmpty(messages); + add("[ns 1f 3/3] 2}"); + assert.deepEqual(messages, ['CONSOLE LOG: {"a":1,"b":2}']); + }); + + it("keeps the whitespace a piece starts with", () => { + add("[ns 2 1/3] CONSOLE ERROR: first"); + add("[ns 2 2/3] \nsecond"); + add("[ns 2 3/3] third"); + assert.deepEqual(messages, ["CONSOLE ERROR: first \nsecond third"]); + }); + + it("takes a bare header for a piece of nothing but whitespace", () => { + add("[ns 9 1/3] CONSOLE LOG: a"); + add("[ns 9 2/3]"); + add("[ns 9 3/3] b"); + assert.deepEqual(messages, ["CONSOLE LOG: a b"]); + }); + + it("keeps interleaved messages apart by id", () => { + add("[ns a 1/2] CONSOLE LOG: a1"); + add("[ns b 1/2] CONSOLE LOG: b1"); + add("CONSOLE LOG: other"); + add("[ns a 2/2] a2"); + add("[ns b 2/2] b2"); + assert.deepEqual(messages, [ + "CONSOLE LOG: other", + "CONSOLE LOG: a1a2", + "CONSOLE LOG: b1b2", + ]); + }); + + it("keeps the same id from different processes apart", () => { + add("[ns 1 1/2] CONSOLE LOG: old", "launch-1"); + add("[ns 1 1/2] CONSOLE LOG: new", "launch-2"); + add("[ns 1 2/2] !", "launch-2"); + assert.deepEqual(messages, ["CONSOLE LOG: new!"]); + }); + + it("never joins the next message onto one whose last piece was lost", () => { + add("[ns 5 1/3] CONSOLE LOG: AAAA"); + add("[ns 5 2/3] aaaa"); + add("CONSOLE ERROR: B failed"); + assert.deepEqual(messages, ["CONSOLE ERROR: B failed"]); + + clock.tick(500); + assert.deepEqual(messages, [ + "CONSOLE ERROR: B failed", + "CONSOLE LOG: AAAAaaaa<…lost…>", + ]); + }); + + it("marks where a lost piece belongs", () => { + add("[ns 6 1/4] CONSOLE LOG: one\n"); + add("[ns 6 4/4] four"); + clock.tick(500); + assert.deepEqual(messages, ["CONSOLE LOG: one\n<…lost…>four"]); + }); + + it("prints what it has when the rest never arrives", () => { + add("[ns 7 2/2] tail"); + clock.tick(499); + assert.isEmpty(messages); + clock.tick(1); + assert.deepEqual(messages, ["<…lost…>tail"]); + }); + + it("passes through text that only looks like a header", () => { + add("[ns 8 3/2] CONSOLE LOG: x"); + add("[ns zz 1/2] CONSOLE LOG: y"); + assert.deepEqual(messages, [ + "[ns 8 3/2] CONSOLE LOG: x", + "[ns zz 1/2] CONSOLE LOG: y", + ]); + }); +}); diff --git a/lib/common/test/unit-tests/services/net-service.ts b/lib/common/test/unit-tests/services/net-service.ts index 9d35773158..73751f1a00 100644 --- a/lib/common/test/unit-tests/services/net-service.ts +++ b/lib/common/test/unit-tests/services/net-service.ts @@ -1,5 +1,6 @@ import { Net } from "../../../services/net-service"; import { assert } from "chai"; +import * as net from "net"; import { Yok } from "../../../yok"; import { ErrorsStub, CommonLoggerStub } from "../stubs"; import { EOL } from "os"; @@ -25,6 +26,43 @@ describe("net", () => { return testInjector; }; + describe("isPortAvailable", () => { + const hosts = [undefined, "127.0.0.1", "::1", "0.0.0.0", "::"]; + + for (const host of hosts) { + it(`returns false for a port taken on ${host || "the default address"}`, async (context: any) => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + const listening = await new Promise((resolve) => { + server.once("error", () => resolve(false)); + const onListening = () => resolve(true); + host ? server.listen(0, host, onListening) : server.listen(0, onListening); + }); + if (!listening) { + context.skip(); + return; + } + const { port } = server.address(); + + try { + assert.isFalse(await service.isPortAvailable(port)); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + } + + it("returns true for a free port", async () => { + const service = createTestInjector("darwin").resolve(Net); + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, () => resolve())); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + + assert.isTrue(await service.isPortAvailable(port)); + }); + }); + describe("waitForPortToListen", () => { let execCalledCount = 0; beforeEach(() => { diff --git a/lib/common/test/unit-tests/stubs.ts b/lib/common/test/unit-tests/stubs.ts index 8bcb472531..83aeaf7f19 100644 --- a/lib/common/test/unit-tests/stubs.ts +++ b/lib/common/test/unit-tests/stubs.ts @@ -234,6 +234,18 @@ export class DeviceLogProviderStub }); } + logEntry( + entry: Mobile.IiOSLogEntry, + platform: string, + deviceIdentifier: string, + ): void { + this.logData(entry.message, platform, deviceIdentifier); + } + + getProjectNameForDevice(deviceIdentifier: string): string { + return this.currentDeviceProjectNames[deviceIdentifier]; + } + setLogLevel(level: string, deviceIdentifier?: string): void {} setApplicationPidForDevice(deviceIdentifier: string, pid: string): void { diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index b39a06d032..58415bc513 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -11,6 +11,7 @@ import { import { cache, performanceLog } from "../common/decorators"; import { hook } from "../common/helpers"; import { injector } from "../common/yok"; +import { IOS_LOG_FORMAT } from "../common/mobile/ios/ios-log-entry"; import { AnalyticsEventLabelDelimiter, BUNDLER_COMPILATION_COMPLETE, @@ -341,6 +342,7 @@ export class PrepareController newNativeWatchStarted = await this.startNativeWatcher( platformData, projectData, + prepareData, ); } @@ -359,6 +361,7 @@ export class PrepareController private async startNativeWatcher( platformData: IPlatformData, projectData: IProjectData, + prepareData: IPrepareData, ): Promise { if ( this.watchersData[projectData.projectDir][ @@ -388,7 +391,11 @@ export class PrepareController this.$watchIgnoreListService.removeFileFromIgnoreList(filePath); } else { this.$logger.info(`Chokidar raised event ${event} for ${filePath}.`); - await this.writeRuntimePackageJson(projectData, platformData); + await this.writeRuntimePackageJson( + projectData, + platformData, + prepareData, + ); this.emitPrepareEvent({ files: [], staleFiles: [], @@ -518,6 +525,10 @@ export class PrepareController version: installedRuntimePackageJSON.version, }; } + if (prepareData && !prepareData.release) { + packageData.logFormat = IOS_LOG_FORMAT; + packageData.forwardStdio = true; + } if (packageData.ios && packageData.ios.discardUncaughtJsExceptions) { packageData.discardUncaughtJsExceptions = packageData.ios.discardUncaughtJsExceptions; diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..08c67db5c7 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -114,6 +114,9 @@ declare global { platformData: IPlatformData, projectData: IProjectData, ): string[]; + shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean; } interface IXcodebuildCommandService { diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..53c0d2d10d 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -563,6 +563,12 @@ interface ICocoaPodsService { platformData: IPlatformData, ): Promise; + applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise; + applyPodfileFromExtensions( projectData: IProjectData, platformData: IPlatformData, diff --git a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts index 10ddd50209..832d53f9b1 100644 --- a/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts +++ b/lib/device-sockets/ios/app-debug-socket-proxy-factory.ts @@ -8,6 +8,36 @@ import { IDictionary, IErrors, INet } from "../../common/declarations"; import { injector } from "../../common/yok"; import { ITempService } from "../../definitions/temp-service"; +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +/** + * The debugger can run any code in the app, and any web page can try to open a + * WebSocket to this port. Clients that aren't browsers, like VS Code's debug + * adapter, send no origin. + */ +export function isAllowedDebuggerOrigin(origin?: string): boolean { + if (!origin) { + return true; + } + + try { + const { protocol, hostname, origin: urlOrigin } = new URL(origin); + return ( + protocol === "devtools:" || + protocol === "chrome-devtools:" || + urlOrigin === "https://chrome-devtools-frontend.appspot.com" || + ["localhost", "127.0.0.1", "[::1]"].includes(hostname) + ); + } catch { + return false; + } +} + +interface IDebugProxySession { + appDebugSocket: net.Socket; + webSocket?: ws; +} + export class AppDebugSocketProxyFactory extends EventEmitter implements IAppDebugSocketProxyFactory @@ -18,7 +48,6 @@ export class AppDebugSocketProxyFactory constructor( private $logger: ILogger, private $errors: IErrors, - private $lockService: ILockService, private $options: IOptions, private $tempService: ITempService, private $net: INet, @@ -132,7 +161,6 @@ export class AppDebugSocketProxyFactory projectName: string, projectDir: string, ): Promise { - let clientConnectionLockRelease: () => void; const cacheKey = `${device.deviceInfo.identifier}-${appId}`; const existingServer = this.deviceWebServers[cacheKey]; if (existingServer) { @@ -141,76 +169,92 @@ export class AppDebugSocketProxyFactory ); } - // NOTE: We will try to provide command line options to select ports, at least on the localhost. - const localPort = await this.$net.getAvailablePortInRange(41000); - this.$logger.info( "\nSetting up debugger proxy...\nPress Ctrl + C to terminate, or disconnect.\n", ); // NB: When the inspector frontend connects we might not have connected to the inspector backend yet. // That's why we use the verifyClient callback of the websocket server to stall the upgrade request until we connect. - // We store the socket that connects us to the device in the upgrade request object itself and later on retrieve it - // in the connection callback. - - let currentAppSocket: net.Socket = null; - let currentWebSocket: ws = null; - const server = new ws.Server({ - port: localPort, - verifyClient: async ( + // We store the session in the upgrade request object itself and later on retrieve it in the connection callback. + + // The runtime serves a single frontend, so the newest client takes over. + // Handlers of a replaced session must leave the device socket alone, as + // by then it belongs to the session that replaced it. + let currentSession: IDebugProxySession = null; + let pendingConnect: Promise = Promise.resolve(); + + const connectToApp = async (): Promise => { + const previousSession = currentSession; + currentSession = null; + if (previousSession) { + previousSession.webSocket?.close(1000, REPLACED_CLOSE_REASON); + await device.destroyDebugSocket(appId); + } + + const appDebugSocket = await device.getDebugSocket( + appId, + projectName, + projectDir, + ); + appDebugSocket.on("error", (err: Error) => { + this.$logger.trace("Error on debugger deviceSocket", err); + }); + this.$logger.info("Backend socket created."); + + currentSession = { appDebugSocket }; + return currentSession; + }; + + const servers = await this.listenOnAvailablePort(41000, { + verifyClient: ( info: any, callback: (res: boolean, code?: number, message?: string) => void, ) => { - let acceptHandshake = true; - clientConnectionLockRelease = null; - - try { - clientConnectionLockRelease = await this.$lockService.lock( - `debug-connection-${device.deviceInfo.identifier}-${appId}.lock`, - ); - - this.$logger.info("Frontend client connected."); - let appDebugSocket; - if (currentAppSocket) { - currentAppSocket.removeAllListeners(); - currentAppSocket = null; - if (currentWebSocket) { - currentWebSocket.removeAllListeners(); - currentWebSocket.close(); - currentWebSocket = null; - } - await device.destroyDebugSocket(appId); - } - appDebugSocket = await device.getDebugSocket( - appId, - projectName, - projectDir, - ); - currentAppSocket = appDebugSocket; - this.$logger.info("Backend socket created."); - info.req["__deviceSocket"] = appDebugSocket; - } catch (err) { - if (clientConnectionLockRelease) { - clientConnectionLockRelease(); - } - err.deviceIdentifier = device.deviceInfo.identifier; - this.$logger.trace(err); - this.emit(CONNECTION_ERROR_EVENT_NAME, err); - acceptHandshake = false; + if (!isAllowedDebuggerOrigin(info.origin)) { this.$logger.warn( - `Cannot connect to device socket. The error message is '${err.message}'.`, + `Rejected a debugger connection from origin '${info.origin}'.`, ); + callback(false, 403, "Origin not allowed"); + return; } - callback(acceptHandshake); + this.$logger.info("Frontend client connected."); + + // Not released on "connection": that never fires for a client that + // drops during the handshake. + pendingConnect = pendingConnect.then(async () => { + try { + info.req["__session"] = await connectToApp(); + callback(true); + } catch (err) { + err.deviceIdentifier = device.deviceInfo.identifier; + this.$logger.trace(err); + this.emit(CONNECTION_ERROR_EVENT_NAME, err); + this.$logger.warn( + `Cannot connect to device socket. The error message is '${err.message}'.`, + ); + callback(false); + } + }); }, }); + const server = servers[0]; this.deviceWebServers[cacheKey] = server; - server.on("connection", (webSocket, req) => { - currentWebSocket = webSocket; + const onConnection = (webSocket: ws, req: any) => { const encoding = "utf16le"; - const appDebugSocket: net.Socket = (req)["__deviceSocket"]; + webSocket.on("error", (err) => { + this.$logger.trace("Error on debugger websocket", err); + }); + + const session: IDebugProxySession = (req)["__session"]; + if (session !== currentSession || session.appDebugSocket.destroyed) { + webSocket.close(1000, REPLACED_CLOSE_REASON); + return; + } + + session.webSocket = webSocket; + const appDebugSocket = session.appDebugSocket; const packets = new MessageUnpackStream(); appDebugSocket.pipe(packets); @@ -230,14 +274,6 @@ export class AppDebugSocketProxyFactory } }); - webSocket.on("error", (err) => { - this.$logger.trace("Error on debugger websocket", err); - }); - - appDebugSocket.on("error", (err) => { - this.$logger.trace("Error on debugger deviceSocket", err); - }); - webSocket.on("message", (message) => { const msg = message.toString(); if (process.env.DEBUG_DEVTOOLS_SOCKETS) { @@ -253,28 +289,88 @@ export class AppDebugSocketProxyFactory }); appDebugSocket.on("close", () => { - currentAppSocket = null; this.$logger.trace("Backend socket closed!"); webSocket.close(); }); webSocket.on("close", async () => { - currentWebSocket = null; this.$logger.trace("Frontend socket closed!"); appDebugSocket.unpipe(packets); packets.destroy(); + if (currentSession !== session) { + return; + } + + currentSession = null; await device.destroyDebugSocket(appId); if (!this.$options.watch) { process.exit(0); } }); - - clientConnectionLockRelease(); + }; + for (const listeningServer of servers) { + listeningServer.on("connection", onConnection); + } + server.once("close", () => { + servers.slice(1).forEach((other) => other.close()); }); return server; } + private async listenOnAvailablePort( + startPort: number, + options: ws.ServerOptions, + ): Promise { + // The port can still get taken after the check, by another CLI for example. + let port = await this.$net.getAvailablePortInRange(startPort); + while (true) { + const servers: ws.Server[] = []; + try { + // Only reachable from this machine. Clients told to use "localhost" + // may end up on either loopback address. + servers.push(await this.listen("127.0.0.1", port, options)); + const ipv6Server = await this.listen("::1", port, options).catch( + (err: NodeJS.ErrnoException): ws.Server => { + if (err.code === "EADDRINUSE") { + throw err; + } + this.$logger.trace("Not listening on ::1", err); + return null; + }, + ); + if (ipv6Server) { + servers.push(ipv6Server); + } + return servers; + } catch (err) { + servers.forEach((server) => server.close()); + if (err.code !== "EADDRINUSE") { + throw err; + } + port = await this.$net.getAvailablePortInRange(port + 1); + } + } + } + + private listen( + host: string, + port: number, + options: ws.ServerOptions, + ): Promise { + return new Promise((resolve, reject) => { + const server = new ws.Server({ ...options, host, port }); + server.once("listening", () => { + server.removeAllListeners("error"); + server.on("error", (err: Error) => { + this.$logger.trace("Error on debugger web socket server", err); + }); + resolve(server); + }); + server.once("error", reject); + }); + } + public removeAllProxies() { let deviceId; for (deviceId in this.deviceWebServers) { diff --git a/lib/services/cocoapods-service.ts b/lib/services/cocoapods-service.ts index 17e433feae..b2b132dfa3 100644 --- a/lib/services/cocoapods-service.ts +++ b/lib/services/cocoapods-service.ts @@ -35,11 +35,11 @@ export class CocoaPodsService implements ICocoaPodsService { private $logger: ILogger, private $config: IConfiguration, private $xcconfigService: IXcconfigService, - private $xcodeSelectService: XcodeSelectService + private $xcodeSelectService: XcodeSelectService, ) { this.getCocoaPodsFromPodfile = _.memoize( this._getCocoaPodsFromPodfile, - getHash + getHash, ); } @@ -57,7 +57,7 @@ export class CocoaPodsService implements ICocoaPodsService { public async executePodInstall( projectRoot: string, - xcodeProjPath: string + xcodeProjPath: string, ): Promise { this.$logger.info("Installing pods..."); let podTool = this.$config.USE_POD_SANDBOX ? "sandbox-pod" : "pod"; @@ -79,7 +79,7 @@ export class CocoaPodsService implements ICocoaPodsService { if (!res.includes("Bad CPU type in executable")) { this.$logger.trace( - "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2" + "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2", ); args.unshift(podTool); args.unshift("-x86_64"); @@ -92,7 +92,7 @@ export class CocoaPodsService implements ICocoaPodsService { args, "close", { cwd: projectRoot, stdio: ["pipe", process.stdout, process.stdout] }, - { throwError: false } + { throwError: false }, ); if (podInstallResult.exitCode !== 0) { @@ -113,28 +113,28 @@ ${versionResolutionHint}`); public async mergePodXcconfigFile( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const podFilesRootDirName = path.join( "Pods", "Target Support Files", - `Pods-${projectData.projectName}` + `Pods-${projectData.projectName}`, ); const podFolder = path.join(platformData.projectRoot, podFilesRootDirName); if (this.$fs.exists(podFolder)) { const pluginsXcconfigFilePaths = this.$xcconfigService.getPluginsXcconfigFilePaths( - platformData.projectRoot + platformData.projectRoot, ); for (const configuration in pluginsXcconfigFilePaths) { const pluginsXcconfigFilePath = pluginsXcconfigFilePaths[configuration]; const podXcconfigFilePath = path.join( podFolder, - `Pods-${projectData.projectName}.${configuration}.xcconfig` + `Pods-${projectData.projectName}.${configuration}.xcconfig`, ); await this.$xcconfigService.mergeFiles( podXcconfigFilePath, - pluginsXcconfigFilePath + pluginsXcconfigFilePath, ); } } @@ -142,13 +142,13 @@ ${versionResolutionHint}`); public async applyPodfileFromAppResources( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const { projectRoot, normalizedPlatformName } = platformData; const mainPodfilePath = path.join( projectData.appResourcesDirectoryPath, normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); const projectPodfilePath = this.getProjectPodfilePath(projectRoot); if ( @@ -159,14 +159,14 @@ ${versionResolutionHint}`); NS_BASE_PODFILE, mainPodfilePath, projectData, - platformData + platformData, ); } } public async applyPodfileArchExclusions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const xcodeVersionData = await this.$xcodeSelectService.getXcodeVersion(); @@ -194,24 +194,90 @@ end`.trim(); "NativeScript-CLI-Architecture-Exclusions", exclusionsPodfile, projectData, - platformData + platformData, ); // clean up this.$fs.deleteFile(exclusionsPodfile); } + /** + * Applies the app's build settings that pods need too: raises pods below + * the app's deployment target (from build.xcconfig) up to it, and sets the + * given settings on every pod target. Living in the Pods project rather + * than on the xcodebuild command line, they apply the same way to CLI and + * Xcode builds. + */ + public async applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise { + const { projectRoot } = platformData; + const buildSettingsPodfile = path.join( + projectRoot, + "Podfile-build-settings", + ); + const deploymentTarget = this.$xcconfigService.readPropertyValue( + path.join( + projectData.appResourcesDirectoryPath, + platformData.normalizedPlatformName, + constants.BUILD_XCCONFIG_FILE_NAME, + ), + "IPHONEOS_DEPLOYMENT_TARGET", + ); + + const configLines = _.map( + buildSettings, + (value, key) => + ` config.build_settings[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, + ); + if (deploymentTarget) { + configLines.push( + ` current = config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"]`, + ` if current.nil? || Gem::Version.new(current) < Gem::Version.new(${JSON.stringify(deploymentTarget)})`, + ` config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = ${JSON.stringify(deploymentTarget)}`, + ` end`, + ); + } + + if (configLines.length) { + this.$fs.writeFile( + buildSettingsPodfile, + [ + "post_install do |installer|", + " installer.pods_project.targets.each do |target|", + " target.build_configurations.each do |config|", + ...configLines, + " end", + " end", + "end", + ].join(EOL), + ); + } + + // Without a temporary Podfile this removes a previously applied section. + await this.applyPodfileToProject( + "NativeScript-CLI-Build-Settings", + buildSettingsPodfile, + projectData, + platformData, + ); + + this.$fs.deleteFile(buildSettingsPodfile); + } + public async applyPodfileFromExtensions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ) { const extensionFolderPath = path.join( projectData.getAppResourcesDirectoryPath(), constants.iOSAppResourcesFolderName, - constants.NATIVE_EXTENSION_FOLDER + constants.NATIVE_EXTENSION_FOLDER, ); const projectPodfilePath = this.getProjectPodfilePath( - platformData.projectRoot + platformData.projectRoot, ); if ( @@ -235,7 +301,7 @@ end`.trim(); podfilePath: path.join( extensionFolderPath, name, - constants.PODFILE_NAME + constants.PODFILE_NAME, ), })); @@ -244,9 +310,9 @@ end`.trim(); const regExpToRemove = new RegExp( `${this.getExtensionPodfileHeader( podfilePath, - targetName + targetName, )}[\\s\\S]*?${this.getExtensionPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); @@ -270,7 +336,7 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const nativeProjectPath = platformData.projectRoot; if (!this.$fs.exists(podfilePath)) { @@ -278,7 +344,7 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); return; } @@ -288,7 +354,7 @@ end`.trim(); podfilePath, moduleName, projectData, - platformData + platformData, ); const pathToProjectPodfile = this.getProjectPodfilePath(nativeProjectPath); const projectPodfileContent = this.$fs.exists(pathToProjectPodfile) @@ -301,24 +367,24 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); let finalPodfileContent = this.$fs.exists(pathToProjectPodfile) ? this.getPodfileContentWithoutTarget( projectData, - this.$fs.readText(pathToProjectPodfile) - ) + this.$fs.readText(pathToProjectPodfile), + ) : ""; if ( podfileContent.indexOf( - CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME + CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, ) !== -1 ) { finalPodfileContent = this.addPostInstallHook( replacedFunctions, finalPodfileContent, - podfileContent + podfileContent, ); } @@ -326,7 +392,7 @@ end`.trim(); finalPodfileContent = this.$cocoaPodsPlatformManager.addPlatformSection( projectData, podfilePlatformData, - finalPodfileContent + finalPodfileContent, ); } @@ -334,7 +400,7 @@ end`.trim(); this.saveProjectPodfile( projectData, finalPodfileContent, - nativeProjectPath + nativeProjectPath, ); } } @@ -343,33 +409,33 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - projectRoot: string + projectRoot: string, ): void { if (this.$fs.exists(this.getProjectPodfilePath(projectRoot))) { let projectPodFileContent = this.$fs.readText( - this.getProjectPodfilePath(projectRoot) + this.getProjectPodfilePath(projectRoot), ); // Remove the data between #Begin Podfile and #EndPodfile const regExpToRemove = new RegExp( `${this.getPluginPodfileHeader( - podfilePath + podfilePath, )}[\\s\\S]*?${this.getPluginPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); projectPodFileContent = this.removePostInstallHook( moduleName, - projectPodFileContent + projectPodFileContent, ); projectPodFileContent = this.$cocoaPodsPlatformManager.removePlatformSection( moduleName, projectPodFileContent, - podfilePath + podfilePath, ); const defaultPodfileBeginning = this.getPodfileHeader( - projectData.projectName + projectData.projectName, ); const defaultContentWithPostInstallHook = `${defaultPodfileBeginning}${this.getPostInstallHookHeader()}end${EOL}end`; const defaultContentWithoutPostInstallHook = `${defaultPodfileBeginning}${EOL}end`; @@ -383,7 +449,7 @@ end`.trim(); } else { this.$fs.writeFile( this.getProjectPodfilePath(projectRoot), - projectPodFileContent + projectPodFileContent, ); } } @@ -391,11 +457,11 @@ end`.trim(); public getPluginPodfilePath(pluginData: IPluginData): string { const pluginPlatformsFolderPath = pluginData.pluginPlatformsFolderPath( - PlatformTypes.ios + PlatformTypes.ios, ); const pluginPodFilePath = path.join( pluginPlatformsFolderPath, - PODFILE_NAME + PODFILE_NAME, ); return pluginPodFilePath; } @@ -403,7 +469,7 @@ end`.trim(); private addPostInstallHook( replacedFunctions: IRubyFunction[], finalPodfileContent: string, - pluginPodfileContent: string + pluginPodfileContent: string, ): string { const postInstallHookStart = this.getPostInstallHookHeader(); let postInstallHookContent = ""; @@ -424,11 +490,11 @@ end`.trim(); if (index !== -1) { const regExp = new RegExp( `(${regExpEscape(postInstallHookStart)}[\\s\\S]*?)(\\bend\\b)`, - "m" + "m", ); finalPodfileContent = finalPodfileContent.replace( regExp, - `$1${postInstallHookContent.trimRight()}${EOL}$2` + `$1${postInstallHookContent.trimRight()}${EOL}$2`, ); } else { if (finalPodfileContent.length > 0) { @@ -444,13 +510,13 @@ end`.trim(); private getPodfileContentWithoutTarget( projectData: IProjectData, - projectPodfileContent: string + projectPodfileContent: string, ): string { const podFileHeader = this.getPodfileHeader(projectData.projectName); if (_.startsWith(projectPodfileContent, podFileHeader)) { projectPodfileContent = projectPodfileContent.substr( - podFileHeader.length + podFileHeader.length, ); const podFileFooter = this.getPodfileFooter(); @@ -458,7 +524,7 @@ end`.trim(); if (_.endsWith(projectPodfileContent, podFileFooter)) { projectPodfileContent = projectPodfileContent.substr( 0, - projectPodfileContent.length - podFileFooter.length + projectPodfileContent.length - podFileFooter.length, ); } } @@ -469,11 +535,11 @@ end`.trim(); private saveProjectPodfile( projectData: IProjectData, projectPodfileContent: string, - projectRoot: string + projectRoot: string, ): void { projectPodfileContent = this.getPodfileContentWithoutTarget( projectData, - projectPodfileContent + projectPodfileContent, ); const podFileHeader = this.getPodfileHeader(projectData.projectName); const podFileFooter = this.getPodfileFooter(); @@ -484,14 +550,14 @@ end`.trim(); private removePostInstallHook( moduleName: string, - projectPodFileContent: string + projectPodFileContent: string, ): string { const regExp = new RegExp( `^.*?${this.getHookBasicFuncNameForPlugin( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, - moduleName + moduleName, )}.*?$\\r?\\n`, - "gm" + "gm", ); projectPodFileContent = projectPodFileContent.replace(regExp, ""); return projectPodFileContent; @@ -499,7 +565,7 @@ end`.trim(); private getHookBasicFuncNameForPlugin( hookName: string, - pluginName: string + pluginName: string, ): string { // nativescript-hook and nativescript_hook should have different names, so replace all _ with ___ first and then replace all special symbols with _ // This will lead to a clash in case plugins are called nativescript-hook and nativescript___hook @@ -512,13 +578,13 @@ end`.trim(); private replaceHookContent( hookName: string, podfileContent: string, - pluginName: string + pluginName: string, ): { replacedContent: string; newFunctions: IRubyFunction[] } { const hookStart = `${hookName} do`; const hookDefinitionRegExp = new RegExp( `${hookStart} *(\\|(\\w+)\\|)?`, - "g" + "g", ); const newFunctions: IRubyFunction[] = []; @@ -528,11 +594,11 @@ end`.trim(); substring: string, firstGroup: string, secondGroup: string, - index: number + index: number, ): string => { const newFunctionName = `${this.getHookBasicFuncNameForPlugin( hookName, - pluginName + pluginName, )}_${newFunctions.length}`; let newDefinition = `def ${newFunctionName}`; @@ -545,7 +611,7 @@ end`.trim(); newFunctions.push(rubyFunction); return newDefinition; - } + }, ); return { replacedContent, newFunctions }; @@ -563,11 +629,11 @@ end`.trim(); private getExtensionPodfileHeader( extensionPodFilePath: string, - targetName: string + targetName: string, ): string { const targetHeader = `target "${targetName.trim()}" do`; return `${this.getPluginPodfileHeader( - extensionPodFilePath + extensionPodFilePath, )}${EOL}${targetHeader}`; } @@ -583,7 +649,7 @@ end`.trim(); pluginPodFilePath: string, pluginName: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): { podfileContent: string; replacedFunctions: IRubyFunction[]; @@ -593,11 +659,11 @@ end`.trim(); const data = this.replaceHookContent( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, pluginPodfileContent, - pluginName + pluginName, ); const cocoapodsData = this.$cocoaPodsPlatformManager.replacePlatformRow( data.replacedContent, - pluginPodFilePath + pluginPodFilePath, ); const podfilePlatformData = cocoapodsData.podfilePlatformData; let replacedContent = cocoapodsData.replacedContent; @@ -610,13 +676,13 @@ end`.trim(); replacedContent = this.overridePodsFromFile( replacedContent, projectData, - platformData + platformData, ); } return { podfileContent: `${this.getPluginPodfileHeader( - pluginPodFilePath + pluginPodFilePath, )}${EOL}${replacedContent}${EOL}${this.getPluginPodfileEnd()}`, replacedFunctions: data.newFunctions, podfilePlatformData, @@ -625,19 +691,19 @@ end`.trim(); private getMainPodFilePath( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { return path.join( projectData.appResourcesDirectoryPath, platformData.normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); } private isMainPodFile( podFilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): boolean { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -647,7 +713,7 @@ end`.trim(); private overridePodsFromFile( podfileContent: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -657,7 +723,7 @@ end`.trim(); _.forEach(pods, (pod) => { podfileContent = podfileContent.replace( new RegExp(`^[ ]*pod\\s*["']${pod}['"].*$`, "gm"), - "#$&" + "#$&", ); }); } diff --git a/lib/services/ios-debugger-port-service.ts b/lib/services/ios-debugger-port-service.ts index ca1c1c5a40..4146453219 100644 --- a/lib/services/ios-debugger-port-service.ts +++ b/lib/services/ios-debugger-port-service.ts @@ -143,8 +143,8 @@ export class IOSDebuggerPortService implements IIOSDebuggerPortService { private clearTimeout(data: IIOSDebuggerPortData): void { const storedData = this.mapDebuggerPortData[`${data.deviceId}${data.appId}`]; - if (storedData && typeof storedData.timer === "number") { - clearTimeout(storedData.timer); + if (storedData && storedData.timer) { + clearTimeout(storedData.timer); } } } diff --git a/lib/services/ios-log-filter.ts b/lib/services/ios-log-filter.ts index 04f58146dc..6b5c5d53fe 100644 --- a/lib/services/ios-log-filter.ts +++ b/lib/services/ios-log-filter.ts @@ -1,16 +1,19 @@ import { injector } from "../common/yok"; +import { IOS_LOG_PIECE_HEADER } from "../common/mobile/ios/ios-log-entry"; export class IOSLogFilter implements Mobile.IPlatformLogFilter { // Used to recognize output related to the current project - // This looks for artifacts like: AppName[22432] or AppName(SomeTextHere)[23123] - private appOutputRegex: RegExp = /([^\s\(\)]+)(?:\(([^\s]+)\))?\[[0-9]+\]/; + // This looks for artifacts like: AppName[22432], AppName(SomeTextHere)[23123] + // or kernel()[0] - a header must be recognized even without an image name, + // or the line is treated as a continuation of the previous message. + private appOutputRegex: RegExp = /([^\s\(\)]+)(?:\(([^\s]*)\))?\[[0-9]+\]/; // Used to trim the passed messages to a simpler output // Example: // This: "May 24 15:54:52 Dragons-iPhone NativeScript250(NativeScript)[356] : CONSOLE ERROR file:///app/tns_modules/@angular/core/bundles/core.umd.js:3477:36: ORIGINAL STACKTRACE:" // Becomes: CONSOLE ERROR file:///app/tns_modules/@angular/core/bundles/core.umd.js:3477:36: ORIGINAL STACKTRACE: protected infoFilterRegex = new RegExp( - `^.*(?::[ \t]?|:[ \t]?|:[ \t]?|\\(NativeScript\\)[ \t]?|${this.appOutputRegex.source}:[ \t]?){1}` + `^.*(?::[ \t]?|:[ \t]?|:[ \t]?|\\(NativeScript\\)[ \t]?|${this.appOutputRegex.source}:[ \t]?){1}`, ); // Used to post filter messages that slip through but are not coming from NativeScript itself. @@ -21,6 +24,10 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { // we then use this to filter out non-NativeScript lines protected postFilterRegex: RegExp = /^\((.+)\) \[com\.apple.+\]/; + // Only the structured logs reassemble split messages; here their pieces just + // lose the header and show as consecutive lines. + private pieceHeaderRegex: RegExp = IOS_LOG_PIECE_HEADER; + private filterActive: boolean = true; private partialLine: string = null; @@ -29,7 +36,7 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { public filterData( data: string, - loggingOptions: Mobile.IDeviceLogOptions = {} + loggingOptions: Mobile.IDeviceLogOptions = {}, ): string { const specifiedLogLevel = (loggingOptions.logLevel || "").toUpperCase(); @@ -83,6 +90,7 @@ export class IOSLogFilter implements Mobile.IPlatformLogFilter { } currentLine = currentLine + .replace(this.pieceHeaderRegex, "") // remove lading space before CONSOLE .replace(/^\s*CONSOLE/, "CONSOLE") // trim trailing spaces only to preserve indentation diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 7ac6f30c0e..c7cc428353 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -7,7 +7,7 @@ import * as helpers from "../common/helpers"; import { attachAwaitDetach } from "../common/helpers"; import * as projectServiceBaseLib from "./platform-project-service-base"; import { PlistSession, Reporter } from "../tools/plist-merge/plist-session"; -import { EOL } from "os"; +import { EOL, userInfo } from "os"; import * as plist from "plist"; import * as fastGlob from "fast-glob"; import { IOSProvisionService } from "./ios-provision-service"; @@ -70,6 +70,31 @@ export const SimulatorPlatformSdkName = "iphonesimulator"; export const VisionDevicePlatformSdkName = "xros"; export const VisionSimulatorPlatformSdkName = "xrsimulator"; +const DERIVED_DATA_RELATIVE_PATH = path.join( + constants.BUILD_DIR, + "DerivedData", +); + +/** + * With a workspace-relative DerivedData location, Xcode appends the workspace + * name to it, so this matches what Xcode uses and lets CLI and Xcode builds + * share one cache. + */ +export const getDerivedDataPath = ( + projectRoot: string, + projectName: string, +): string => path.join(projectRoot, DERIVED_DATA_RELATIVE_PATH, projectName); + +/** + * Lives next to the platform folder rather than in it, so regenerating + * platforms/ios still reuses unchanged compiles; deleting platforms/ clears it. + */ +export const getCompilationCachePath = ( + platformsDir: string, + platformName: string, +): string => + path.join(platformsDir, ".cache", platformName, "CompilationCache"); + const FRAMEWORK_EXTENSIONS = [".framework", ".xcframework"]; const getPlatformSdkName = (buildData: IBuildData): string => { @@ -177,8 +202,9 @@ export class IOSProjectService getBuildOutputPath: (options: IBuildData): string => { const config = getConfigurationName(!options || options.release); return path.join( - projectRoot, - constants.BUILD_DIR, + getDerivedDataPath(projectRoot, projectData.projectName), + "Build", + "Products", `${config}-${getPlatformSdkName(options)}`, ); }, @@ -437,6 +463,7 @@ export class IOSProjectService buildData: IOSBuildData, ): Promise { const platformData = this.getPlatformData(projectData); + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); // On a first build, the runtime (and any other Swift packages) download // here. Pre-resolve under a clear spinner so the subsequent @@ -928,6 +955,8 @@ export class IOSProjectService "The support for Apple Watch App is currently in Beta. For more information about the current development state and any known issues, please check the relevant GitHub issue: https://github.com/NativeScript/nativescript-cli/issues/4589", ); } + + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); } public prepareAppResources(projectData: IProjectData): void { @@ -1292,6 +1321,11 @@ export class IOSProjectService projectData, platformData, ); + await this.$cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + this.getCompilationCacheSettings(projectData), + ); await this.$cocoapodsService.applyPodfileFromExtensions( projectData, platformData, @@ -1370,6 +1404,10 @@ export class IOSProjectService } } + // `pod install` may have just created the .xcworkspace, and Swift + // packages resolve into the location this sets. + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); + await this.$spmService.applySPMPackages( platformData, projectData, @@ -1377,6 +1415,73 @@ export class IOSProjectService ); } + /** + * Puts DerivedData inside the platforms folder for both xcodebuild and + * Xcode, so CLI and Xcode builds share one cache. Xcode ignores this + * setting in xcshareddata, so it has to be the per-user file. + */ + private ensureWorkspaceDerivedDataLocation( + projectData: IProjectData, + platformData: IPlatformData, + ): void { + if (this.$options.hostProjectPath) { + return; + } + + const { projectRoot } = platformData; + const xcodeprojPath = path.join( + projectRoot, + `${projectData.projectName}.xcodeproj`, + ); + const podsWorkspacePath = path.join( + projectRoot, + `${projectData.projectName}.xcworkspace`, + ); + const workspacePaths = [ + ...(this.$fs.exists(xcodeprojPath) + ? [path.join(xcodeprojPath, "project.xcworkspace")] + : []), + ...(this.$fs.exists(podsWorkspacePath) ? [podsWorkspacePath] : []), + ]; + const settings: Record = { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: DERIVED_DATA_RELATIVE_PATH, + }; + + for (const workspacePath of workspacePaths) { + const contentsPath = path.join(workspacePath, "contents.xcworkspacedata"); + if (!this.$fs.exists(contentsPath)) { + this.$fs.writeFile( + contentsPath, + `${EOL}${EOL} ${EOL} ${EOL}${EOL}`, + ); + } + + const settingsPath = path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + let existing: Record = {}; + if (this.$fs.exists(settingsPath)) { + try { + existing = plist.parse(this.$fs.readText(settingsPath)); + } catch (err) { + this.$logger.trace(`Unable to parse ${settingsPath}: `, err); + } + } + if (_.isMatch(existing, settings)) { + continue; + } + + this.$fs.writeFile( + settingsPath, + plist.build({ ...existing, ...settings }), + ); + } + } + public beforePrepareAllPlugins( projectData: IProjectData, dependencies?: IDependencyData[], @@ -1872,6 +1977,27 @@ export class IOSProjectService } } + const compilationCacheSettings = + this.getCompilationCacheSettings(projectData); + if (compilationCacheSettings) { + const tempDir = await this.$tempService.mkdirSync("compilation-cache"); + const tempXcconfigPath = path.join(tempDir, "compilation-cache.xcconfig"); + this.$fs.writeFile( + tempXcconfigPath, + _.map( + compilationCacheSettings, + (value, key) => `${key} = ${value}${EOL}`, + ).join(""), + ); + // Merged last, so the app's or a plugin's xcconfig can turn it off. + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { + await this.$xcconfigService.mergeFiles( + tempXcconfigPath, + pluginsXcconfigFilePath, + ); + } + } + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { // Set Entitlements Property to point to default file if not set explicitly by the user. const entitlementsPropertyValue = this.$xcconfigService.readPropertyValue( @@ -1909,6 +2035,31 @@ export class IOSProjectService } } + /** + * Xcode's compilation cache replays compiles whose inputs are unchanged, + * even after the project is regenerated and file timestamps change. + */ + public getCompilationCacheSettings( + projectData: IProjectData, + ): Record | null { + if (this.$options.hostProjectPath) { + return null; + } + + const platformData = this.getPlatformData(projectData); + return { + COMPILATION_CACHE_ENABLE_CACHING: + this.$xcconfigService.readPropertyValue( + this.getBuildXCConfigFilePath(projectData), + "COMPILATION_CACHE_ENABLE_CACHING", + ) || "YES", + COMPILATION_CACHE_CAS_PATH: getCompilationCachePath( + projectData.platformsDir, + platformData.platformNameLowerCase, + ), + }; + } + private getBuildXCConfigFilePath(projectData: IProjectData): string { const buildXCConfig = path.join( projectData.appResourcesDirectoryPath, diff --git a/lib/services/ios/spm-service.ts b/lib/services/ios/spm-service.ts index b4515b4dc7..d31b07c4b4 100644 --- a/lib/services/ios/spm-service.ts +++ b/lib/services/ios/spm-service.ts @@ -1,6 +1,7 @@ import { injector } from "../../common/yok"; import { IProjectConfigService, IProjectData } from "../../definitions/project"; import { IPlatformData } from "../../definitions/platform"; +import { getDerivedDataPath } from "../ios-project-service"; import { IFileSystem } from "../../common/declarations"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; import { color } from "../../color"; @@ -531,9 +532,9 @@ export class SPMService implements ISPMService { } /** - * True when a Package.resolved already exists for the project — i.e. packages - * have been resolved at least once, so the build's own resolve step will be a - * fast no-op rather than a slow, silent first-time download. + * True when a Package.resolved exists and packages were already fetched into + * the build's DerivedData, so the build's own resolve step will be a fast + * no-op rather than a slow, silent first-time download. */ private arePackagesResolved( platformData: IPlatformData, @@ -556,7 +557,15 @@ export class SPMService implements ISPMService { "Package.resolved", ), ]; - return candidates.some((p) => this.$fs.exists(p)); + return ( + candidates.some((p) => this.$fs.exists(p)) && + this.$fs.exists( + path.join( + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + "SourcePackages", + ), + ) + ); } } injector.register("spmService", SPMService); diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index fbc5a2ebe4..0104caf2fe 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -7,18 +7,13 @@ import { IBuildConfig, IiOSBuildConfig, } from "../../definitions/project"; -import { IXcconfigService } from "../../declarations"; +import { IOptions, IXcconfigService } from "../../declarations"; import { IPlatformData } from "../../definitions/platform"; import { IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; import * as _ from "lodash"; -import { - DevicePlatformSdkName, - SimulatorPlatformSdkName, - VisionDevicePlatformSdkName, - VisionSimulatorPlatformSdkName, -} from "../ios-project-service"; +import { getDerivedDataPath } from "../ios-project-service"; export class XcodebuildArgsService implements IXcodebuildArgsService { constructor( @@ -27,6 +22,7 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { private $fs: IFileSystem, private $iOSWatchAppService: IIOSWatchAppService, private $logger: ILogger, + private $options: IOptions, private $xcconfigService: IXcconfigService, ) {} @@ -43,13 +39,11 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { args = args.concat(["CODE_SIGN_IDENTITY="]); } - let destination = "generic/platform=iOS Simulator"; - - let isvisionOS = this.$devicePlatformsConstants.isvisionOS( - buildConfig.platform, - ); + let destination = buildConfig._device + ? `platform=iOS Simulator,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS Simulator"; - if (isvisionOS) { + if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { destination = "generic/platform=visionOS Simulator"; if (buildConfig._device) { destination += `,id=${buildConfig._device.deviceInfo.identifier}`; @@ -64,15 +58,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, ]) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS - ? VisionSimulatorPlatformSdkName - : SimulatorPlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()) .concat(this.getXcodeProjectArgs(platformData, projectData)); @@ -89,10 +74,14 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { platformData.getBuildOutputPath(buildConfig), projectData.projectName + ".xcarchive", ); - let destination = "generic/platform=iOS"; let isvisionOS = this.$devicePlatformsConstants.isvisionOS( buildConfig.platform, ); + const shouldArchive = this.shouldArchiveDeviceBuild(buildConfig); + let destination = + !shouldArchive && buildConfig._device + ? `platform=iOS,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS"; if (isvisionOS) { destination = "generic/platform=visionOS"; @@ -103,22 +92,13 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { const args = [ "-destination", destination, - "archive", - "-archivePath", - archivePath, + ...(shouldArchive ? ["archive", "-archivePath", archivePath] : ["build"]), "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, "-allowProvisioningUpdates", ] .concat(this.getXcodeProjectArgs(platformData, projectData)) .concat(architectures) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS ? VisionDevicePlatformSdkName : DevicePlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()); // pbxproj-dom sets CODE_SIGN_IDENTITY[sdk=iphoneos*] which doesn't match @@ -134,10 +114,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } + /** + * Debug device builds use a plain build, like Xcode's Run, so both share + * one set of intermediates; archives are only needed for release. + */ + public shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean { + return !!(buildConfig.release || buildConfig.buildForAppStore); + } + private async getArchitecturesArgs( buildConfig: IBuildConfig, ): Promise { - const args = []; + const args: string[] = []; if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { // visionOS builds (device/simulator) are arm64-only; rely on destination for arch @@ -146,10 +136,22 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } - const devicesArchitectures = buildConfig.buildForDevice - ? await this.getArchitecturesFromConnectedDevices(buildConfig) - : []; - if (!buildConfig.buildForDevice || devicesArchitectures.length > 1) { + if (!buildConfig.buildForDevice) { + // A specific simulator destination already limits the build to its + // architecture, the same way Xcode builds for it. + if (!buildConfig._device) { + args.push("ONLY_ACTIVE_ARCH=NO"); + } + return args; + } + + if (buildConfig._device && !this.shouldArchiveDeviceBuild(buildConfig)) { + return args; + } + + const devicesArchitectures = + await this.getArchitecturesFromConnectedDevices(buildConfig); + if (devicesArchitectures.length > 1) { args.push("ONLY_ACTIVE_ARCH=NO"); } @@ -180,6 +182,17 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { skipMacroValidation, ]; + // Otherwise the workspace's DerivedData setting decides the location. + // Passing -derivedDataPath as well would move the module and SDK stat + // caches, whose paths end up in every compile command, so CLI and Xcode + // builds would no longer reuse each other's output. + if (this.$options.hostProjectPath) { + extraArgs.push( + "-derivedDataPath", + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + ); + } + // Selects how xcodebuild authenticates against private Swift Package // registries ("netrc" or "keychain"). It describes the machine's // credential setup rather than the build, so it is read from the @@ -206,29 +219,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { // references: https://medium.com/@iostechset/why-cocoapods-eats-app-icons-79fe729808d4 // https://github.com/CocoaPods/CocoaPods/issues/7003 - // Xcode 26 makes Swift "explicitly built modules" the default. A - // regression there prevents macro/compiler-plugin SPM targets from - // resolving their swift-syntax module dependencies, failing with: - // "Unable to resolve module dependency: 'SwiftSyntax'" (and SwiftParser, - // SwiftSyntaxMacros, SwiftCompilerPlugin, SwiftDiagnostics). - // Passed as a command-line build setting so it overrides ALL targets, - // including the package targets we don't control. + // Passed as a command-line build setting so it also reaches package + // targets, but only when the app sets it: any value Xcode wouldn't use + // itself stops CLI and Xcode builds from reusing each other's output. + // Early Xcode 26 releases fail on macro packages with explicit modules + // ("Unable to resolve module dependency: 'SwiftSyntax'"); setting it to + // NO in build.xcconfig works around that. // ref: https://forums.swift.org/t/xcode-26-unable-to-find-module-dependency/80516 const explicitModulesProperty = "SWIFT_ENABLE_EXPLICIT_MODULES"; - const explicitModulesValue = - this.$xcconfigService.readPropertyValue( - BUILD_SETTINGS_FILE_PATH, - explicitModulesProperty, - ) || "NO"; - extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); - - const deployTargetProperty = "IPHONEOS_DEPLOYMENT_TARGET"; - const deployTargetVersion = this.$xcconfigService.readPropertyValue( + const explicitModulesValue = this.$xcconfigService.readPropertyValue( BUILD_SETTINGS_FILE_PATH, - deployTargetProperty, + explicitModulesProperty, ); - if (deployTargetVersion) { - extraArgs.push(`${deployTargetProperty}=${deployTargetVersion}`); + if (explicitModulesValue) { + extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); } const swiftUIBootProperty = "NS_SWIFTUI_BOOT"; @@ -267,26 +271,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return this.$logger.getLevel() === "INFO" ? ["-quiet"] : []; } - private getBuildCommonArgs( - platformData: IPlatformData, - projectData: IProjectData, - platformSdkName: string, - ): string[] { - let args: string[] = []; - - if (!this.$iOSWatchAppService.hasWatchApp(platformData, projectData)) { - args = args.concat(["-sdk", platformSdkName]); - } - - args = args.concat([ - "BUILD_DIR=" + path.join(platformData.projectRoot, constants.BUILD_DIR), - "SHARED_PRECOMPS_DIR=" + - path.join(platformData.projectRoot, constants.BUILD_DIR, "sharedpch"), - ]); - - return args; - } - private async getArchitecturesFromConnectedDevices( buildConfig: IiOSBuildConfig, ): Promise { diff --git a/lib/services/ios/xcodebuild-service.ts b/lib/services/ios/xcodebuild-service.ts index 4f8308dfb5..6c7d8904a9 100644 --- a/lib/services/ios/xcodebuild-service.ts +++ b/lib/services/ios/xcodebuild-service.ts @@ -2,32 +2,38 @@ import * as path from "path"; import { IPlatformData } from "../../definitions/platform"; import { IProjectData, IBuildConfig } from "../../definitions/project"; import { injector } from "../../common/yok"; +import { IFileSystem, IChildProcess } from "../../common/declarations"; export class XcodebuildService implements IXcodebuildService { constructor( + private $childProcess: IChildProcess, private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, private $xcodebuildArgsService: IXcodebuildArgsService, - private $xcodebuildCommandService: IXcodebuildCommandService + private $xcodebuildCommandService: IXcodebuildCommandService, ) {} public async buildForDevice( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, stdio: buildConfig && buildConfig.buildOutputStdio, }); + if (!this.$xcodebuildArgsService.shouldArchiveDeviceBuild(buildConfig)) { + return this.packageDevelopmentIpa(platformData, projectData, buildConfig); + } const archivePath = await this.createDevelopmentArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } @@ -35,12 +41,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForSimulator( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForSimulatorArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -51,12 +57,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForAppStore( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -65,25 +71,74 @@ export class XcodebuildService implements IXcodebuildService { const archivePath = await this.createDistributionArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } + /** + * Packages the signed .app from a plain build as an .ipa, at the same path + * an exported development archive would use. + */ + private async packageDevelopmentIpa( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise { + const outputPath = platformData.getBuildOutputPath(buildConfig); + const appPath = path.join(outputPath, `${projectData.projectName}.app`); + const ipaPath = path.join(outputPath, `${projectData.projectName}.ipa`); + // Kept next to the .app so cp -c can clone it instead of copying. + const payloadPath = path.join(outputPath, "ipa-staging", "Payload"); + + this.$fs.deleteDirectory(path.dirname(payloadPath)); + this.$fs.deleteFile(ipaPath); + this.$fs.createDirectory(payloadPath); + try { + await this.$childProcess.spawnFromEvent( + "cp", + ["-cR", appPath, payloadPath], + "close", + {}, + { throwError: true }, + ); + await this.$childProcess.spawnFromEvent( + "ditto", + [ + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + ipaPath, + ], + "close", + {}, + { throwError: true }, + ); + } finally { + this.$fs.deleteDirectory(path.dirname(payloadPath)); + } + + return ipaPath; + } + private async createDevelopmentArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDevelopmentExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const args = [ "-exportArchive", @@ -105,17 +160,17 @@ export class XcodebuildService implements IXcodebuildService { private async createDistributionArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDistributionExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const provision = buildConfig.provision || buildConfig.mobileProvisionIdentifier; diff --git a/lib/services/livesync/ios-device-livesync-service.ts b/lib/services/livesync/ios-device-livesync-service.ts index 78c3150ff9..8668c0fca0 100644 --- a/lib/services/livesync/ios-device-livesync-service.ts +++ b/lib/services/livesync/ios-device-livesync-service.ts @@ -58,7 +58,8 @@ export class IOSDeviceLiveSyncService } private async setupSocketIfNeeded( - projectData: IProjectData + projectData: IProjectData, + ensureAppStarted: boolean = true ): Promise { // TODO: persist the sockets per app in order to support LiveSync on multiple apps on the same device if (this.socket) { @@ -67,8 +68,6 @@ export class IOSDeviceLiveSyncService const appId = projectData.projectIdentifiers.ios; try { - // TODO: temp workaround till we setup the sockets along with the app start - const ensureAppStarted = true; this.socket = await this.device.getDebugSocket( appId, projectData.projectName, @@ -217,7 +216,9 @@ export class IOSDeviceLiveSyncService if (liveSyncInfo.useHotModuleReload) { // enable HOT updates - await this.setupSocketIfNeeded(projectData); + // Starting the app again here would terminate the instance launched + // above. + await this.setupSocketIfNeeded(projectData, false); } } diff --git a/test/cocoapods-service.ts b/test/cocoapods-service.ts index 24281b5636..aa9431df92 100644 --- a/test/cocoapods-service.ts +++ b/test/cocoapods-service.ts @@ -6,6 +6,9 @@ import * as _ from "lodash"; import { LoggerStub, ErrorsStub } from "./stubs"; import { XcconfigService } from "../lib/services/xcconfig-service"; import * as path from "path"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { FileSystem } from "../lib/common/file-system"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { IPluginData } from "../lib/definitions/plugins"; import { ICocoaPodsService, IProjectData } from "../lib/definitions/project"; @@ -27,10 +30,10 @@ interface IMergePodfileHooksTestCase { pluginData?: IPluginData; } -function createTestInjector(): IInjector { +function createTestInjector(fs: any = {}): IInjector { const testInjector: IInjector = new yok.Yok(); - testInjector.register("fs", {}); + testInjector.register("fs", fs); testInjector.register("cocoapodsService", CocoaPodsService); testInjector.register("childProcess", {}); testInjector.register("errors", ErrorsStub); @@ -61,7 +64,7 @@ function changeNewLineCharacter(input: string): string { describe("Cocoapods service", () => { if (require("os").platform() === "win32") { console.log( - "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux" + "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux", ); return; } @@ -92,7 +95,7 @@ describe("Cocoapods service", () => { injector: IInjector, podfileContent: string, projectPodfileContent?: string, - appResourcesPodfileContent?: string + appResourcesPodfileContent?: string, ): void => { const fs: IFileSystem = injector.resolve("fs"); @@ -567,21 +570,21 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); await cocoapodsService.applyPodfileToProject( testCase.pluginData ? testCase.pluginData.name : mockPluginData.name, cocoapodsService.getPluginPodfilePath( - testCase.pluginData || mockPluginData + testCase.pluginData || mockPluginData, ), mockProjectData, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -791,19 +794,19 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); cocoapodsService.removePodfileFromProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), mockProjectData, - nativeProjectPath + nativeProjectPath, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -818,7 +821,7 @@ end`, childProcess.exec = async ( command: string, options?: any, - execOptions?: IExecOptions + execOptions?: IExecOptions, ): Promise => { if (command === "arch -x86_64 pod --version") { // This is the command that is used to check if cocoapods is installed under Rosetta 2 @@ -835,7 +838,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => ({ stdout: "", stderr: "", @@ -857,7 +860,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; return { @@ -880,7 +883,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; assert.deepStrictEqual(args, ["install"]); @@ -908,7 +911,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return { stdout: "", @@ -919,7 +922,7 @@ end`, await assert.isRejected( cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), - "'pod install' command failed." + "'pod install' command failed.", ); }); @@ -935,14 +938,14 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return expectedResult; }; const result = await cocoapodsService.executePodInstall( projectRoot, - xcodeProjPath + xcodeProjPath, ); assert.deepStrictEqual(result, expectedResult); }); @@ -1084,13 +1087,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterApply + testCase.expectedProjectPodfileContentAfterApply, ); for (const pod of testCase.podsToRemove) { @@ -1098,13 +1101,13 @@ end`, pod.name, pod.path, projectData, - projectPodfilePath + projectPodfilePath, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterRemove + testCase.expectedProjectPodfileContentAfterRemove, ); }); }); @@ -1365,13 +1368,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContent + testCase.expectedProjectPodfileContent, ); }); }); @@ -1413,27 +1416,27 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1485,49 +1488,49 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1579,50 +1582,133 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); }); }); + +describe("applyPodfileBuildSettings", () => { + const projectName = "projectName"; + + function setup(buildXcconfig: string) { + const dir = mkdtempSync(path.join(tmpdir(), "podfile-deployment-target-")); + const testInjector = createTestInjector(FileSystem); + const fs: IFileSystem = testInjector.resolve("fs"); + const cocoapodsService: ICocoaPodsService = + testInjector.resolve("cocoapodsService"); + const projectData: any = { + projectDir: dir, + projectName, + appResourcesDirectoryPath: path.join(dir, "App_Resources"), + nsConfig: { overridePods: false }, + }; + const platformData: any = { + projectRoot: path.join(dir, "platforms", "ios"), + normalizedPlatformName: "iOS", + }; + const podfilePath = path.join(platformData.projectRoot, "Podfile"); + const xcconfigPath = path.join( + projectData.appResourcesDirectoryPath, + "iOS", + "build.xcconfig", + ); + fs.writeFile( + podfilePath, + `use_frameworks!${EOL}${EOL}target "${projectName}" do${EOL}${EOL}end`, + ); + fs.writeFile(xcconfigPath, buildXcconfig); + const apply = (buildSettings: Record = null) => + cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + buildSettings, + ); + return { fs, podfilePath, xcconfigPath, platformData, apply }; + } + + it("raises pods below the app's deployment target", async () => { + const { fs, podfilePath, platformData, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + + await apply(); + + const podfile = fs.readText(podfilePath); + assert.include(podfile, "# Begin Podfile - "); + assert.include(podfile, 'Gem::Version.new("18.0")'); + assert.include(podfile, "post_install do |installer|"); + assert.isFalse( + fs.exists(path.join(platformData.projectRoot, "Podfile-build-settings")), + ); + }); + + it("sets the given build settings on every pod target", async () => { + const { fs, podfilePath, apply } = setup(""); + + await apply({ COMPILATION_CACHE_ENABLE_CACHING: "YES" }); + + const podfile = fs.readText(podfilePath); + assert.include( + podfile, + 'config.build_settings["COMPILATION_CACHE_ENABLE_CACHING"] = "YES"', + ); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); + + it("removes the section once build.xcconfig no longer sets a deployment target", async () => { + const { fs, podfilePath, xcconfigPath, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + await apply(); + + fs.writeFile(xcconfigPath, ""); + await apply(); + + const podfile = fs.readText(podfilePath); + assert.notInclude(podfile, "Podfile-build-settings"); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); +}); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index a8a901a569..2121f5b989 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,6 +151,42 @@ describe("prepareController", () => { }); }); + describe("writeRuntimePackageJson", () => { + const writePackageJson = async (platform: string, release: boolean) => { + const injector = createTestInjector({ hasNativeChanges: false }); + let written: any; + const prepareController: PrepareController = + injector.resolve("prepareController"); + _.assign(prepareController, { + $projectConfigService: { + detectProjectConfigs: () => ({ usingNSConfig: false }), + readConfig: () => ({}), + }, + $fs: { + readJson: () => ({}), + writeJson: (filePath: string, data: any) => { + written = data; + }, + }, + }); + await prepareController.writeRuntimePackageJson( + { projectDir, projectName: "app", packageJsonData: {} }, + { platformNameLowerCase: platform, projectRoot: projectDir }, + { ...prepareData, release }, + ); + + return written; + }; + + it("asks for the structured log format and stdio in iOS debug builds only", async () => { + assert.equal((await writePackageJson("ios", false)).logFormat, 1); + assert.isUndefined((await writePackageJson("ios", true)).logFormat); + assert.isTrue((await writePackageJson("ios", false)).forwardStdio); + assert.isUndefined((await writePackageJson("ios", true)).forwardStdio); + assert.isUndefined((await writePackageJson("android", false)).logFormat); + }); + }); + describe("stopWatchers", () => { it("detaches the bundler handler of a paused watcher", async () => { const injector = createTestInjector({ hasNativeChanges: false }); diff --git a/test/device-sockets/app-debug-socket-proxy-factory.ts b/test/device-sockets/app-debug-socket-proxy-factory.ts new file mode 100644 index 0000000000..30d28473d2 --- /dev/null +++ b/test/device-sockets/app-debug-socket-proxy-factory.ts @@ -0,0 +1,324 @@ +import { assert } from "chai"; +import * as net from "net"; +import * as os from "os"; +import * as sinon from "sinon"; +import * as ws from "ws"; +import { MessageUnpackStream } from "ios-device-lib"; +import { + AppDebugSocketProxyFactory, + isAllowedDebuggerOrigin, +} from "../../lib/device-sockets/ios/app-debug-socket-proxy-factory"; + +const REPLACED_CLOSE_REASON = "Replaced by a newer debugger client"; + +const encode = (message: string): Buffer => { + const length = Buffer.byteLength(message, "utf16le"); + const payload = Buffer.allocUnsafe(length + 4); + payload.writeInt32BE(length, 0); + payload.write(message, 4, length, "utf16le"); + return payload; +}; + +const hasIPv6Loopback = () => + Object.values(os.networkInterfaces()) + .flat() + .some((address) => address.internal && address.family === "IPv6"); + +describe("AppDebugSocketProxyFactory", () => { + let backend: net.Server; + let backendPort: number; + let backendConnections: number; + let proxy: any; + let proxies: ws.Server[]; + let device: any; + + // Echoes every framed message, as the runtime answers each CDP request. + const startBackend = () => + new Promise((resolve) => { + backend = net.createServer((socket) => { + backendConnections++; + const packets = new MessageUnpackStream(); + socket.pipe(packets); + packets.on("data", (buffer: Buffer) => { + socket.write(encode(buffer.toString("utf16le"))); + }); + socket.on("error", () => {}); + }); + backend.listen(0, "127.0.0.1", () => { + backendPort = (backend.address()).port; + resolve(); + }); + }); + + const createDevice = () => { + const cachedSockets: { [appId: string]: net.Socket } = {}; + return { + deviceInfo: { identifier: "device" }, + getDebugSocket: async (appId: string) => { + if (!cachedSockets[appId]) { + cachedSockets[appId] = net.connect(backendPort, "127.0.0.1"); + } + return cachedSockets[appId]; + }, + destroyDebugSocket: async (appId: string) => { + cachedSockets[appId]?.destroy(); + cachedSockets[appId] = null; + }, + }; + }; + + const createProxy = ( + getAvailablePortInRange?: () => Promise, + watch = true, + ) => + new AppDebugSocketProxyFactory( + { info: () => {}, trace: () => {}, warn: () => {} }, + { fail: (message: string) => assert.fail(message) }, + { watch }, + {}, + { + getAvailablePortInRange: + getAvailablePortInRange || + (async () => { + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }), + }, + ); + + const startProxy = async (factory: any = proxy) => { + const server: ws.Server = await factory.ensureWebSocketProxy( + device, + "app", + "project", + "/project", + ); + proxies.push(server); + return (server.address()).port; + }; + + const open = (port: number, options?: ws.ClientOptions, host = "127.0.0.1") => + new Promise((resolve, reject) => { + const client = new ws(`ws://${host}:${port}`, options); + client.once("open", () => resolve(client)); + client.once("error", reject); + }); + + const assertRejects = async (promise: Promise) => { + const client = await promise.catch((): ws => null); + assert.isNull(client); + }; + + const roundTrip = (client: ws, message: string) => + new Promise((resolve, reject) => { + client.once("message", (data) => resolve(data.toString())); + client.once("close", () => reject(new Error("closed before the reply"))); + client.send(message); + }); + + const closeReason = (client: ws) => + new Promise((resolve) => { + client.once("close", (_code, reason) => resolve(reason.toString())); + }); + + beforeEach(async () => { + backendConnections = 0; + proxies = []; + await startBackend(); + device = createDevice(); + proxy = createProxy(); + }); + + afterEach(async () => { + await device.destroyDebugSocket("app"); + proxies.forEach((server) => { + server.clients.forEach((client) => client.terminate()); + server.close(); + }); + await new Promise((resolve) => backend.close(resolve)); + }); + + it("moves to the next port when another server takes the checked one", async () => { + const blocker = net.createServer(); + await new Promise((resolve) => + blocker.listen(0, "127.0.0.1", () => resolve()), + ); + const blockedPort = (blocker.address()).port; + const ports = [blockedPort]; + const factory = createProxy(async () => { + if (ports.length) { + return ports.shift(); + } + const server = net.createServer(); + await new Promise((r) => server.listen(0, () => r())); + const { port } = server.address(); + await new Promise((r) => server.close(r)); + return port; + }); + + const port = await startProxy(factory); + await new Promise((resolve) => blocker.close(resolve)); + + assert.notEqual(port, blockedPort); + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("listens on both loopback addresses and nothing else", async () => { + const port = await startProxy(); + + const hosts = ["127.0.0.1"]; + if (hasIPv6Loopback()) { + hosts.push("[::1]"); + } + for (const host of hosts) { + const client = await open(port, undefined, host); + assert.equal(await roundTrip(client, host), host); + } + + const addresses = Object.values(os.networkInterfaces()) + .flat() + .filter((address) => !address.internal && address.family === "IPv4"); + for (const { address } of addresses) { + await assertRejects(open(port, undefined, address)); + } + }); + + it("turns away web pages but not DevTools or clients without an origin", async () => { + const port = await startProxy(); + + await assertRejects(open(port, { origin: "https://evil.example" })); + assert.equal(backendConnections, 0); + + const devtools = await open(port, { origin: "devtools://devtools" }); + assert.equal(await roundTrip(devtools, "hello"), "hello"); + const noOrigin = await open(port); + assert.equal(await roundTrip(noOrigin, "hello"), "hello"); + }); + + it("serves a client after the previous one disconnected", async () => { + const port = await startProxy(); + for (let i = 0; i < 3; i++) { + const client = await open(port); + assert.equal(await roundTrip(client, `message ${i}`), `message ${i}`); + client.close(); + } + + const client = await open(port); + assert.equal(await roundTrip(client, "last"), "last"); + }); + + it("still serves clients after one drops during the handshake", async () => { + const port = await startProxy(); + await new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.write( + "GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + setImmediate(() => { + socket.destroy(); + resolve(); + }); + }); + }); + + const client = await open(port); + assert.equal(await roundTrip(client, "hello"), "hello"); + }); + + it("gives the backend to the newest client and tells the previous one why", async () => { + const port = await startProxy(); + const first = await open(port); + const firstClosed = closeReason(first); + + const second = await open(port); + + assert.equal(await firstClosed, REPLACED_CLOSE_REASON); + assert.equal(await roundTrip(second, "hello"), "hello"); + assert.equal(backendConnections, 2); + }); + + it("cleans up and exits without watch when the app goes away", async () => { + const exit = sinon.stub(process, "exit"); + const destroyDebugSocket = sinon.spy(device, "destroyDebugSocket"); + try { + const port = await startProxy(createProxy(undefined, false)); + const client = await open(port); + const closed = closeReason(client); + const appSocket = await device.getDebugSocket("app"); + + appSocket.destroy(); + await closed; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.isTrue(destroyDebugSocket.called); + assert.isTrue(exit.calledWith(0)); + } finally { + exit.restore(); + destroyDebugSocket.restore(); + } + }); + + it("keeps the new client's backend when the replaced client closes late", async () => { + const port = await startProxy(); + const first = await open(port); + const opening = open(port); + first.terminate(); + + const second = await opening; + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.equal(second.readyState, ws.OPEN); + assert.equal(await roundTrip(second, "hello"), "hello"); + }); + + it("leaves only the newest of simultaneous clients connected", async () => { + const port = await startProxy(); + const clients = await Promise.all( + [0, 1, 2, 3].map(() => open(port).catch((): ws => null)), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const connected = clients.filter( + (client) => client && client.readyState === ws.OPEN, + ); + assert.lengthOf(connected, 1); + assert.equal(await roundTrip(connected[0], "hello"), "hello"); + }); + + describe("isAllowedDebuggerOrigin", () => { + const allowed = [ + undefined, + "", + "devtools://devtools", + "chrome-devtools://devtools", + "https://chrome-devtools-frontend.appspot.com", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://[::1]:3000", + ]; + for (const origin of allowed) { + it(`allows ${origin || "no origin"}`, () => { + assert.isTrue(isAllowedDebuggerOrigin(origin)); + }); + } + + const rejected = [ + "https://evil.example", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "https://chrome-devtools-frontend.appspot.com.evil.example", + "null", + "file://", + ]; + for (const origin of rejected) { + it(`rejects ${origin}`, () => { + assert.isFalse(isAllowedDebuggerOrigin(origin)); + }); + } + }); +}); diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index e55c28f3f2..4d54f6477d 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -39,7 +39,8 @@ import { } from "./stubs"; import { xcode } from "../lib/node/xcode"; import { mkdtempSync } from "fs"; -import { tmpdir } from "os"; +import { tmpdir, userInfo } from "os"; +import * as plist from "plist"; import * as path from "path"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { XcodebuildService } from "../lib/services/ios/xcodebuild-service"; @@ -406,6 +407,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -516,6 +520,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -1392,7 +1399,7 @@ describeOnMacOS("Merge Project XCConfig files", () => { }); }); - it("creates empty plugins-.xcconfig in case there are no build.xcconfig in App_Resources and in plugins", async () => { + it("creates plugins-.xcconfig with only the compilation cache settings when there are no build.xcconfig in App_Resources and in plugins", async () => { await (iOSProjectService).mergeProjectXcconfigFiles(projectData); const destinationFilePaths = @@ -1403,10 +1410,49 @@ describeOnMacOS("Merge Project XCConfig files", () => { fs.exists(destinationFilePath), `Target build xcconfig ${destinationFilePath} is missing.`, ); - const content = fs.readFile(destinationFilePath).toString(); - assert.equal(content, ""); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "YES", + ); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_CAS_PATH", + ), + path.join( + projectData.platformsDir, + ".cache", + "ios", + "CompilationCache", + ), + ); }); }); + + it("lets the app's build.xcconfig turn compilation caching off", async () => { + fs.writeFile( + appResourcesXcconfigPath, + "COMPILATION_CACHE_ENABLE_CACHING = NO", + ); + + await (iOSProjectService).mergeProjectXcconfigFiles(projectData); + + _.each( + xcconfigService.getPluginsXcconfigFilePaths(projectRoot), + (destinationFilePath) => { + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "NO", + ); + }, + ); + }); }); describe("handleNativeDependenciesChange", () => { @@ -1442,6 +1488,85 @@ describe("handleNativeDependenciesChange", () => { }); }); +describe("workspace DerivedData location", () => { + const projectName = "myTestProjectName"; + const settingsFile = (workspacePath: string) => + path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + + function setup() { + const dir = mkdtempSync(path.join(tmpdir(), "derivedDataLocation-")); + const testInjector = createTestInjector(dir, projectName); + const iOSProjectService = testInjector.resolve("iOSProjectService"); + const projectData = testInjector.resolve("projectData"); + const platformData = iOSProjectService.getPlatformData(projectData); + const fs: IFileSystem = testInjector.resolve("fs"); + const xcodeprojPath = path.join( + platformData.projectRoot, + `${projectName}.xcodeproj`, + ); + fs.createDirectory(xcodeprojPath); + const writeSettings = () => + iOSProjectService.ensureWorkspaceDerivedDataLocation( + projectData, + platformData, + ); + return { fs, platformData, xcodeprojPath, writeSettings }; + } + + it("points the project and pods workspaces at the CLI's DerivedData folder", () => { + const { fs, platformData, xcodeprojPath, writeSettings } = setup(); + const podsWorkspacePath = path.join( + platformData.projectRoot, + `${projectName}.xcworkspace`, + ); + fs.createDirectory(podsWorkspacePath); + + writeSettings(); + + const projectWorkspacePath = path.join( + xcodeprojPath, + "project.xcworkspace", + ); + assert.isTrue( + fs.exists(path.join(projectWorkspacePath, "contents.xcworkspacedata")), + ); + for (const workspacePath of [projectWorkspacePath, podsWorkspacePath]) { + assert.deepStrictEqual( + plist.parse(fs.readText(settingsFile(workspacePath))), + { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + }, + ); + } + }); + + it("keeps other workspace settings", () => { + const { fs, xcodeprojPath, writeSettings } = setup(); + const file = settingsFile(path.join(xcodeprojPath, "project.xcworkspace")); + fs.writeFile( + file, + plist.build({ + DerivedDataLocationStyle: "Default", + ShowSharedSchemesAutomaticallyEnabled: true, + }), + ); + + writeSettings(); + + assert.deepStrictEqual(plist.parse(fs.readText(file)), { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + ShowSharedSchemesAutomaticallyEnabled: true, + }); + }); +}); + describe("SPM Packages", () => { it("should add SPM packages to the project", async () => { // todo: add tests for SPM packages diff --git a/test/services/ios-log-filter.ts b/test/services/ios-log-filter.ts index 2330e8958e..038597cd80 100644 --- a/test/services/ios-log-filter.ts +++ b/test/services/ios-log-filter.ts @@ -182,6 +182,58 @@ describe("iOSLogFilter", () => { const infoLogLevel = "INFO"; const fullLogLevel = "FULL"; + it("removes the headers of split messages", () => { + testInjector = createTestInjector("app"); + logFilter = testInjector.resolve(IOSLogFilter); + const header = + "\u0000Oct 29 20:20:54 iPad app(NativeScript)[1514] : "; + const chunks = [ + `${header}[ns 1f 1/3] CONSOLE LOG: a\n`, + `${header}[ns 1f 2/3] CONSOLE LOG: b\n`, + `${header}[ns 1f 3/3] CONSOLE LOG: c\n`, + `${header}CONSOLE LOG: d\n`, + ]; + + const output = chunks + .map((chunk) => + logFilter.filterData(chunk, { + logLevel: infoLogLevel, + projectName: "app", + }), + ) + .join(""); + + assert.strictEqual( + output, + "CONSOLE LOG: a\nCONSOLE LOG: b\nCONSOLE LOG: c\nCONSOLE LOG: d\n", + ); + }); + + it("drops other processes' lines that have no image name, such as the kernel's", () => { + testInjector = createTestInjector("nsconsoletablet"); + logFilter = testInjector.resolve(IOSLogFilter); + const chunks = [ + '\u0000Oct 29 20:20:54 iPad nsconsoletablet(NativeScript)[1514] : CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]\n', + "\u0000Oct 29 20:20:45 iPad kernel()[0] : wlan0:com.apple.p2p.awdl0: isInfraRealtimePacketThresholdAllowed allowed:1 option:32 threshold:50\n", + "\u0000Oct 29 20:20:45 iPad kernel()[0] : wlan0:com.apple.p2p.awdl0: currentInfraTrafficType:9274 checking if realtime upgrade required\n", + ]; + + const output = chunks + .map((chunk) => + logFilter.filterData(chunk, { + logLevel: infoLogLevel, + projectName: "nsconsoletablet", + }), + ) + .filter(Boolean) + .join(""); + + assert.strictEqual( + output, + 'CONSOLE LOG: [ "network", "internet probe -> true (status 200)" ]\n', + ); + }); + describe("filterData", () => { testData.forEach((data) => { it(`returns correct data on iOS ${data.version} when data comes in chunks`, () => { @@ -211,7 +263,7 @@ describe("iOSLogFilter", () => { assert.deepStrictEqual( output, - data.infoExpectedArr.filter((item) => item !== null).join("\n") + data.infoExpectedArr.filter((item) => item !== null).join("\n"), ); }); @@ -220,7 +272,7 @@ describe("iOSLogFilter", () => { logFilter = testInjector.resolve(IOSLogFilter); const actualData = logFilter.filterData( data.originalDataArr.join("\n"), - { logLevel: fullLogLevel, projectName: data.projectName } + { logLevel: fullLogLevel, projectName: data.projectName }, ); const actualArr = actualData.split("\n").map((line) => line.trim()); const expectedArr = data.originalDataArr @@ -252,7 +304,7 @@ describe("iOSLogFilter", () => { const actualArr = actualData.split("\n").map((line) => line.trim()); assert.deepStrictEqual( actualArr, - data.simulatorExpectedArr.filter((item) => item !== null) + data.simulatorExpectedArr.filter((item) => item !== null), ); }); @@ -261,11 +313,11 @@ describe("iOSLogFilter", () => { logFilter = testInjector.resolve(IOSLogFilter); const actualData = logFilter.filterData( data.originalDataArr.join("\n"), - { logLevel: infoLogLevel, projectName: data.projectName } + { logLevel: infoLogLevel, projectName: data.projectName }, ); const actualArr = actualData.split("\n").map((line) => line.trim()); const expectedArr = data.infoExpectedArr.filter( - (item) => item !== null + (item) => item !== null, ); assert.deepStrictEqual(actualArr, expectedArr); }); diff --git a/test/services/ios/xcodebuild-args-service.ts b/test/services/ios/xcodebuild-args-service.ts index 6bffa9e9e0..e753d177a5 100644 --- a/test/services/ios/xcodebuild-args-service.ts +++ b/test/services/ios/xcodebuild-args-service.ts @@ -12,6 +12,7 @@ function createTestInjector(data: { hasProjectWorkspace: boolean; connectedDevices?: any[]; buildXcconfigContent?: string; + hostProjectPath?: string; }): IInjector { const injector = new Yok(); injector.register("devicePlatformsConstants", DevicePlatformsConstants); @@ -29,6 +30,7 @@ function createTestInjector(data: { injector.register("logger", { getLevel: () => data.logLevel, }); + injector.register("options", { hostProjectPath: data.hostProjectPath }); injector.register("xcodebuildArgsService", XcodebuildArgsService); injector.register("childProcess", {}); injector.register("xcconfigService", XcconfigService); @@ -45,20 +47,12 @@ const appResourcesDirectoryPath = "App_Resources"; const projectName = "myApp"; const buildOutputPath = path.join(projectRoot, projectName, "archive"); -function getCommonArgs() { - return [ - "BUILD_DIR=" + path.join(projectRoot, "build"), - "SHARED_PRECOMPS_DIR=" + path.join(projectRoot, "build", "sharedpch"), - ]; -} - function getXcodeProjectArgs(data?: { hasProjectWorkspace: boolean }) { const extraArgs = [ "-scheme", projectName, "-skipPackagePluginValidation", "-skipMacroValidation", - "SWIFT_ENABLE_EXPLICIT_MODULES=NO", ]; return data && data.hasProjectWorkspace ? [ @@ -112,6 +106,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "SWIFT_ENABLE_EXPLICIT_MODULES=NO"); }); + it("leaves SWIFT_ENABLE_EXPLICIT_MODULES to Xcode when build.xcconfig doesn't set it", () => { + const injector = createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = xcodebuildArgsService.getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.isUndefined( + actualArgs.find((arg) => + arg.startsWith("SWIFT_ENABLE_EXPLICIT_MODULES="), + ), + ); + }); + it("should include DEVELOPMENT_TEAM from build.xcconfig", () => { const injector = createTestInjector({ logLevel: "INFO", @@ -168,6 +183,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "-packageAuthorizationProvider"); }); + it("passes the DerivedData location only for host projects", () => { + const argsFor = (hostProjectPath?: string) => + createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + hostProjectPath, + }) + .resolve("xcodebuildArgsService") + .getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.notInclude(argsFor(), "-derivedDataPath"); + const hostArgs = argsFor("path/to/host"); + assert.strictEqual( + hostArgs[hostArgs.indexOf("-derivedDataPath") + 1], + path.join(projectRoot, "build", "DerivedData", projectName), + ); + }); + it("keeps the project path as the second argument", () => { process.env.NS_PACKAGE_AUTHORIZATION_PROVIDER = "keychain"; const injector = createTestInjector({ @@ -228,10 +264,7 @@ describe("xcodebuildArgsService", () => { "build", "-configuration", configuration, - "-sdk", - platform === "visionOS" ? "xrsimulator" : "iphonesimulator", ] - .concat(getCommonArgs()) .concat(getBuildLoggingArgs(logLevel)) .concat(getXcodeProjectArgs({ hasProjectWorkspace })); @@ -242,6 +275,73 @@ describe("xcodebuildArgsService", () => { }); }); }); + describe("getBuildForSimulatorArgs with a target simulator", () => { + it("builds for that simulator only, like Xcode does", async () => { + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForSimulatorArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: false, + platform: "iOS", + _device: { deviceInfo: { identifier: "SIM-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "CODE_SIGN_IDENTITY=", + "-destination", + "platform=iOS Simulator,id=SIM-UDID", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + }); + }); + + describe("getBuildForDeviceArgs with a target device", () => { + it("builds for that device, like Xcode's Run", async () => { + let queriedDevices = false; + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + injector.resolve("devicesService").initialize = async () => { + queriedDevices = true; + }; + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForDeviceArgs( + { + projectRoot, + normalizedPlatformName, + getBuildOutputPath: () => buildOutputPath, + }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: true, + platform: "ios", + _device: { deviceInfo: { identifier: "DEVICE-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "-destination", + "platform=iOS,id=DEVICE-UDID", + "build", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + assert.isFalse(queriedDevices); + }); + }); + describe("getBuildForDeviceArgs", () => { const testCases = [ { @@ -250,19 +350,17 @@ describe("xcodebuildArgsService", () => { { deviceInfo: { activeArchitecture: "arm64" } }, { deviceInfo: { activeArchitecture: "armv7" } }, ], - expectedArgs: ["ONLY_ACTIVE_ARCH=NO", "-sdk", "iphoneos"].concat( - getCommonArgs(), - ), + expectedArgs: ["ONLY_ACTIVE_ARCH=NO"], }, { name: "should return correct args when there is only one connected device", connectedDevices: [{ deviceInfo: { activeArchitecture: "arm64" } }], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, { name: "should return correct args when no connected devices", connectedDevices: [], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, ]; @@ -303,9 +401,13 @@ describe("xcodebuildArgsService", () => { const expectedArgs = [ "-destination", "generic/platform=iOS", - "archive", - "-archivePath", - path.join(buildOutputPath, `${projectName}.xcarchive`), + ...(configuration === "Release" + ? [ + "archive", + "-archivePath", + path.join(buildOutputPath, `${projectName}.xcarchive`), + ] + : ["build"]), "-configuration", configuration, "-allowProvisioningUpdates", diff --git a/test/services/ios/xcodebuild-service.ts b/test/services/ios/xcodebuild-service.ts index 611f402c77..543cfbe577 100644 --- a/test/services/ios/xcodebuild-service.ts +++ b/test/services/ios/xcodebuild-service.ts @@ -15,9 +15,21 @@ const exportOptionsPlistOutput = { }; let actualBuildArgs: string[] = []; let actualBuildOptions: IXcodebuildCommandOptions = null; +let spawnedCommands: string[][] = []; function createTestInjector(): IInjector { const injector = new Yok(); + spawnedCommands = []; + injector.register("childProcess", { + spawnFromEvent: async (command: string, args: string[]) => { + spawnedCommands.push([command, ...args]); + }, + }); + injector.register("fs", { + createDirectory: (): void => undefined, + deleteDirectory: (): void => undefined, + deleteFile: (): void => undefined, + }); injector.register("exportOptionsPlistService", { createDevelopmentExportOptionsPlist: async () => exportOptionsPlistOutput, createDistributionExportOptionsPlist: async () => exportOptionsPlistOutput, @@ -25,11 +37,13 @@ function createTestInjector(): IInjector { injector.register("xcodebuildArgsService", { getBuildForDeviceArgs: async () => [], getBuildForSimulatorArgs: async () => [], + shouldArchiveDeviceBuild: (buildConfig: any) => + !!(buildConfig.release || buildConfig.buildForAppStore), }); injector.register("xcodebuildCommandService", { executeCommand: async ( args: string[], - options: IXcodebuildCommandOptions + options: IXcodebuildCommandOptions, ) => { actualBuildArgs = args; actualBuildOptions = options; @@ -43,7 +57,48 @@ function createTestInjector(): IInjector { describe("xcodebuildService", () => { describe("buildForDevice", () => { - it("should build correctly for device", async () => { + it("packages the built .app as an .ipa for debug builds", async () => { + const injector = createTestInjector(); + const xcodebuildService = injector.resolve("xcodebuildService"); + const platformData = { + getBuildOutputPath: () => buildOutputPath, + projectRoot, + }; + const projectData = { projectName }; + + const buildResult = await xcodebuildService.buildForDevice( + platformData, + projectData, + {}, + ); + + const payloadPath = path.join(buildOutputPath, "ipa-staging", "Payload"); + assert.deepStrictEqual(spawnedCommands, [ + [ + "cp", + "-cR", + path.join(buildOutputPath, `${projectName}.app`), + payloadPath, + ], + [ + "ditto", + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + path.join(buildOutputPath, `${projectName}.ipa`), + ], + ]); + assert.deepStrictEqual( + buildResult, + path.join(buildOutputPath, `${projectName}.ipa`), + ); + }); + + it("exports an archive for release builds", async () => { const injector = createTestInjector(); const xcodebuildService = injector.resolve("xcodebuildService"); const platformData = { @@ -55,7 +110,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForDevice( platformData, projectData, - {} + { release: true }, ); const expectedBuildArgs = [ @@ -63,7 +118,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -77,7 +132,7 @@ describe("xcodebuildService", () => { }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); @@ -113,7 +168,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForAppStore( platformData, projectData, - {} + {}, ); const expectedBuildArgs = [ @@ -121,7 +176,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -133,7 +188,7 @@ describe("xcodebuildService", () => { assert.deepStrictEqual(actualBuildOptions, { cwd: projectRoot }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); diff --git a/test/services/livesync/ios-device-livesync-service.ts b/test/services/livesync/ios-device-livesync-service.ts new file mode 100644 index 0000000000..97e03af8f3 --- /dev/null +++ b/test/services/livesync/ios-device-livesync-service.ts @@ -0,0 +1,63 @@ +import * as assert from "assert"; +import { IOSDeviceLiveSyncService } from "../../../lib/services/livesync/ios-device-livesync-service"; + +describe("IOSDeviceLiveSyncService", () => { + const projectData: any = { + projectName: "app", + projectDir: "/app", + projectIdentifiers: { ios: "org.app" }, + }; + + const restart = async (waitForDebugger: boolean) => { + const calls: string[] = []; + const ensureAppStartedValues: boolean[] = []; + const device: any = { + applicationManager: { + restartApplication: async () => { + calls.push("restart"); + }, + }, + getDebugSocket: async ( + _appId: string, + _projectName: string, + _projectDir: string, + ensureAppStarted: boolean + ) => { + calls.push("socket"); + ensureAppStartedValues.push(ensureAppStarted); + return { on: () => {}, removeAllListeners: () => {} }; + }, + }; + const service = new (IOSDeviceLiveSyncService)( + { trace: () => {} }, + {}, + {}, + {}, + {}, + {}, + device + ); + + await service.restartApplication(projectData, { + deviceAppData: { appIdentifier: "org.app" }, + useHotModuleReload: true, + waitForDebugger, + }); + + return { calls, ensureAppStartedValues }; + }; + + it("connects to the app it restarted without starting it again", async () => { + const { calls, ensureAppStartedValues } = await restart(false); + + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); + }); + + it("keeps an instance that waits for a debugger", async () => { + const { calls, ensureAppStartedValues } = await restart(true); + + assert.deepStrictEqual(calls, ["restart", "socket"]); + assert.deepStrictEqual(ensureAppStartedValues, [false]); + }); +});