From edaf19f59bd52ba66dca19ce8be312368bb93aac Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Tue, 20 Aug 2024 19:28:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Add=20section=20for=20cl?= =?UTF-8?q?ient=20agent=20in=20settings=20(#2442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 Add section for client agent in settings ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-14780 --- addons/api/mirage/scenarios/ipc.js | 14 ++-- addons/core/translations/actions/en-us.yaml | 2 + addons/core/translations/en-us.yaml | 7 ++ addons/core/translations/errors/en-us.yaml | 2 - addons/core/translations/states/en-us.yaml | 1 + .../settings-card/application/index.hbs | 14 ++-- .../settings-card/client-agent/index.hbs | 63 ++++++++++++++ .../settings-card/client-agent/index.js | 68 +++++++++++++++ .../app/components/settings-card/index.hbs | 5 +- .../components/settings-card/logs/index.js | 10 +++ .../components/settings-card/user/index.js | 1 + .../app/routes/scopes/scope/projects.js | 2 + .../scopes/scope/projects/settings/index.js | 62 +++++++++----- .../scopes/scope/projects/settings/index.hbs | 1 + .../electron-app/src/helpers/spawn-promise.js | 5 +- ui/desktop/electron-app/src/ipc/handlers.js | 17 +++- .../services/client-agent-daemon-manager.js | 46 +++++++++- ui/desktop/tests/helpers/window-mock-ipc.js | 21 +++++ .../settings-card/application-test.js | 58 ++++++------- .../settings-card/client-agent-test.js | 84 +++++++++++++++++++ 20 files changed, 403 insertions(+), 80 deletions(-) create mode 100644 ui/desktop/app/components/settings-card/client-agent/index.hbs create mode 100644 ui/desktop/app/components/settings-card/client-agent/index.js create mode 100644 ui/desktop/tests/integration/components/settings-card/client-agent-test.js diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index 652459391..62e17c9c5 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -199,18 +199,18 @@ export default function initializeMockIPC(server, config) { getDesktopVersion() { return { desktopVersion: '1.0.0' }; } - getLogLevel() { - return 'debug'; + return 'info'; } - - setLogLevel() { - return 'warn'; - } - getLogPath() { return '~/.config/Boundary/logs/desktop-client.log'; } + setLogLevel() {} + clientAgentStatus() { + return { version: '0.0.1-dev', status: 'running' }; + } + pauseClientAgent() {} + resumeClientAgent() {} } /** diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index 6ab0b3bbe..f23351b08 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -22,6 +22,8 @@ back: Back view: View retry: Retry play: Play +pause: Pause +resume: Resume get-topic-help: Get help for this topic copy-to-clipboard: Copy to Clipboard copy-error-detail-to-clipboard: Copy error detail to clipboard diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index 4d642b5ef..8cf9176cf 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -109,7 +109,11 @@ settings: application: Application cli: CLI cache-daemon: Cache Daemon + client-agent: + title: Boundary Client Agent + description: Required to use Transparent Sessions client: Client + version: Version color-theme: Color Theme provider: Provider hcp: HashiCorp Cloud Platform @@ -127,3 +131,6 @@ settings: warn: Warn info: Info debug: Debug + alerts: + cache-daemon: There may be a problem with the cache daemon + client-agent: There may be a problem with the client agent diff --git a/addons/core/translations/errors/en-us.yaml b/addons/core/translations/errors/en-us.yaml index 7c5207a33..525883d68 100644 --- a/addons/core/translations/errors/en-us.yaml +++ b/addons/core/translations/errors/en-us.yaml @@ -27,5 +27,3 @@ onboarding-failed: required-field: This is a required field. client-agent-failed: sessions: Unable to retrieve sessions from the client agent. -settings-cache: - title: There may be a problem with the cache daemon diff --git a/addons/core/translations/states/en-us.yaml b/addons/core/translations/states/en-us.yaml index 15893d59d..c3d36f289 100644 --- a/addons/core/translations/states/en-us.yaml +++ b/addons/core/translations/states/en-us.yaml @@ -17,6 +17,7 @@ failed: Failed installed: Installed not-running: Not Running running: Running +paused: Paused authenticated: Authenticated unauthenticated: Unauthenticated loading: diff --git a/ui/desktop/app/components/settings-card/application/index.hbs b/ui/desktop/app/components/settings-card/application/index.hbs index 1e91984fe..b8539a6b1 100644 --- a/ui/desktop/app/components/settings-card/application/index.hbs +++ b/ui/desktop/app/components/settings-card/application/index.hbs @@ -10,7 +10,7 @@ {{t 'settings.client'}} - {{@model.formattedDesktopVersion}} + {{@model.desktopVersion}}
@@ -18,7 +18,7 @@ {{t 'settings.cli'}} - {{@model.formattedCliVersion}} + {{@model.cliVersion}}
@@ -26,8 +26,8 @@ {{t 'settings.cache-daemon'}} - {{#if @model.formattedCacheVersion}} - {{@model.formattedCacheVersion}} + {{#if @model.cacheDaemonStatus.version}} + {{@model.cacheDaemonStatus.version}}
- {{#if @model.cacheDaemonErrors}} + {{#if @model.cacheDaemonStatus.errors}} - {{t 'errors.settings-cache.title'}} + {{t 'settings.alerts.cache-daemon'}} - {{#each @model.cacheDaemonErrors as |error|}} + {{#each @model.cacheDaemonStatus.errors as |error|}} {{#if error.name}} {{error.name}}: diff --git a/ui/desktop/app/components/settings-card/client-agent/index.hbs b/ui/desktop/app/components/settings-card/client-agent/index.hbs new file mode 100644 index 000000000..b04aec8b9 --- /dev/null +++ b/ui/desktop/app/components/settings-card/client-agent/index.hbs @@ -0,0 +1,63 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if this.isClientAgentActive}} + + <:state> + {{#if this.isClientAgentStatusRunning}} + + {{else}} + + {{/if}} + + <:body> +
+ + {{t 'settings.version'}} + + + {{@model.clientAgentStatus.version}} + +
+ {{#if @model.clientAgentStatus.errors}} + + {{t 'settings.alerts.client-agent'}} + + {{#each @model.clientAgentStatus.errors as |error|}} + + {{error.message}} + + {{/each}} + + + {{/if}} + + + <:action> + + +
+{{/if}} \ No newline at end of file diff --git a/ui/desktop/app/components/settings-card/client-agent/index.js b/ui/desktop/app/components/settings-card/client-agent/index.js new file mode 100644 index 000000000..2ba238c34 --- /dev/null +++ b/ui/desktop/app/components/settings-card/client-agent/index.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { loading } from 'ember-loading'; +import { notifyError } from 'core/decorators/notify'; + +export default class SettingsCardClientAgentComponent extends Component { + // =services + @service ipc; + @service router; + @service loading; + + // =attributes + + /** + * Checks if the status returned by the client agent is "running" + * @return {boolean} + */ + get isClientAgentStatusRunning() { + return this.args.model.clientAgentStatus?.status === 'running'; + } + + /** + * Checks if the client agent is actively running + * @return {boolean} + */ + get isClientAgentActive() { + return ( + this.args.model.clientAgentStatus?.status === 'running' || + this.args.model.clientAgentStatus?.status === 'paused' + ); + } + + /** + * Return the button icon based on the current state of the client agent + * @return {string} + */ + get buttonIcon() { + if (this.loading.isLoading) { + return 'loading'; + } else { + return this.isClientAgentStatusRunning ? 'pause' : 'play'; + } + } + + // =actions + + /** + * Changes the state of the client agent based on the current state. + * @return {Promise} + */ + @action + @loading + @notifyError(({ message }) => message) + async changeClientAgentState() { + if (this.isClientAgentStatusRunning) { + await this.ipc.invoke('pauseClientAgent'); + } else { + await this.ipc.invoke('resumeClientAgent'); + } + await this.router.refresh(); + } +} diff --git a/ui/desktop/app/components/settings-card/index.hbs b/ui/desktop/app/components/settings-card/index.hbs index 80d79e3d4..5694a12c1 100644 --- a/ui/desktop/app/components/settings-card/index.hbs +++ b/ui/desktop/app/components/settings-card/index.hbs @@ -4,7 +4,6 @@ }} -
@@ -20,11 +19,10 @@ {{yield to='action'}}
{{/if}} - {{#if @description}} - + {{@description}} {{/if}} @@ -35,5 +33,4 @@ {{yield to='body'}} {{/if}} -
\ No newline at end of file diff --git a/ui/desktop/app/components/settings-card/logs/index.js b/ui/desktop/app/components/settings-card/logs/index.js index 22b26234e..40a14c4e5 100644 --- a/ui/desktop/app/components/settings-card/logs/index.js +++ b/ui/desktop/app/components/settings-card/logs/index.js @@ -13,11 +13,21 @@ export default class SettingsCardLogsComponent extends Component { // =attributes + /** + * Returns the list of available log levels + * @return {string[]} + */ get logLevels() { return ['error', 'warn', 'info', 'debug']; } // =methods + + /** + * Sets the log level to the selected value + * @param value + * @return {Promise} + */ @action async selectLogLevel({ target: { value } }) { await this.ipc.invoke('setLogLevel', value); diff --git a/ui/desktop/app/components/settings-card/user/index.js b/ui/desktop/app/components/settings-card/user/index.js index 917437b6f..0c946ae30 100644 --- a/ui/desktop/app/components/settings-card/user/index.js +++ b/ui/desktop/app/components/settings-card/user/index.js @@ -13,6 +13,7 @@ export default class SettingsUserInfoComponent extends Component { @service session; @service intl; + // =attributes /** * Returns the username of the current user * @type {string} diff --git a/ui/desktop/app/routes/scopes/scope/projects.js b/ui/desktop/app/routes/scopes/scope/projects.js index d8f6e0bbb..a4160ed1c 100644 --- a/ui/desktop/app/routes/scopes/scope/projects.js +++ b/ui/desktop/app/routes/scopes/scope/projects.js @@ -64,6 +64,8 @@ export default class ScopesScopeProjectsRoute extends Route { ); if (isClientAgentRunning) { this.job.start(); + } else { + this.job.stop(); } } diff --git a/ui/desktop/app/routes/scopes/scope/projects/settings/index.js b/ui/desktop/app/routes/scopes/scope/projects/settings/index.js index 09fb66aae..4f4766c6c 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/settings/index.js +++ b/ui/desktop/app/routes/scopes/scope/projects/settings/index.js @@ -14,26 +14,38 @@ export default class ScopesScopeProjectsSettingsIndexRoute extends Route { // =methods async model() { - let cacheDaemonStatus, - formattedCacheVersion, - formattedCliVersion, - formattedDesktopVersion, - cacheDaemonErrors = []; - const { versionNumber: cliVersion } = await this.ipc.invoke('getCliVersion'); - formattedCliVersion = `v${cliVersion}`; const { desktopVersion } = await this.ipc.invoke('getDesktopVersion'); - formattedDesktopVersion = `v${desktopVersion}`; + const cacheDaemonStatus = await this.getCacheStatus(); + const clientAgentStatus = await this.getClientAgentStatus(); + const logLevel = await this.ipc.invoke('getLogLevel'); + const logPath = await this.ipc.invoke('getLogPath'); + const serverInformation = this.clusterUrl.rendererClusterUrl; + + return { + desktopVersion: `v${desktopVersion}`, + cliVersion: `v${cliVersion}`, + cacheDaemonStatus, + clientAgentStatus, + logLevel, + logPath, + serverInformation, + }; + } + + async getCacheStatus() { + let cacheDaemonStatus; + let cacheDaemonStatusError = []; try { cacheDaemonStatus = await this.ipc.invoke('cacheDaemonStatus'); } catch (e) { - cacheDaemonErrors.push(e); + cacheDaemonStatusError.push(e); } const { version, users } = cacheDaemonStatus ?? {}; - formattedCacheVersion = version?.match(/v\d+\.\d+\.\d+/)?.[0]; + const cacheVersion = version?.match(/v\d+\.\d+\.\d+/)?.[0]; // Grab the errors from each resource const userId = this.session.data.authenticated.user_id; @@ -45,20 +57,24 @@ export default class ScopesScopeProjectsSettingsIndexRoute extends Route { message: resource.last_error.error, name: resource.name, })) ?? []; - cacheDaemonErrors = [...cacheDaemonErrors, ...resourceErrors]; - const logLevel = await this.ipc.invoke('getLogLevel'); - const logPath = await this.ipc.invoke('getLogPath'); - const serverInformation = this.clusterUrl.rendererClusterUrl; + const errors = [...cacheDaemonStatusError, ...resourceErrors]; + return { version: cacheVersion, errors }; + } - return { - cacheDaemonErrors, - formattedCliVersion, - formattedCacheVersion, - formattedDesktopVersion, - logLevel, - logPath, - serverInformation, - }; + async getClientAgentStatus() { + let clientAgentStatus; + let clientAgentStatusError = []; + try { + clientAgentStatus = await this.ipc.invoke('clientAgentStatus'); + } catch (e) { + clientAgentStatusError.push(e); + } + + const { version, status, errors: errorMessages } = clientAgentStatus ?? {}; + const statusErrors = errorMessages?.map((message) => ({ message })) ?? []; + + const errors = [...clientAgentStatusError, ...statusErrors]; + return { version: `v${version}`, status, errors }; } } 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 1d07b63a8..d7b5cd04a 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs @@ -4,6 +4,7 @@ }} + \ No newline at end of file diff --git a/ui/desktop/electron-app/src/helpers/spawn-promise.js b/ui/desktop/electron-app/src/helpers/spawn-promise.js index bf6c7e996..8835509e0 100644 --- a/ui/desktop/electron-app/src/helpers/spawn-promise.js +++ b/ui/desktop/electron-app/src/helpers/spawn-promise.js @@ -99,10 +99,11 @@ module.exports = { * Spawn a child process and return a promise. * Resolves on any output from stdout or stderr. * @param command + * @param options */ - spawn(command) { + spawn(command, options) { return new Promise((resolve, reject) => { - const childProcess = spawn(path(), command); + const childProcess = spawn(path(), command, options); childProcess.stdout.on('data', (data) => { resolve({ childProcess, stdout: data.toString() }); }); diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index f529ffae1..6cd8263fa 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -197,6 +197,21 @@ handle('isClientAgentRunning', async () => { } }); +/** + * Returns the client agent's status + */ +handle('clientAgentStatus', async () => clientAgentDaemonManager.status()); + +/** + * Pauses the client agent + */ +handle('pauseClientAgent', async () => clientAgentDaemonManager.pause()); + +/** + * Resumes the client agent + */ +handle('resumeClientAgent', async () => clientAgentDaemonManager.resume()); + /** * Gets boundary cli version */ @@ -210,7 +225,7 @@ handle('getDesktopVersion', async () => ({ desktopVersion: releaseVersion })); /** * Returns the Cache daemon status */ -handle('cacheDaemonStatus', async () => await cacheDaemonManager.status()); +handle('cacheDaemonStatus', async () => cacheDaemonManager.status()); /** * Returns the current log level diff --git a/ui/desktop/electron-app/src/services/client-agent-daemon-manager.js b/ui/desktop/electron-app/src/services/client-agent-daemon-manager.js index 9111f2bd9..9764ff712 100644 --- a/ui/desktop/electron-app/src/services/client-agent-daemon-manager.js +++ b/ui/desktop/electron-app/src/services/client-agent-daemon-manager.js @@ -15,7 +15,11 @@ class ClientAgentDaemonManager { */ async status() { const clientAgentStatusCommand = ['client-agent', 'status', '-format=json']; - const { stdout, stderr } = spawnSync(clientAgentStatusCommand); + // The status command can take a while to execute if it's not running as it + // retries multiple times so we should set a timeout to not wait too long + const { stderr, stdout } = await spawn(clientAgentStatusCommand, { + timeout: 500, + }); let parsedResponse = jsonify(stdout); if (parsedResponse?.status_code === 200) { @@ -47,6 +51,46 @@ class ClientAgentDaemonManager { log.warn('Client Agent Sessions:', stderr); return generateErrorPromise(stderr); } + + /** + * Pauses the client agent daemon. + * @return {Promise<*>} + */ + async pause() { + const clientAgentPauseCommand = ['client-agent', 'pause', '-format=json']; + // We use spawn here as pausing can take a while to execute and using spawnSync + // can cause the app to completely freeze and be unresponsive during that time + const { stderr, stdout } = await spawn(clientAgentPauseCommand, { + timeout: 10000, + }); + + let parsedResponse = jsonify(stdout); + if (parsedResponse?.status_code === 200) { + return parsedResponse.item; + } + + log.warn('Client Agent Pause:', stderr); + return generateErrorPromise(stderr); + } + + /** + * Resumes the client agent daemon. + * @return {Promise<*>} + */ + async resume() { + const clientAgentResumeCommand = ['client-agent', 'resume', '-format=json']; + const { stderr, stdout } = await spawn(clientAgentResumeCommand, { + timeout: 10000, + }); + + let parsedResponse = jsonify(stdout); + if (parsedResponse?.status_code === 200) { + return parsedResponse.item; + } + + log.warn('Client Agent Resume:', stderr); + return generateErrorPromise(stderr); + } } // Export an instance so we get a singleton diff --git a/ui/desktop/tests/helpers/window-mock-ipc.js b/ui/desktop/tests/helpers/window-mock-ipc.js index 5f1244e29..d48fef131 100644 --- a/ui/desktop/tests/helpers/window-mock-ipc.js +++ b/ui/desktop/tests/helpers/window-mock-ipc.js @@ -34,6 +34,27 @@ class MockIPC { isClientAgentRunning() { return false; } + cacheDaemonStatus() { + return { version: 'Boundary v1.0.0' }; + } + getCliVersion() { + return { versionNumber: '1.0.0' }; + } + getDesktopVersion() { + return { desktopVersion: '1.0.0' }; + } + getLogLevel() { + return 'info'; + } + getLogPath() { + return '~/.config/Boundary/logs/desktop-client.log'; + } + setLogLevel() {} + clientAgentStatus() { + return { version: '0.0.1-dev', status: 'running' }; + } + pauseClientAgent() {} + resumeClientAgent() {} } /** diff --git a/ui/desktop/tests/integration/components/settings-card/application-test.js b/ui/desktop/tests/integration/components/settings-card/application-test.js index afed0dc8f..2c59ecd37 100644 --- a/ui/desktop/tests/integration/components/settings-card/application-test.js +++ b/ui/desktop/tests/integration/components/settings-card/application-test.js @@ -18,13 +18,18 @@ module('Integration | Component | settings-card/application', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks); + let model; + + hooks.beforeEach(() => { + model = { + cacheDaemonStatus: { version: 'v4.0.0', errors: [] }, + desktopVersion: 'v2.0.0', + cliVersion: 'v3.0.0', + }; + }); + test('it renders', async function (assert) { - this.set('model', { - cacheDaemonErrors: [], - formattedDesktopVersion: 'v2.0.0', - formattedCliVersion: 'v3.0.0', - formattedCacheVersion: 'v4.0.0', - }); + this.set('model', model); this.set('toggleTheme', () => {}); await render( @@ -37,21 +42,17 @@ module('Integration | Component | settings-card/application', function (hooks) { }); test('it renders errors', async function (assert) { - this.set('model', { - cacheDaemonErrors: [ - { - message: `this is a resolvable alias error`, - name: 'resolvable-alias', - }, - { - message: `this is a target error`, - name: 'target', - }, - ], - formattedDesktopVersion: 'v2.0.0', - formattedCliVersion: 'v3.0.0', - formattedCacheVersion: 'v4.0.0', - }); + model.cacheDaemonStatus.errors = [ + { + message: `this is a resolvable alias error`, + name: 'resolvable-alias', + }, + { + message: `this is a target error`, + name: 'target', + }, + ]; + this.set('model', model); this.set('toggleTheme', () => {}); await render( @@ -66,12 +67,7 @@ module('Integration | Component | settings-card/application', function (hooks) { }); test('it indicates when cache daemon is running', async function (assert) { - this.set('model', { - cacheDaemonErrors: [], - formattedDesktopVersion: 'v2.0.0', - formattedCliVersion: 'v3.0.0', - formattedCacheVersion: 'v4.0.0', - }); + this.set('model', model); this.set('toggleTheme', () => {}); await render( @@ -80,12 +76,8 @@ module('Integration | Component | settings-card/application', function (hooks) { assert.dom(CACHE_DAEMON_VERSION).hasText('v4.0.0 Running'); - this.set('model', { - cacheDaemonErrors: [], - formattedDesktopVersion: 'v2.0.0', - formattedCliVersion: 'v3.0.0', - formattedCacheVersion: '', - }); + model.cacheDaemonStatus.version = ''; + this.set('model', structuredClone(model)); assert.dom(CACHE_DAEMON_VERSION).hasText('Not Running'); }); }); diff --git a/ui/desktop/tests/integration/components/settings-card/client-agent-test.js b/ui/desktop/tests/integration/components/settings-card/client-agent-test.js new file mode 100644 index 000000000..45e8ae6af --- /dev/null +++ b/ui/desktop/tests/integration/components/settings-card/client-agent-test.js @@ -0,0 +1,84 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +const STATUS_BADGE = '.hds-badge'; +const ACTION_BUTTON = 'button'; +const CARD_CONTAINER = 'hds-card__container'; +const ERROR_MESSAGES = '.hds-alert__description > p'; +const LAST_ERROR_MESSAGE = '.hds-alert__description > p:last-child'; + +module( + 'Integration | Component | settings-card/client-agent', + function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks); + + test('it renders running badge and pause button', async function (assert) { + this.set('model', { + clientAgentStatus: { + status: 'running', + errors: [], + }, + }); + + await render(hbs``); + + assert.dom(STATUS_BADGE).hasText('Running'); + assert.dom(ACTION_BUTTON).hasText('Pause'); + }); + + test('it renders paused badge and resume button', async function (assert) { + this.set('model', { + clientAgentStatus: { + status: 'paused', + errors: [], + }, + }); + + await render(hbs``); + + assert.dom(STATUS_BADGE).hasText('Paused'); + assert.dom(ACTION_BUTTON).hasText('Resume'); + }); + + test('it does not render component if there is no status', async function (assert) { + this.set('model', { + clientAgentStatus: { + errors: [{ message: 'agent is offline' }], + }, + }); + + await render(hbs``); + + assert.dom(STATUS_BADGE).doesNotExist(); + assert.dom(ACTION_BUTTON).doesNotExist(); + assert.dom(CARD_CONTAINER).doesNotExist(); + }); + + test('it renders errors', async function (assert) { + this.set('model', { + clientAgentStatus: { + status: 'running', + errors: [ + { message: 'agent is wonky' }, + { message: 'another error' }, + { message: 'big oof' }, + ], + }, + }); + + await render(hbs``); + + assert.dom(ERROR_MESSAGES).isVisible({ count: 3 }); + assert.dom(LAST_ERROR_MESSAGE).hasText('big oof'); + }); + }, +);