From e55ea0b629f77e89be6834691eb10f9d5c42e9df Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Fri, 31 Oct 2025 16:52:49 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20preferred=20clients=20se?= =?UTF-8?q?ction=20on=20settings=20page=20(#3034)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 preferred clients section on settings page * chore: 🤖 update mock ipc with new handlers * refactor: 💡 moved windows rdp protocol to const * refactor: 💡 moved rdp calls to app route * test: 💍 fixed failings tests * refactor: 💡 addressed comments * fix: 🐛 fixed failing tests * refactor: 💡 add mock rdp service calls to tests * refactor: 💡 addressed comments * test: 💍 add additional tests * refactor: 💡 fixed test failure --- addons/api/mirage/scenarios/ipc.js | 14 +++ addons/core/translations/en-us.yaml | 15 +++ .../settings-card/preferred-clients/index.hbs | 78 ++++++++++++++++ .../settings-card/preferred-clients/index.js | 50 ++++++++++ ui/desktop/app/routes/application.js | 4 + ui/desktop/app/services/rdp.js | 65 +++++++++++-- .../scopes/scope/projects/settings/index.hbs | 3 +- ui/desktop/electron-app/src/index.js | 8 +- .../src/services/rdp-client-manager.js | 19 ++-- .../tests/acceptance/application-test.js | 4 + .../tests/acceptance/cluster-url-test.js | 4 + .../projects/sessions/index-test.js | 5 + .../projects/sessions/session-test.js | 5 + .../projects/settings/index-test.js | 65 +++++++++++++ .../acceptance/projects/targets/index-test.js | 5 + .../projects/targets/target-test.js | 20 ++-- ui/desktop/tests/acceptance/scopes-test.js | 5 + .../settings-card/preferred-clients-test.js | 91 +++++++++++++++++++ ui/desktop/tests/unit/services/rdp-test.js | 58 ++++++++++-- 19 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 ui/desktop/app/components/settings-card/preferred-clients/index.hbs create mode 100644 ui/desktop/app/components/settings-card/preferred-clients/index.js create mode 100644 ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index 5b34bc5b7..0339764c8 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -248,6 +248,20 @@ export default function initializeMockIPC(server, config) { resumeClientAgent() {} hasRunningSessions() {} stopAll() {} + getRdpClients() { + return ['windows-app', 'none', 'mstsc']; + } + getPreferredRdpClient() { + return 'windows-app'; + } + getRecommendedRdpClient() { + return { + name: 'windows-app', + link: 'https://apps.apple.com/us/app/windows-app/id1295203466', + }; + } + setPreferredRdpClient() {} + launchRdpClient() {} } /** diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index a60074f45..a0c2a5a10 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -142,6 +142,21 @@ settings: alerts: cache-daemon: There may be a problem with the cache daemon client-agent: There may be a problem with the client agent + preferred-clients: + title: Preferred Clients + description: Select the applications for Boundary to use when opening these target types. + table: + header: + protocol: Protocol + client: Client + data: + protocols: + windows-rdp: Windows RDP + clients: + none: None + mstsc: Remote Desktop Connection (mstsc) + windows-app: Windows App + none-detected: 'None detected. We recommend {rdpClientName}.' worker-filter-generator: title: Filter generator description: Choose what you want to format into a filter. diff --git a/ui/desktop/app/components/settings-card/preferred-clients/index.hbs b/ui/desktop/app/components/settings-card/preferred-clients/index.hbs new file mode 100644 index 000000000..a9c6a7afa --- /dev/null +++ b/ui/desktop/app/components/settings-card/preferred-clients/index.hbs @@ -0,0 +1,78 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:body> + + <:body as |B|> + + {{t + (concat + 'settings.preferred-clients.table.data.protocols.' + B.data.protocolType + ) + }} + + + {{#if this.showRecommendedRdpClient}} + + {{t + 'settings.preferred-clients.table.data.clients.none-detected' + rdpClientLink=this.rdp.recommendedRdpClient.link + rdpClientName=(t + (concat + 'settings.preferred-clients.table.data.clients.' + this.rdp.recommendedRdpClient.name + ) + ) + htmlSafe=true + }} + + {{else}} + + + {{#each B.data.clients as |client|}} + + {{/each}} + + + {{/if}} + + + + + + \ No newline at end of file diff --git a/ui/desktop/app/components/settings-card/preferred-clients/index.js b/ui/desktop/app/components/settings-card/preferred-clients/index.js new file mode 100644 index 000000000..bf42849f9 --- /dev/null +++ b/ui/desktop/app/components/settings-card/preferred-clients/index.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; + +const PROTOCOL_WINDOWS_RDP = 'windows-rdp'; + +export default class SettingsCardPreferredClientsComponent extends Component { + // =services + @service rdp; + + // =getters + + /** + * Returns the list of protocols and their available clients + * @returns {Array} + */ + get protocolClients() { + return [ + { + protocolType: PROTOCOL_WINDOWS_RDP, + clients: this.rdp.rdpClients, + preferredClient: this.rdp.preferredRdpClient, + updateClient: this.updatePreferredRDPClient, + }, + ]; + } + + get showRecommendedRdpClient() { + return ( + this.rdp.rdpClients.length === 1 && this.rdp.rdpClients[0] === 'none' + ); + } + + // =methods + + /** + * Updates the preferred RDP client + * @param value + * @return {Promise} + */ + @action + async updatePreferredRDPClient({ target: { value } }) { + await this.rdp.setPreferredRdpClient(value); + } +} diff --git a/ui/desktop/app/routes/application.js b/ui/desktop/app/routes/application.js index 6a1d9cfb6..f64c43191 100644 --- a/ui/desktop/app/routes/application.js +++ b/ui/desktop/app/routes/application.js @@ -16,6 +16,7 @@ export default class ApplicationRoute extends Route { @service clusterUrl; @service ipc; @service intl; + @service rdp; // =attributes @@ -52,6 +53,9 @@ export default class ApplicationRoute extends Route { await this.session.loadAuthenticatedAccount(); } + + // initialize RDP service with rdp client data + await this.rdp.initialize(); } /** diff --git a/ui/desktop/app/services/rdp.js b/ui/desktop/app/services/rdp.js index 8b11a0b59..09d037199 100644 --- a/ui/desktop/app/services/rdp.js +++ b/ui/desktop/app/services/rdp.js @@ -7,7 +7,23 @@ import Service from '@ember/service'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +export const RDP_CLIENT_NONE = 'none'; +export const RDP_CLIENT_WINDOWS_APP = 'windows-app'; +export const RDP_CLIENT_MSTSC = 'mstsc'; +export const RDP_CLIENT_WINDOWS_APP_LINK = + 'https://apps.apple.com/us/app/windows-app/id1295203466'; +export const RDP_CLIENT_MSTSC_LINK = + 'https://learn.microsoft.com/windows-server/remote/remote-desktop-services/remotepc/uninstall-remote-desktop-connection'; + const { __electronLog } = globalThis; +const macRecommendedRdpClient = { + name: RDP_CLIENT_WINDOWS_APP, + link: RDP_CLIENT_WINDOWS_APP_LINK, +}; +const windowsRecommendedRdpClient = { + name: RDP_CLIENT_MSTSC, + link: RDP_CLIENT_MSTSC_LINK, +}; export default class RdpService extends Service { // =services @@ -19,17 +35,21 @@ export default class RdpService extends Service { /** * The preferred RDP client set by the user. * @type {string|null} - * @private */ @tracked preferredRdpClient = null; /** * The list of available RDP clients fetched from the main process. * @type {Array} - * @private */ @tracked rdpClients = []; + /** + * The recommended RDP client based on platform when only 'none' is available. + * @type {Object|null} + */ + @tracked recommendedRdpClient = null; + // =attributes /** @@ -40,12 +60,21 @@ export default class RdpService extends Service { */ get isPreferredRdpClientSet() { return ( - this.preferredRdpClient !== null && this.preferredRdpClient !== 'none' + this.preferredRdpClient !== null && + this.preferredRdpClient !== RDP_CLIENT_NONE ); } // =methods + async initialize() { + await Promise.all([ + this.getRdpClients(), + this.getPreferredRdpClient(), + this.getRecommendedRdpClient(), + ]); + } + /** * Fetches the list of available RDP clients from the main process. */ @@ -60,11 +89,35 @@ export default class RdpService extends Service { } catch (error) { __electronLog?.error('Failed to fetch RDP clients', error.message); // default to 'none' option if it fails - this.rdpClients = ['none']; + this.rdpClients = [RDP_CLIENT_NONE]; return this.rdpClients; } } + /** + * Gets the recommended RDP client based on OS platform. + */ + async getRecommendedRdpClient() { + try { + // return cached recommended client if already set + if (this.recommendedRdpClient !== null) { + return this.recommendedRdpClient; + } + const { isWindows, isMac } = await this.ipc.invoke('checkOS'); + if (isWindows) { + this.recommendedRdpClient = windowsRecommendedRdpClient; + } else if (isMac) { + this.recommendedRdpClient = macRecommendedRdpClient; + } + } catch (error) { + __electronLog?.error( + 'Failed to set recommended RDP client', + error.message, + ); + this.recommendedRdpClient = null; + } + } + /** * Fetches the preferred RDP client from the main process. * @returns {string} The preferred RDP client @@ -83,7 +136,7 @@ export default class RdpService extends Service { error.message, ); // default to 'none' if it fails - this.preferredRdpClient = 'none'; + this.preferredRdpClient = RDP_CLIENT_NONE; return this.preferredRdpClient; } } @@ -101,7 +154,7 @@ export default class RdpService extends Service { } catch (error) { __electronLog?.error('Failed to set preferred RDP client', error.message); // set to 'none' if it fails - this.preferredRdpClient = 'none'; + this.preferredRdpClient = RDP_CLIENT_NONE; } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs index 53b9c073b..1ef0e799d 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs @@ -7,4 +7,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 7222a96bb..a6016d28b 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -164,13 +164,17 @@ const createWindow = async (partition, closeWindowCB) => { // Opens external links in the host default browser. // We allow developer.hashicorp.com domain to open on an external window // and releases.hashicorp.com domain to download the desktop app or - // link to the release page for the desktop app. + // link to the release page for the desktop app or + // apps.apple.com domain to open the app store page to download the Windows App or + // learn.microsoft.com domain to open the documentation for installing Remote Desktop Connection. browserWindow.webContents.setWindowOpenHandler(({ url }) => { if ( isLocalhost(url) || url.startsWith('https://developer.hashicorp.com/') || url.startsWith('https://releases.hashicorp.com/boundary-desktop/') || - url.startsWith('https://support.hashicorp.com/hc/en-us') + url.startsWith('https://support.hashicorp.com/hc/en-us') || + url.startsWith('https://learn.microsoft.com/') || + url.startsWith('https://apps.apple.com/') ) { shell.openExternal(url); } diff --git a/ui/desktop/electron-app/src/services/rdp-client-manager.js b/ui/desktop/electron-app/src/services/rdp-client-manager.js index 41b027e43..94f256711 100644 --- a/ui/desktop/electron-app/src/services/rdp-client-manager.js +++ b/ui/desktop/electron-app/src/services/rdp-client-manager.js @@ -9,10 +9,15 @@ const which = require('which'); const { isMac, isWindows } = require('../helpers/platform.js'); const store = require('./electron-store-manager'); +// RDP Clients +const RDP_CLIENT_NONE = 'none'; +const RDP_CLIENT_WINDOWS_APP = 'windows-app'; +const RDP_CLIENT_MSTSC = 'mstsc'; + // RDP Client Configuration const RDP_CLIENTS = [ { - value: 'mstsc', + value: RDP_CLIENT_MSTSC, isAvailable: async () => { if (!isWindows()) return false; try { @@ -24,7 +29,7 @@ const RDP_CLIENTS = [ }, }, { - value: 'windows-app', + value: RDP_CLIENT_WINDOWS_APP, isAvailable: () => { if (!isMac()) return false; try { @@ -50,7 +55,7 @@ const RDP_CLIENTS = [ }, }, { - value: 'none', + value: RDP_CLIENT_NONE, isAvailable: () => true, }, ]; @@ -78,8 +83,10 @@ class RdpClientManager { */ async getBestDefaultRdpClient() { const availableClients = await this.getAvailableRdpClients(); - const bestClient = availableClients.find((client) => client !== 'none'); - return bestClient ?? 'none'; + const bestClient = availableClients.find( + (client) => client !== RDP_CLIENT_NONE, + ); + return bestClient ?? RDP_CLIENT_NONE; } /** @@ -103,7 +110,7 @@ class RdpClientManager { */ setPreferredRdpClient(preferredClient) { if (!preferredClient) { - store.set('preferredRdpClient', 'none'); + store.set('preferredRdpClient', RDP_CLIENT_NONE); } else { store.set('preferredRdpClient', preferredClient); } diff --git a/ui/desktop/tests/acceptance/application-test.js b/ui/desktop/tests/acceptance/application-test.js index 7dc6ae47a..92828fbe1 100644 --- a/ui/desktop/tests/acceptance/application-test.js +++ b/ui/desktop/tests/acceptance/application-test.js @@ -34,6 +34,10 @@ module('Acceptance | Application', function (hooks) { const ipcService = this.owner.lookup('service:ipc'); stubs.ipcService = sinon.stub(ipcService, 'invoke'); + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); hooks.afterEach(function () { diff --git a/ui/desktop/tests/acceptance/cluster-url-test.js b/ui/desktop/tests/acceptance/cluster-url-test.js index dd57cd56b..f6d0e358c 100644 --- a/ui/desktop/tests/acceptance/cluster-url-test.js +++ b/ui/desktop/tests/acceptance/cluster-url-test.js @@ -94,6 +94,10 @@ module('Acceptance | clusterUrl', function (hooks) { urls.authenticate.methods.global = `${urls.authenticate.global}/${instances.authMethods.global.id}`; urls.projects = `${urls.scopes.global}/projects`; urls.targets = `${urls.projects}/targets`; + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); hooks.afterEach(function () { diff --git a/ui/desktop/tests/acceptance/projects/sessions/index-test.js b/ui/desktop/tests/acceptance/projects/sessions/index-test.js index ffa21f3f8..8524f0856 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/index-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/index-test.js @@ -20,6 +20,7 @@ import { } from 'api/models/session'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import sinon from 'sinon'; module('Acceptance | projects | sessions | index', function (hooks) { setupApplicationTest(hooks); @@ -147,6 +148,10 @@ module('Acceptance | projects | sessions | index', function (hooks) { this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); this.stubCacheDaemonSearch('sessions', 'sessions', 'targets'); + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js index 0e40cbb5c..61e43a90c 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js @@ -13,6 +13,7 @@ import WindowMockIPC from '../../../helpers/window-mock-ipc'; import { STATUS_SESSION_ACTIVE } from 'api/models/session'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import sinon from 'sinon'; module('Acceptance | projects | sessions | session', function (hooks) { setupApplicationTest(hooks); @@ -136,6 +137,10 @@ module('Acceptance | projects | sessions | session', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isCacheDaemonRunning').returns(false); + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); hooks.afterEach(function () { diff --git a/ui/desktop/tests/acceptance/projects/settings/index-test.js b/ui/desktop/tests/acceptance/projects/settings/index-test.js index 648c93125..03405e543 100644 --- a/ui/desktop/tests/acceptance/projects/settings/index-test.js +++ b/ui/desktop/tests/acceptance/projects/settings/index-test.js @@ -19,6 +19,12 @@ import { import WindowMockIPC from '../../../helpers/window-mock-ipc'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { + RDP_CLIENT_MSTSC_LINK, + RDP_CLIENT_MSTSC, + RDP_CLIENT_NONE, + RDP_CLIENT_WINDOWS_APP, +} from 'desktop/services/rdp'; module('Acceptance | projects | settings | index', function (hooks) { setupApplicationTest(hooks); @@ -58,6 +64,9 @@ module('Acceptance | projects | settings | index', function (hooks) { const SIGNOUT_BTN = '[data-test-settings-signout-btn]'; const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]'; const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary'; + const RDP_PREFERRED_CLIENT = '[data-test-select-preferred-rdp-client]'; + const RDP_RECOMMENDED_CLIENT = '[data-test-recommended-rdp-client]'; + const RDP_RECOMMENDED_CLIENT_LINK = '[data-test-recommended-rdp-client] a'; const setDefaultClusterUrl = (test) => { const windowOrigin = window.location.origin; @@ -98,6 +107,13 @@ module('Acceptance | projects | settings | index', function (hooks) { this.ipcStub .withArgs('cacheDaemonStatus') .returns({ version: 'Boundary CLI v0.1.0' }); + + // mock RDP client data + this.ipcStub + .withArgs('getRdpClients') + .returns([RDP_CLIENT_MSTSC, RDP_CLIENT_NONE]); + this.ipcStub.withArgs('getPreferredRdpClient').returns(RDP_CLIENT_MSTSC); + this.ipcStub.withArgs('checkOS').returns({ isWindows: true, isMac: false }); }); test('can navigate to the settings page', async function (assert) { @@ -226,4 +242,53 @@ module('Acceptance | projects | settings | index', function (hooks) { assert.ok(stopAllSessions.calledOnce); assert.notOk(currentSession().isAuthenticated); }); + + test('preferred RDP client is selected correctly for Windows', async function (assert) { + await visit(urls.settings); + + assert.dom(RDP_PREFERRED_CLIENT).isVisible().hasValue(RDP_CLIENT_MSTSC); + }); + + test('preferred RDP client is selected correctly for Mac', async function (assert) { + // update IPC stub fo mac + this.ipcStub.withArgs('checkOS').returns({ isWindows: false, isMac: true }); + this.ipcStub + .withArgs('getRdpClients') + .returns([RDP_CLIENT_WINDOWS_APP, RDP_CLIENT_NONE]); + this.ipcStub + .withArgs('getPreferredRdpClient') + .returns(RDP_CLIENT_WINDOWS_APP); + await visit(urls.settings); + + assert + .dom(RDP_PREFERRED_CLIENT) + .isVisible() + .hasValue(RDP_CLIENT_WINDOWS_APP); + }); + + test('recommended RDP client is shown when no RDP clients are detected', async function (assert) { + // update IPC stub for no RDP clients + this.ipcStub.withArgs('getRdpClients').returns([RDP_CLIENT_NONE]); + this.ipcStub.withArgs('getPreferredRdpClient').returns(RDP_CLIENT_NONE); + await visit(urls.settings); + + assert.dom(RDP_RECOMMENDED_CLIENT).isVisible(); + assert + .dom(RDP_RECOMMENDED_CLIENT_LINK) + .hasAttribute('href', RDP_CLIENT_MSTSC_LINK) + .hasText('Remote Desktop Connection (mstsc)'); + }); + + test('preferred RDP client is updated correctly', async function (assert) { + const rdpService = this.owner.lookup('service:rdp'); + this.ipcStub.withArgs('setPreferredRdpClient').resolves(); + await visit(urls.settings); + + await select(RDP_PREFERRED_CLIENT, RDP_CLIENT_NONE); + + assert.ok( + this.ipcStub.calledWith('setPreferredRdpClient', RDP_CLIENT_NONE), + ); + assert.strictEqual(rdpService.preferredRdpClient, RDP_CLIENT_NONE); + }); }); diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index 178459018..d75cf1aa5 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -23,6 +23,7 @@ import { STATUS_SESSION_TERMINATED, } from 'api/models/session'; import { TYPE_TARGET_RDP } from 'api/models/target'; +import sinon from 'sinon'; module('Acceptance | projects | targets | index', function (hooks) { setupApplicationTest(hooks); @@ -162,6 +163,10 @@ module('Acceptance | projects | targets | index', function (hooks) { this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js index 67c22164c..8df611fd2 100644 --- a/ui/desktop/tests/acceptance/projects/targets/target-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js @@ -18,6 +18,7 @@ import WindowMockIPC from '../../../helpers/window-mock-ipc'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; import { TYPE_TARGET_RDP } from 'api/models/target'; +import sinon from 'sinon'; module('Acceptance | projects | targets | target', function (hooks) { setupApplicationTest(hooks); @@ -153,6 +154,10 @@ module('Acceptance | projects | targets | target', function (hooks) { this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); + + // mock RDP service calls + this.rdpService = this.owner.lookup('service:rdp'); + sinon.stub(this.rdpService, 'initialize').resolves(); }); test('user can connect to a target with an address', async function (assert) { @@ -630,8 +635,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows `Open` and `Connect` button for RDP target with preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -645,8 +649,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows "Connect" button for RDP target without preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = null; + this.rdpService.preferredRdpClient = null; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -658,8 +661,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('clicking `open` button for RDP target triggers launchRdpClient', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('cliExists').returns(true); @@ -683,8 +685,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows `Connect` button for rdp target without preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = null; + this.rdpService.preferredRdpClient = null; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -708,8 +709,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows confirm modal when quickConnectAndLaunchRdp fails', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); diff --git a/ui/desktop/tests/acceptance/scopes-test.js b/ui/desktop/tests/acceptance/scopes-test.js index 46c30db09..660c967db 100644 --- a/ui/desktop/tests/acceptance/scopes-test.js +++ b/ui/desktop/tests/acceptance/scopes-test.js @@ -21,6 +21,7 @@ import { import WindowMockIPC from '../helpers/window-mock-ipc'; import setupStubs from 'api/test-support/handlers/cache-daemon-search'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import sinon from 'sinon'; module('Acceptance | scopes', function (hooks) { setupApplicationTest(hooks); @@ -138,6 +139,10 @@ module('Acceptance | scopes', function (hooks) { this.ipcStub.withArgs('isCacheDaemonRunning').returns(true); this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); + + // mock RDP service calls + let rdpService = this.owner.lookup('service:rdp'); + sinon.stub(rdpService, 'initialize').resolves(); }); test('visiting index', async function (assert) { diff --git a/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js b/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js new file mode 100644 index 000000000..d91012c8a --- /dev/null +++ b/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, select } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; +import Service from '@ember/service'; +import { + RDP_CLIENT_WINDOWS_APP, + RDP_CLIENT_NONE, + RDP_CLIENT_WINDOWS_APP_LINK, +} from 'desktop/services/rdp'; + +module( + 'Integration | Component | settings-card/preferred-clients', + function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it renders protocol and preferred client correctly', async function (assert) { + let updatedPreferredRDPClient; + this.owner.register( + 'service:rdp', + class extends Service { + rdpClients = [RDP_CLIENT_WINDOWS_APP, RDP_CLIENT_NONE]; + preferredRdpClient = RDP_CLIENT_WINDOWS_APP; + setPreferredRdpClient = (value) => { + updatedPreferredRDPClient = value; + }; + }, + ); + await render(hbs``); + + assert + .dom('.hds-table tbody tr td:first-child') + .hasText('Windows RDP', 'Protocol is rendered'); + assert + .dom('select option:checked') + .hasText('Windows App', 'Preferred client is selected'); + + await select('select', RDP_CLIENT_NONE); + + assert + .dom('select option:checked') + .hasText('None', 'Preferred client is updated'); + + assert.strictEqual( + updatedPreferredRDPClient, + RDP_CLIENT_NONE, + 'setPreferredRdpClient is called with correct value', + ); + }); + + test('it renders recommended client link only when none is detected', async function (assert) { + this.owner.register( + 'service:rdp', + class extends Service { + rdpClients = [RDP_CLIENT_NONE]; + recommendedRdpClient = { + name: RDP_CLIENT_WINDOWS_APP, + link: RDP_CLIENT_WINDOWS_APP_LINK, + }; + }, + ); + await render(hbs``); + + assert + .dom('.hds-table tbody tr td:first-child') + .hasText('Windows RDP', 'Protocol is rendered'); + + assert + .dom('.hds-table tbody tr td:last-child') + .includesText( + 'None detected. We recommend Windows App', + 'None detected message is rendered', + ); + + assert + .dom('.hds-table tbody tr td:last-child a') + .hasAttribute( + 'href', + RDP_CLIENT_WINDOWS_APP_LINK, + 'windows app download link is rendered', + ); + }); + }, +); diff --git a/ui/desktop/tests/unit/services/rdp-test.js b/ui/desktop/tests/unit/services/rdp-test.js index 0c104bd1d..d945b7b32 100644 --- a/ui/desktop/tests/unit/services/rdp-test.js +++ b/ui/desktop/tests/unit/services/rdp-test.js @@ -6,6 +6,13 @@ import { module, test } from 'qunit'; import { setupTest } from 'desktop/tests/helpers'; import sinon from 'sinon'; +import { + RDP_CLIENT_NONE, + RDP_CLIENT_WINDOWS_APP, + RDP_CLIENT_MSTSC, + RDP_CLIENT_WINDOWS_APP_LINK, + RDP_CLIENT_MSTSC_LINK, +} from 'desktop/services/rdp'; module('Unit | Service | rdp', function (hooks) { setupTest(hooks); @@ -17,12 +24,19 @@ module('Unit | Service | rdp', function (hooks) { ipcService = this.owner.lookup('service:ipc'); }); + hooks.afterEach(function () { + sinon.restore(); + }); + test('getRdpClients sets to fallback value on error', async function (assert) { - sinon.stub(ipcService, 'invoke').withArgs('getRdpClients').rejects(); + const ipcStub = sinon.stub(ipcService, 'invoke'); + ipcStub.withArgs('getRdpClients').rejects(); + ipcStub.withArgs('checkOS').resolves({ isMac: true }); await service.getRdpClients(); + assert.deepEqual( service.rdpClients, - ['none'], + [RDP_CLIENT_NONE], 'rdpClients fallback is set correctly', ); }); @@ -33,9 +47,10 @@ module('Unit | Service | rdp', function (hooks) { .withArgs('getPreferredRdpClient') .rejects(); await service.getPreferredRdpClient(); + assert.strictEqual( service.preferredRdpClient, - 'none', + RDP_CLIENT_NONE, 'preferredRdpClient fallback is set correctly', ); }); @@ -43,13 +58,44 @@ module('Unit | Service | rdp', function (hooks) { test('setPreferredRdpClient sets to fallback value on error', async function (assert) { sinon .stub(ipcService, 'invoke') - .withArgs('setPreferredRdpClient', 'mstsc') + .withArgs('setPreferredRdpClient', RDP_CLIENT_MSTSC) .rejects(); - await service.setPreferredRdpClient('mstsc'); + await service.setPreferredRdpClient(RDP_CLIENT_MSTSC); + assert.strictEqual( service.preferredRdpClient, - 'none', + RDP_CLIENT_NONE, 'preferredRdpClient fallback is set correctly', ); }); + + test('sets recommendedRdpClient correctly based on OS', async function (assert) { + sinon + .stub(ipcService, 'invoke') + .withArgs('checkOS') + .resolves({ isWindows: true }); + await service.getRecommendedRdpClient(); + + assert.deepEqual( + service.recommendedRdpClient, + { + name: RDP_CLIENT_MSTSC, + link: RDP_CLIENT_MSTSC_LINK, + }, + 'recommendedRdpClient is set correctly for windows', + ); + + ipcService.invoke.withArgs('checkOS').resolves({ isMac: true }); + service.recommendedRdpClient = null; + await service.getRecommendedRdpClient(); + + assert.deepEqual( + service.recommendedRdpClient, + { + name: RDP_CLIENT_WINDOWS_APP, + link: RDP_CLIENT_WINDOWS_APP_LINK, + }, + 'recommendedRdpClient is set correctly for mac', + ); + }); });