diff --git a/CHANGELOG.md b/CHANGELOG.md index 16cafb03d2..796e029bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ NativeScript CLI Changelog ================ +5.0.3 (2018, December 4) +== +### Fixed +* [Fixed #4186](https://github.com/NativeScript/nativescript-cli/issues/4186): Fix stuck http requests/responses +* [Fixed #4189](https://github.com/NativeScript/nativescript-cli/pull/4189): API: Fix "Cannot read property 'removeListener' of undefined" error on second stop of livesync to preview app + + +5.0.2 (2018, November 29) +== +### Implemented +* [Implemented #4167](https://github.com/NativeScript/nativescript-cli/pull/4167): API: Expose previewAppLiveSyncError event when some error is thrown while livesyncing to preview app + +### Fixed +* [Fixed #3962](https://github.com/NativeScript/nativescript-cli/issues/3962): If command 'tns plugin create .. ' failed , directory with plugin repository name must be deleted +* [Fixed #4053](https://github.com/NativeScript/nativescript-cli/issues/4053): Update Nativescript cli setup scripts to use android sdk 28 +* [Fixed #4077](https://github.com/NativeScript/nativescript-cli/issues/4077): Platform add with framework path and custom version breaks run with "--bundle" +* [Fixed #4129](https://github.com/NativeScript/nativescript-cli/issues/4129): tns preview doesn't sync changes when download 2 Playground projects +* [Fixed #4135](https://github.com/NativeScript/nativescript-cli/issues/4135): Too many TypeScript "Watching for file changes" messages in console during build +* [Fixed #4158](https://github.com/NativeScript/nativescript-cli/pull/4158): API: reset devices list when stopLiveSync method is called +* [Fixed #4161](https://github.com/NativeScript/nativescript-cli/pull/4161): API: raise deviceLost event after timeout of 5 seconds + 5.0.1 (2018, November 14) == diff --git a/PublicAPI.md b/PublicAPI.md index 068f0963ad..44fa4e8e46 100644 --- a/PublicAPI.md +++ b/PublicAPI.md @@ -37,6 +37,7 @@ const tns = require("nativescript"); * [debug](#debug) * [liveSyncService](#livesyncservice) * [liveSync](#livesync) + * [liveSyncToPreviewApp](#livesynctopreviewapp) * [stopLiveSync](#stopLiveSync) * [enableDebugging](#enableDebugging) * [attachDebugger](#attachDebugger) @@ -59,6 +60,12 @@ const tns = require("nativescript"); * [startEmulator](#startemulator) * [deviceEmitter](#deviceemitter) * [events](#deviceemitterevents) +* [previewDevicesService](#previewdevicesservice) + * [deviceFound](#devicefound) + * [deviceLost](#devicelost) + * [deviceLog](#devicelog) +* [previewQrCodeService](#previewqrcodeservice) + * [getPlaygroundAppQrCode](#getplaygroundappqrcode) ## Module projectService @@ -779,6 +786,33 @@ tns.liveSyncService.liveSync([ androidDeviceDescriptor, iOSDeviceDescriptor ], l }); ``` +### liveSyncToPreviewApp +Starts a LiveSync operation to the Preview app. After scanning the QR code with the scanner provided in the NativeScript Playground app, the app will be launched on a device through the Preview app. Additionally, any changes made to the project will be automatically synchronized with the deployed app. + +* Definition +```TypeScript +/** + * Starts LiveSync operation by producting a QR code and starting watcher. + * @param {IPreviewAppLiveSyncData} liveSyncData Describes the LiveSync operation - for which project directory is the operation and other settings. + * @returns {Promise} + */ +liveSyncToPreviewApp(liveSyncData: IPreviewAppLiveSyncData): Promise; +``` + +* Usage: +```JavaScript +const liveSyncData = { + projectDir, + bundle: false, + useHotModuleReload: false, + env: { } +}; +tns.liveSyncService.liveSyncToPreviewApp(liveSyncData) + .then(qrCodeImageData => { + console.log("The qrCodeImageData is: " + qrCodeImageData); + }); +``` + ### stopLiveSync Stops LiveSync operation. In case deviceIdentifires are passed, the operation will be stopped only for these devices. @@ -1352,6 +1386,50 @@ tns.deviceEmitter.on("emulatorImageLost", (emulatorImageInfo) => { ``` `emulatorImageInfo` is of type [Moble.IDeviceInfo](https://github.com/telerik/mobile-cli-lib/blob/61cdaaaf7533394afbbe84dd4eee355072ade2de/definitions/mobile.d.ts#L9-L86). +## previewDevicesService +The `previewDevicesService` module allows interaction with preview devices. You can get a list of the connected preview devices and logs from specified device. + +### previewDevicesEmitterEvents + +* `deviceFound` - Raised when the QR code is scanned with any device. The callback function will receive one argument - `device`. +Sample usage: +```JavaScript +tns.previewDevicesService.on("deviceFound", device => { + console.log("Attached device with identifier: " + device.id); +}); +``` + +* `deviceLost` - Raised when the Preview app is stopped on a specified device. The callback function will receive one argument - `device`. +Sample usage: +```JavaScript +tns.previewDevicesService.on("deviceLost", device => { + console.log("Detached device with identifier: " + device.id); +}); +``` + +* `deviceLog` - Raised when the app deployed in Preview app reports any information. The event is raised for any device that reports data. The callback function has two arguments - `device` and `message`.

+Sample usage: +```JavaScript +tns.previewDevicesService.on("deviceLogData", (device, message) => { + console.log("Device " + device.id + " reports: " + message); +}); +``` + +## previewQrCodeService +The `previewQrCodeService` exposes methods for getting information about the QR of the Playground app and deployed app in Preview app. + +### getPlaygroundAppQrCode +Returns information used to generate the QR code of the Playground app. + +* Usage: +```TypeScript +tns.previewQrCodeService.getPlaygroundAppQrCode() + .then(result => { + console.log("QR code data for iOS platform: " + result.ios); + console.log("QR code data for Android platform: " + result.android); + }); +``` + ## How to add a new method to Public API CLI is designed as command line tool and when it is used as a library, it does not give you access to all of the methods. This is mainly implementation detail. Most of the CLI's code is created to work in command line, not as a library, so before adding method to public API, most probably it will require some modification. For example the `$options` injected module contains information about all `--` options passed on the terminal. When the CLI is used as a library, the options are not populated. Before adding method to public API, make sure its implementation does not rely on `$options`. diff --git a/lib/commands/plugin/create-plugin.ts b/lib/commands/plugin/create-plugin.ts index 847a3ff1f3..278c024cf1 100644 --- a/lib/commands/plugin/create-plugin.ts +++ b/lib/commands/plugin/create-plugin.ts @@ -5,6 +5,7 @@ export class CreatePluginCommand implements ICommand { public allowedParameters: ICommandParameter[] = []; public userMessage = "What is your GitHub username?\n(will be used to update the Github URLs in the plugin's package.json)"; public nameMessage = "What will be the name of your plugin?\n(use lowercase characters and dashes only)"; + public pathAlreadyExistsMessageTemplate = "Path already exists and is not empty %s"; constructor(private $options: IOptions, private $errors: IErrors, private $terminalSpinnerService: ITerminalSpinnerService, @@ -22,8 +23,17 @@ export class CreatePluginCommand implements ICommand { const selectedPath = path.resolve(pathToProject || "."); const projectDir = path.join(selectedPath, pluginRepoName); - await this.downloadPackage(selectedTemplate, projectDir); - await this.setupSeed(projectDir, pluginRepoName); + // Must be out of try catch block, because will throw error if folder alredy exists and we don't want to delete it. + this.ensurePackageDir(projectDir); + + try { + await this.downloadPackage(selectedTemplate, projectDir); + await this.setupSeed(projectDir, pluginRepoName); + } catch (err) { + // The call to this.ensurePackageDir() above will throw error if folder alredy exists, so it is safe to delete here. + this.$fs.deleteDirectory(projectDir); + throw err; + } this.$logger.printMarkdown("Solution for `%s` was successfully created.", pluginRepoName); } @@ -66,13 +76,15 @@ export class CreatePluginCommand implements ICommand { } } - private async downloadPackage(selectedTemplate: string, projectDir: string): Promise { + private ensurePackageDir(projectDir: string): void { this.$fs.createDirectory(projectDir); if (this.$fs.exists(projectDir) && !this.$fs.isEmptyDir(projectDir)) { - this.$errors.fail("Path already exists and is not empty %s", projectDir); + this.$errors.fail(this.pathAlreadyExistsMessageTemplate, projectDir); } + } + private async downloadPackage(selectedTemplate: string, projectDir: string): Promise { if (selectedTemplate) { this.$logger.printMarkdown("Make sure your custom template is compatible with the Plugin Seed at https://github.com/NativeScript/nativescript-plugin-seed/"); } else { @@ -84,9 +96,6 @@ export class CreatePluginCommand implements ICommand { try { spinner.start(); await this.$pacoteService.extractPackage(packageToInstall, projectDir); - } catch (err) { - this.$fs.deleteDirectory(projectDir); - throw err; } finally { spinner.stop(); } diff --git a/lib/common/declarations.d.ts b/lib/common/declarations.d.ts index 31e16b3c83..39587c2024 100644 --- a/lib/common/declarations.d.ts +++ b/lib/common/declarations.d.ts @@ -163,8 +163,10 @@ declare module Server { interface IRequestResponseData { statusCode: number; headers: { [index: string]: any }; + complete: boolean; pipe(destination: any, options?: { end?: boolean; }): IRequestResponseData; on(event: string, listener: Function): void; + destroy(error?: Error): void; } } @@ -744,7 +746,7 @@ interface IAnalyticsSettingsService { * Gets information for projects that are exported from playground * @param projectDir Project directory path */ - getPlaygroundInfo(projectDir?: string): Promise; + getPlaygroundInfo(projectDir?: string): Promise; } /** diff --git a/lib/common/http-client.ts b/lib/common/http-client.ts index fa191f1922..cb30d342b2 100644 --- a/lib/common/http-client.ts +++ b/lib/common/http-client.ts @@ -7,15 +7,48 @@ import { HttpStatusCodes } from "./constants"; import * as request from "request"; export class HttpClient implements Server.IHttpClient { - private defaultUserAgent: string; private static STATUS_CODE_REGEX = /statuscode=(\d+)/i; + private static STUCK_REQUEST_ERROR_MESSAGE = "The request can't receive any response."; + private static STUCK_RESPONSE_ERROR_MESSAGE = "Can't receive all parts of the response."; + private static STUCK_REQUEST_TIMEOUT = 60000; + // We receive multiple response packets every ms but we don't need to be very aggressive here. + private static STUCK_RESPONSE_CHECK_INTERVAL = 10000; + + private defaultUserAgent: string; + private cleanupData: ICleanupRequestData[]; constructor(private $config: Config.IConfig, private $logger: ILogger, + private $processService: IProcessService, private $proxyService: IProxyService, - private $staticConfig: Config.IStaticConfig) { } + private $staticConfig: Config.IStaticConfig) { + this.cleanupData = []; + this.$processService.attachToProcessExitSignals(this, () => { + this.cleanupData.forEach(d => { + this.cleanupAfterRequest(d); + }); + }); + } + + public async httpRequest(options: any, proxySettings?: IProxySettings): Promise { + try { + const result = await this.httpRequestCore(options, proxySettings); + return result; + } catch (err) { + if (err.message === HttpClient.STUCK_REQUEST_ERROR_MESSAGE || err.message === HttpClient.STUCK_RESPONSE_ERROR_MESSAGE) { + // Retry the request immediately because there are at least 10 seconds between the two requests. + // We have to retry only once the sporadically stuck requests/responses. + // We can add exponential backoff retry here if we decide that we need to workaround bigger network issues on the client side. + this.$logger.warn("%s Retrying request to %s...", err.message, options.url || options); + const retryResult = await this.httpRequestCore(options, proxySettings); + return retryResult; + } - async httpRequest(options: any, proxySettings?: IProxySettings): Promise { + throw err; + } + } + + private async httpRequestCore(options: any, proxySettings?: IProxySettings): Promise { if (_.isString(options)) { options = { url: options, @@ -73,6 +106,10 @@ export class HttpClient implements Server.IHttpClient { const result = new Promise((resolve, reject) => { let timerId: number; + let stuckRequestTimerId: number; + let hasResponse = false; + const cleanupRequestData: ICleanupRequestData = Object.create({ timers: [] }); + this.cleanupData.push(cleanupRequestData); const promiseActions: IPromiseActions = { resolve, @@ -82,8 +119,9 @@ export class HttpClient implements Server.IHttpClient { if (options.timeout) { timerId = setTimeout(() => { - this.setResponseResult(promiseActions, timerId, { err: new Error(`Request to ${unmodifiedOptions.url} timed out.`) }, ); + this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(`Request to ${unmodifiedOptions.url} timed out.`) }); }, options.timeout); + cleanupRequestData.timers.push(timerId); delete options.timeout; } @@ -94,6 +132,16 @@ export class HttpClient implements Server.IHttpClient { this.$logger.trace("httpRequest: %s", util.inspect(options)); const requestObj = request(options); + cleanupRequestData.req = requestObj; + + stuckRequestTimerId = setTimeout(() => { + clearTimeout(stuckRequestTimerId); + stuckRequestTimerId = null; + if (!hasResponse) { + this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(HttpClient.STUCK_REQUEST_ERROR_MESSAGE) }); + } + }, options.timeout || HttpClient.STUCK_REQUEST_TIMEOUT); + cleanupRequestData.timers.push(stuckRequestTimerId); requestObj .on("error", (err: IHttpRequestError) => { @@ -107,15 +155,26 @@ export class HttpClient implements Server.IHttpClient { const errorMessage = this.getErrorMessage(errorMessageStatusCode, null); err.proxyAuthenticationRequired = errorMessageStatusCode === HttpStatusCodes.PROXY_AUTHENTICATION_REQUIRED; err.message = errorMessage || err.message; - this.setResponseResult(promiseActions, timerId, { err }); + this.setResponseResult(promiseActions, cleanupRequestData, { err }); }) .on("response", (response: Server.IRequestResponseData) => { + cleanupRequestData.res = response; + hasResponse = true; + let lastChunkTimestamp = Date.now(); + cleanupRequestData.stuckResponseIntervalId = setInterval(() => { + if (Date.now() - lastChunkTimestamp > HttpClient.STUCK_RESPONSE_CHECK_INTERVAL) { + this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(HttpClient.STUCK_RESPONSE_ERROR_MESSAGE) }); + } + }, HttpClient.STUCK_RESPONSE_CHECK_INTERVAL); const successful = helpers.isRequestSuccessful(response); if (!successful) { pipeTo = undefined; } let responseStream = response; + responseStream.on("data", (chunk: string) => { + lastChunkTimestamp = Date.now(); + }); switch (response.headers["content-encoding"]) { case "gzip": responseStream = responseStream.pipe(zlib.createGunzip()); @@ -128,7 +187,7 @@ export class HttpClient implements Server.IHttpClient { if (pipeTo) { pipeTo.on("finish", () => { this.$logger.trace("httpRequest: Piping done. code = %d", response.statusCode.toString()); - this.setResponseResult(promiseActions, timerId, { response }); + this.setResponseResult(promiseActions, cleanupRequestData, { response }); }); responseStream.pipe(pipeTo); @@ -144,13 +203,13 @@ export class HttpClient implements Server.IHttpClient { const responseBody = data.join(""); if (successful) { - this.setResponseResult(promiseActions, timerId, { body: responseBody, response }); + this.setResponseResult(promiseActions, cleanupRequestData, { body: responseBody, response }); } else { const errorMessage = this.getErrorMessage(response.statusCode, responseBody); const err: any = new Error(errorMessage); err.response = response; err.body = responseBody; - this.setResponseResult(promiseActions, timerId, { err }); + this.setResponseResult(promiseActions, cleanupRequestData, { err }); } }); } @@ -181,16 +240,12 @@ export class HttpClient implements Server.IHttpClient { return response; } - private setResponseResult(result: IPromiseActions, timerId: number, resultData: { response?: Server.IRequestResponseData, body?: string, err?: Error }): void { - if (timerId) { - clearTimeout(timerId); - timerId = null; - } - + private setResponseResult(result: IPromiseActions, cleanupRequestData: ICleanupRequestData, resultData: { response?: Server.IRequestResponseData, body?: string, err?: Error }): void { + this.cleanupAfterRequest(cleanupRequestData); if (!result.isResolved()) { result.isResolved = () => true; - if (resultData.err) { - return result.reject(resultData.err); + if (resultData.err || !resultData.response.complete) { + return result.reject(resultData.err || new Error("Request canceled")); } const finalResult: any = resultData; @@ -258,5 +313,36 @@ export class HttpClient implements Server.IHttpClient { this.$logger.trace("Using proxy: %s", options.proxy); } } + + private cleanupAfterRequest(data: ICleanupRequestData): void { + data.timers.forEach(t => { + if (t) { + clearTimeout(t); + t = null; + } + }); + + if (data.stuckResponseIntervalId) { + clearInterval(data.stuckResponseIntervalId); + data.stuckResponseIntervalId = null; + } + + if (data.req) { + data.req.abort(); + } + + if (data.res) { + data.res.destroy(); + } + } + } + +interface ICleanupRequestData { + timers: number[]; + stuckResponseIntervalId: NodeJS.Timer; + req: request.Request; + res: Server.IRequestResponseData; +} + $injector.register("httpClient", HttpClient); diff --git a/lib/definitions/preview-app-livesync.d.ts b/lib/definitions/preview-app-livesync.d.ts index 18c3b8d877..bf728df915 100644 --- a/lib/definitions/preview-app-livesync.d.ts +++ b/lib/definitions/preview-app-livesync.d.ts @@ -2,7 +2,7 @@ import { FilePayload, Device, FilesPayload } from "nativescript-preview-sdk"; import { EventEmitter } from "events"; declare global { - interface IPreviewAppLiveSyncService { + interface IPreviewAppLiveSyncService extends EventEmitter { initialize(data: IPreviewAppLiveSyncData): void; syncFiles(data: IPreviewAppLiveSyncData, filesToSync: string[], filesToRemove: string[]): Promise; stopLiveSync(): Promise; diff --git a/lib/services/livesync/livesync-service.ts b/lib/services/livesync/livesync-service.ts index 7ee71b5947..822bf28cc1 100644 --- a/lib/services/livesync/livesync-service.ts +++ b/lib/services/livesync/livesync-service.ts @@ -7,6 +7,7 @@ import { PACKAGE_JSON_FILE_NAME, LiveSyncTrackActionNames, USER_INTERACTION_NEED import { DeviceTypes, DeviceDiscoveryEventNames, HmrConstants } from "../../common/constants"; import { cache } from "../../common/decorators"; import * as constants from "../../constants"; +import { PreviewAppLiveSyncEvents } from "./playground/preview-app-constants"; const deviceDescriptorPrimaryKey = "identifier"; @@ -14,6 +15,7 @@ const LiveSyncEvents = { liveSyncStopped: "liveSyncStopped", // In case we name it error, EventEmitter expects instance of Error to be raised and will also raise uncaught exception in case there's no handler liveSyncError: "liveSyncError", + previewAppLiveSyncError: PreviewAppLiveSyncEvents.PREVIEW_APP_LIVE_SYNC_ERROR, liveSyncExecuted: "liveSyncExecuted", liveSyncStarted: "liveSyncStarted", liveSyncNotification: "notify" @@ -54,6 +56,10 @@ export class LiveSyncService extends EventEmitter implements IDebugLiveSyncServi } public async liveSyncToPreviewApp(data: IPreviewAppLiveSyncData): Promise { + this.$previewAppLiveSyncService.on(LiveSyncEvents.previewAppLiveSyncError, liveSyncData => { + this.emit(LiveSyncEvents.previewAppLiveSyncError, liveSyncData); + }); + await this.liveSync([], { syncToPreviewApp: true, projectDir: data.projectDir, @@ -102,6 +108,7 @@ export class LiveSyncService extends EventEmitter implements IDebugLiveSyncServi if (liveSyncProcessInfo.syncToPreviewApp) { await this.$previewAppLiveSyncService.stopLiveSync(); + this.$previewAppLiveSyncService.removeAllListeners(); } // Kill typescript watcher diff --git a/lib/services/livesync/playground/devices/preview-devices-service.ts b/lib/services/livesync/playground/devices/preview-devices-service.ts index 93429b789e..66f674572e 100644 --- a/lib/services/livesync/playground/devices/preview-devices-service.ts +++ b/lib/services/livesync/playground/devices/preview-devices-service.ts @@ -4,6 +4,7 @@ import { DeviceDiscoveryEventNames, DEVICE_LOG_EVENT_NAME } from "../../../../co export class PreviewDevicesService extends EventEmitter implements IPreviewDevicesService { private connectedDevices: Device[] = []; + private deviceLostTimers: IDictionary = {}; constructor(private $previewAppLogProvider: IPreviewAppLogProvider, private $previewAppPluginsService: IPreviewAppPluginsService) { @@ -23,7 +24,7 @@ export class PreviewDevicesService extends EventEmitter implements IPreviewDevic _(this.connectedDevices) .reject(d => _.find(devices, device => d.id === device.id)) - .each(device => this.raiseDeviceLost(device)); + .each(device => this.raiseDeviceLostAfterTimeout(device)); } public getDeviceById(id: string): Device { @@ -45,6 +46,11 @@ export class PreviewDevicesService extends EventEmitter implements IPreviewDevic } private raiseDeviceFound(device: Device) { + if (this.deviceLostTimers[device.id]) { + clearTimeout(this.deviceLostTimers[device.id]); + this.deviceLostTimers[device.id] = null; + } + this.emit(DeviceDiscoveryEventNames.DEVICE_FOUND, device); this.connectedDevices.push(device); } @@ -53,5 +59,15 @@ export class PreviewDevicesService extends EventEmitter implements IPreviewDevic this.emit(DeviceDiscoveryEventNames.DEVICE_LOST, device); _.remove(this.connectedDevices, d => d.id === device.id); } + + private raiseDeviceLostAfterTimeout(device: Device) { + if (!this.deviceLostTimers[device.id]) { + const timeoutId = setTimeout(() => { + this.raiseDeviceLost(device); + clearTimeout(timeoutId); + }, 5 * 1000); + this.deviceLostTimers[device.id] = timeoutId; + } + } } $injector.register("previewDevicesService", PreviewDevicesService); diff --git a/lib/services/livesync/playground/preview-app-constants.ts b/lib/services/livesync/playground/preview-app-constants.ts index 49afc03626..fd8ac000e6 100644 --- a/lib/services/livesync/playground/preview-app-constants.ts +++ b/lib/services/livesync/playground/preview-app-constants.ts @@ -18,3 +18,7 @@ export class PluginComparisonMessages { public static LOCAL_PLUGIN_WITH_DIFFERENCE_IN_MAJOR_VERSION = "Local plugin %s differs in major version from plugin in preview app. The local plugin has version %s and the plugin in preview app has version %s. Some features might not work as expected."; public static LOCAL_PLUGIN_WITH_GREATHER_MINOR_VERSION = "Local plugin %s differs in minor version from plugin in preview app. The local plugin has version %s and the plugin in preview app has version %s. Some features might not work as expected."; } + +export class PreviewAppLiveSyncEvents { + public static PREVIEW_APP_LIVE_SYNC_ERROR = "previewAppLiveSyncError"; +} diff --git a/lib/services/livesync/playground/preview-app-livesync-service.ts b/lib/services/livesync/playground/preview-app-livesync-service.ts index d13b5a926c..ed0780cc75 100644 --- a/lib/services/livesync/playground/preview-app-livesync-service.ts +++ b/lib/services/livesync/playground/preview-app-livesync-service.ts @@ -1,10 +1,12 @@ import * as path from "path"; import { Device, FilesPayload } from "nativescript-preview-sdk"; import { APP_RESOURCES_FOLDER_NAME, APP_FOLDER_NAME } from "../../../constants"; +import { PreviewAppLiveSyncEvents } from "./preview-app-constants"; import { HmrConstants } from "../../../common/constants"; import { stringify } from "../../../common/helpers"; +import { EventEmitter } from "events"; -export class PreviewAppLiveSyncService implements IPreviewAppLiveSyncService { +export class PreviewAppLiveSyncService extends EventEmitter implements IPreviewAppLiveSyncService { private deviceInitializationPromise: IDictionary> = {}; @@ -18,8 +20,9 @@ export class PreviewAppLiveSyncService implements IPreviewAppLiveSyncService { private $previewAppFilesService: IPreviewAppFilesService, private $previewAppPluginsService: IPreviewAppPluginsService, private $previewDevicesService: IPreviewDevicesService, - private $hmrStatusService: IHmrStatusService, - ) { } + private $hmrStatusService: IHmrStatusService) { + super(); + } public async initialize(data: IPreviewAppLiveSyncData): Promise { await this.$previewSdkService.initialize(async (device: Device) => { @@ -61,6 +64,7 @@ export class PreviewAppLiveSyncService implements IPreviewAppLiveSyncService { public async stopLiveSync(): Promise { this.$previewSdkService.stop(); + this.$previewDevicesService.updateConnectedDevices([]); } private async getInitialFilesForDevice(data: IPreviewAppLiveSyncData, device: Device): Promise { @@ -115,8 +119,14 @@ export class PreviewAppLiveSyncService implements IPreviewAppLiveSyncService { await this.$previewSdkService.applyChanges(payloads); this.$logger.info(`Successfully synced ${payloads.files.map(filePayload => filePayload.file.yellow)} for platform ${platform}.`); } - } catch (err) { - this.$logger.warn(`Unable to apply changes for platform ${platform}. Error is: ${err}, ${stringify(err)}.`); + } catch (error) { + this.$logger.warn(`Unable to apply changes for platform ${platform}. Error is: ${error}, ${JSON.stringify(error, null, 2)}.`); + this.emit(PreviewAppLiveSyncEvents.PREVIEW_APP_LIVE_SYNC_ERROR, { + error, + data, + platform, + deviceId + }); } } diff --git a/lib/services/project-changes-service.ts b/lib/services/project-changes-service.ts index ac3be05546..5ee4b39c08 100644 --- a/lib/services/project-changes-service.ts +++ b/lib/services/project-changes-service.ts @@ -171,7 +171,7 @@ export class ProjectChangesService implements IProjectChangesService { public setNativePlatformStatus(platform: string, projectData: IProjectData, addedPlatform: IAddedNativePlatform): void { this._prepareInfo = this._prepareInfo || this.getPrepareInfo(platform, projectData); - if (this._prepareInfo) { + if (this._prepareInfo && addedPlatform.nativePlatformStatus === NativePlatformStatus.alreadyPrepared) { this._prepareInfo.nativePlatformStatus = addedPlatform.nativePlatformStatus; } else { this._prepareInfo = { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 45b3e8c7ef..234cae828e 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -521,9 +521,9 @@ "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=" }, "ast-types": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.11.6.tgz", - "integrity": "sha512-nHiuV14upVGl7MWwFUYbzJ6YlfwWS084CU9EA8HajfYQjMSli5TQi3UTRygGF58LFWVkXxS1rbgRhROEqlQkXg==" + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.11.7.tgz", + "integrity": "sha512-2mP3TwtkY/aTv5X3ZsMpNAbOnyoC/aMJwJSoaELPkHId0nSQgFcnU4dRW3isxiz7+zBexk0ym3WNVjMiQBnJSw==" }, "async": { "version": "1.2.1", @@ -2932,7 +2932,7 @@ }, "string_decoder": { "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" } } @@ -5250,9 +5250,9 @@ } }, "nativescript-preview-sdk": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/nativescript-preview-sdk/-/nativescript-preview-sdk-0.3.0.tgz", - "integrity": "sha512-HZFT/eT/4HQcZ7Y20zrwpnEXf3OvOSL8o75A+faKBAox34Y/rfGsZQd/flDfJWrNd79OwQXuKSCV8vn9hJD9Ng==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/nativescript-preview-sdk/-/nativescript-preview-sdk-0.3.1.tgz", + "integrity": "sha512-8xKl150/LJk2YvmbDgCKJHOd1pFwYf/2s5PtXXF/D0fw6DpwGgFNBe26IaTHNiZJ2U0w9n4o2L9YSrHRk7kjqQ==", "requires": { "@types/axios": "0.14.0", "@types/pubnub": "4.0.2", diff --git a/package.json b/package.json index b9edca6b2e..bfb1490ec2 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "mkdirp": "0.5.1", "mute-stream": "0.0.5", "nativescript-doctor": "1.8.1", - "nativescript-preview-sdk": "0.3.0", + "nativescript-preview-sdk": "0.3.1", "open": "0.0.5", "ora": "2.0.0", "osenv": "0.1.3", diff --git a/test/plugin-create.ts b/test/plugin-create.ts index f1850bb9fa..60f037006d 100644 --- a/test/plugin-create.ts +++ b/test/plugin-create.ts @@ -3,6 +3,11 @@ import * as stubs from "./stubs"; import { CreatePluginCommand } from "../lib/commands/plugin/create-plugin"; import { assert } from "chai"; import helpers = require("../lib/common/helpers"); +import * as sinon from "sinon"; +import temp = require("temp"); +import * as path from "path"; +import * as util from "util"; +temp.track(); interface IPacoteOutput { packageName: string; @@ -10,7 +15,8 @@ interface IPacoteOutput { } const originalIsInteractive = helpers.isInteractive; -const dummyArgs = ["dummyProjectName"]; +const dummyProjectName = "dummyProjectName"; +const dummyArgs = [dummyProjectName]; const dummyUser = "devUsername"; const dummyName = "devPlugin"; const dummyPacote: IPacoteOutput = { packageName: "", destinationDirectory: "" }; @@ -142,5 +148,62 @@ describe("Plugin create command tests", () => { options.pluginName = dummyName; await createPluginCommand.execute(dummyArgs); }); + + describe("when fails", () => { + let sandbox: sinon.SinonSandbox; + let fsSpy: sinon.SinonSpy; + let projectPath: string; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + const workingPath = temp.mkdirSync("test_plugin"); + options.path = workingPath; + projectPath = path.join(workingPath, dummyProjectName); + const fsService = testInjector.resolve("fs"); + fsSpy = sandbox.spy(fsService, "deleteDirectory"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("downloadPackage, should remove projectDir", async () => { + const errorMessage = "Test fail"; + const pacoteService = testInjector.resolve("pacoteService"); + sandbox.stub(pacoteService, "extractPackage").callsFake(() => { + return Promise.reject(new Error(errorMessage)); + }); + + const executePromise = createPluginCommand.execute(dummyArgs); + + await assert.isRejected(executePromise, errorMessage); + assert(fsSpy.calledWith(projectPath)); + }); + + it("setupSeed, should remove projectDir", async () => { + const errorMessage = "Test fail"; + const packageManagerService = testInjector.resolve("packageManager"); + sandbox.stub(packageManagerService, "install").callsFake(() => { + return Promise.reject(new Error(errorMessage)); + }); + + const executePromise = createPluginCommand.execute(dummyArgs); + + await assert.isRejected(executePromise, errorMessage); + assert(fsSpy.calledWith(projectPath)); + }); + + it("ensurePachageDir should not remove projectDir", async () => { + const fsService = testInjector.resolve("fs"); + sandbox.stub(fsService, "isEmptyDir").callsFake(() => { + return false; + }); + + const executePromise = createPluginCommand.execute(dummyArgs); + + await assert.isRejected(executePromise, util.format(createPluginCommand.pathAlreadyExistsMessageTemplate, projectPath)); + assert(fsSpy.notCalled); + }); + }); }); }); diff --git a/test/project-changes-service.ts b/test/project-changes-service.ts index 47eae49639..b42b5d318e 100644 --- a/test/project-changes-service.ts +++ b/test/project-changes-service.ts @@ -187,5 +187,51 @@ describe("Project Changes Service Tests", () => { assert.deepEqual(actualPrepareInfo, { nativePlatformStatus: Constants.NativePlatformStatus.requiresPrepare }); } }); + + it(`shouldn't reset prepare info when native platform status is ${Constants.NativePlatformStatus.alreadyPrepared} and there is existing prepare info`, async () => { + for (const platform of ["ios", "android"]) { + await serviceTest.projectChangesService.checkForChanges({ + platform, + projectData: serviceTest.projectData, + projectChangesOptions: { + bundle: false, + release: false, + provision: undefined, + teamId: undefined, + useHotModuleReload: false + } + }); + serviceTest.projectChangesService.savePrepareInfo(platform, serviceTest.projectData); + const prepareInfo = serviceTest.projectChangesService.getPrepareInfo(platform, serviceTest.projectData); + + serviceTest.projectChangesService.setNativePlatformStatus(platform, serviceTest.projectData, { nativePlatformStatus: Constants.NativePlatformStatus.alreadyPrepared }); + + const actualPrepareInfo = serviceTest.projectChangesService.getPrepareInfo(platform, serviceTest.projectData); + prepareInfo.nativePlatformStatus = Constants.NativePlatformStatus.alreadyPrepared; + assert.deepEqual(actualPrepareInfo, prepareInfo); + } + }); + + _.each([Constants.NativePlatformStatus.requiresPlatformAdd, Constants.NativePlatformStatus.requiresPrepare], nativePlatformStatus => { + it(`should reset prepare info when native platform status is ${nativePlatformStatus} and there is existing prepare info`, async () => { + for (const platform of ["ios", "android"]) { + await serviceTest.projectChangesService.checkForChanges({ + platform, + projectData: serviceTest.projectData, + projectChangesOptions: { + bundle: false, + release: false, + provision: undefined, + teamId: undefined, + useHotModuleReload: false + } + }); + serviceTest.projectChangesService.setNativePlatformStatus(platform, serviceTest.projectData, { nativePlatformStatus: nativePlatformStatus }); + + const actualPrepareInfo = serviceTest.projectChangesService.getPrepareInfo(platform, serviceTest.projectData); + assert.deepEqual(actualPrepareInfo, { nativePlatformStatus: nativePlatformStatus }); + } + }); + }); }); }); diff --git a/test/services/preview-devices-service.ts b/test/services/preview-devices-service.ts index ccc9339a66..6ae78312b4 100644 --- a/test/services/preview-devices-service.ts +++ b/test/services/preview-devices-service.ts @@ -4,6 +4,7 @@ import { Device } from "nativescript-preview-sdk"; import { assert } from "chai"; import { DeviceDiscoveryEventNames } from "../../lib/common/constants"; import { LoggerStub, ErrorsStub } from "../stubs"; +import * as sinon from "sinon"; let foundDevices: Device[] = []; let lostDevices: Device[] = []; @@ -38,8 +39,9 @@ function resetDevices() { } describe("PreviewDevicesService", () => { - describe("onDevicesPresence", () => { + describe("getConnectedDevices", () => { let previewDevicesService: IPreviewDevicesService = null; + let clock: sinon.SinonFakeTimers = null; beforeEach(() => { const injector = createTestInjector(); previewDevicesService = injector.resolve("previewDevicesService"); @@ -49,11 +51,13 @@ describe("PreviewDevicesService", () => { previewDevicesService.on(DeviceDiscoveryEventNames.DEVICE_LOST, device => { lostDevices.push(device); }); + clock = sinon.useFakeTimers(); }); afterEach(() => { previewDevicesService.removeAllListeners(); resetDevices(); + clock.restore(); }); it("should add new device", () => { @@ -101,6 +105,7 @@ describe("PreviewDevicesService", () => { resetDevices(); previewDevicesService.updateConnectedDevices([]); + clock.tick(5000); assert.deepEqual(foundDevices, []); assert.deepEqual(lostDevices, [device1]); @@ -116,10 +121,30 @@ describe("PreviewDevicesService", () => { resetDevices(); previewDevicesService.updateConnectedDevices([device2]); + clock.tick(5000); assert.deepEqual(previewDevicesService.getConnectedDevices(), [device2]); assert.deepEqual(foundDevices, [device2]); assert.deepEqual(lostDevices, [device1]); }); + it("shouldn't emit deviceFound or deviceLost when preview app is restarted on device", () => { + const device1 = createDevice("device1"); + + previewDevicesService.updateConnectedDevices([device1]); + + assert.deepEqual(previewDevicesService.getConnectedDevices(), [device1]); + assert.deepEqual(foundDevices, [device1]); + assert.deepEqual(lostDevices, []); + resetDevices(); + + // preview app is restarted + previewDevicesService.updateConnectedDevices([]); + clock.tick(500); + previewDevicesService.updateConnectedDevices([device1]); + + assert.deepEqual(foundDevices, []); + assert.deepEqual(lostDevices, []); + assert.deepEqual(previewDevicesService.getConnectedDevices(), [device1]); + }); }); });