github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/tests/acceptance/allocation-detail-test.js (about)

     1  /* eslint-disable qunit/require-expect */
     2  /* Mirage fixtures are random so we can't expect a set number of assertions */
     3  import { run } from '@ember/runloop';
     4  import { currentURL, click, visit, triggerEvent } from '@ember/test-helpers';
     5  import { assign } from '@ember/polyfills';
     6  import { module, test } from 'qunit';
     7  import { setupApplicationTest } from 'ember-qunit';
     8  import { setupMirage } from 'ember-cli-mirage/test-support';
     9  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
    10  import Allocation from 'nomad-ui/tests/pages/allocations/detail';
    11  import moment from 'moment';
    12  import formatHost from 'nomad-ui/utils/format-host';
    13  
    14  let job;
    15  let node;
    16  let allocation;
    17  
    18  module('Acceptance | allocation detail', function (hooks) {
    19    setupApplicationTest(hooks);
    20    setupMirage(hooks);
    21  
    22    hooks.beforeEach(async function () {
    23      server.create('agent');
    24  
    25      node = server.create('node');
    26      job = server.create('job', {
    27        groupsCount: 1,
    28        withGroupServices: true,
    29        createAllocations: false,
    30      });
    31      allocation = server.create('allocation', 'withTaskWithPorts', {
    32        clientStatus: 'running',
    33      });
    34  
    35      // Make sure the node has an unhealthy driver
    36      node.update({
    37        driver: assign(node.drivers, {
    38          docker: {
    39            detected: true,
    40            healthy: false,
    41          },
    42        }),
    43      });
    44  
    45      // Make sure a task for the allocation depends on the unhealthy driver
    46      server.schema.tasks.first().update({
    47        driver: 'docker',
    48      });
    49  
    50      await Allocation.visit({ id: allocation.id });
    51    });
    52  
    53    test('it passes an accessibility audit', async function (assert) {
    54      await a11yAudit(assert);
    55    });
    56  
    57    test('/allocation/:id should name the allocation and link to the corresponding job and node', async function (assert) {
    58      assert.ok(
    59        Allocation.title.includes(allocation.name),
    60        'Allocation name is in the heading'
    61      );
    62      assert.equal(
    63        Allocation.details.job,
    64        job.name,
    65        'Job name is in the subheading'
    66      );
    67      assert.equal(
    68        Allocation.details.client,
    69        node.id.split('-')[0],
    70        'Node short id is in the subheading'
    71      );
    72      assert.ok(Allocation.execButton.isPresent);
    73  
    74      assert.equal(document.title, `Allocation ${allocation.name} - Nomad`);
    75  
    76      await Allocation.details.visitJob();
    77      assert.equal(
    78        currentURL(),
    79        `/jobs/${job.id}@default`,
    80        'Job link navigates to the job'
    81      );
    82  
    83      await Allocation.visit({ id: allocation.id });
    84  
    85      await Allocation.details.visitClient();
    86      assert.equal(
    87        currentURL(),
    88        `/clients/${node.id}`,
    89        'Client link navigates to the client'
    90      );
    91    });
    92  
    93    test('/allocation/:id should include resource utilization graphs', async function (assert) {
    94      assert.equal(
    95        Allocation.resourceCharts.length,
    96        2,
    97        'Two resource utilization graphs'
    98      );
    99      assert.equal(
   100        Allocation.resourceCharts.objectAt(0).name,
   101        'CPU',
   102        'First chart is CPU'
   103      );
   104      assert.equal(
   105        Allocation.resourceCharts.objectAt(1).name,
   106        'Memory',
   107        'Second chart is Memory'
   108      );
   109    });
   110  
   111    test('/allocation/:id should present task lifecycles', async function (assert) {
   112      const job = server.create('job', {
   113        groupsCount: 1,
   114        groupTaskCount: 6,
   115        withGroupServices: true,
   116        createAllocations: false,
   117      });
   118  
   119      const allocation = server.create('allocation', 'withTaskWithPorts', {
   120        clientStatus: 'running',
   121        jobId: job.id,
   122      });
   123  
   124      await Allocation.visit({ id: allocation.id });
   125  
   126      assert.ok(Allocation.lifecycleChart.isPresent);
   127      assert.equal(Allocation.lifecycleChart.title, 'Task Lifecycle Status');
   128      assert.equal(Allocation.lifecycleChart.phases.length, 4);
   129      assert.equal(Allocation.lifecycleChart.tasks.length, 6);
   130  
   131      await Allocation.lifecycleChart.tasks[0].visit();
   132  
   133      const prestartEphemeralTask = server.db.taskStates
   134        .where({ allocationId: allocation.id })
   135        .sortBy('name')
   136        .find((taskState) => {
   137          const task = server.db.tasks.findBy({ name: taskState.name });
   138          return (
   139            task.Lifecycle &&
   140            task.Lifecycle.Hook === 'prestart' &&
   141            !task.Lifecycle.Sidecar
   142          );
   143        });
   144  
   145      assert.equal(
   146        currentURL(),
   147        `/allocations/${allocation.id}/${prestartEphemeralTask.name}`
   148      );
   149    });
   150  
   151    test('/allocation/:id should list all tasks for the allocation', async function (assert) {
   152      assert.equal(
   153        Allocation.tasks.length,
   154        server.db.taskStates.where({ allocationId: allocation.id }).length,
   155        'Table lists all tasks'
   156      );
   157      assert.notOk(Allocation.isEmpty, 'Task table empty state is not shown');
   158    });
   159  
   160    test('each task row should list high-level information for the task', async function (assert) {
   161      const job = server.create('job', {
   162        groupsCount: 1,
   163        groupTaskCount: 3,
   164        withGroupServices: true,
   165        createAllocations: false,
   166      });
   167  
   168      const allocation = server.create('allocation', 'withTaskWithPorts', {
   169        clientStatus: 'running',
   170        jobId: job.id,
   171      });
   172  
   173      const taskGroup = server.schema.taskGroups.where({
   174        jobId: allocation.jobId,
   175        name: allocation.taskGroup,
   176      }).models[0];
   177  
   178      // Set the expected task states.
   179      const states = ['running', 'pending', 'dead'];
   180      server.db.taskStates
   181        .where({ allocationId: allocation.id })
   182        .sortBy('name')
   183        .forEach((task, i) => {
   184          server.db.taskStates.update(task.id, { state: states[i] });
   185        });
   186  
   187      await Allocation.visit({ id: allocation.id });
   188  
   189      Allocation.tasks.forEach((taskRow, i) => {
   190        const task = server.db.taskStates
   191          .where({ allocationId: allocation.id })
   192          .sortBy('name')[i];
   193        const events = server.db.taskEvents.where({ taskStateId: task.id });
   194        const event = events[events.length - 1];
   195  
   196        const jobTask = taskGroup.tasks.models.find((m) => m.name === task.name);
   197        const volumes = jobTask.volumeMounts.map((volume) => ({
   198          name: volume.Volume,
   199          source: taskGroup.volumes[volume.Volume].Source,
   200        }));
   201  
   202        assert.equal(taskRow.name, task.name, 'Name');
   203        assert.equal(taskRow.state, task.state, 'State');
   204        assert.equal(taskRow.message, event.displayMessage, 'Event Message');
   205        assert.equal(
   206          taskRow.time,
   207          moment(event.time / 1000000).format("MMM DD, 'YY HH:mm:ss ZZ"),
   208          'Event Time'
   209        );
   210  
   211        const expectStats = task.state === 'running';
   212        assert.equal(taskRow.hasCpuMetrics, expectStats, 'CPU metrics');
   213        assert.equal(taskRow.hasMemoryMetrics, expectStats, 'Memory metrics');
   214  
   215        const volumesText = taskRow.volumes;
   216        volumes.forEach((volume) => {
   217          assert.ok(
   218            volumesText.includes(volume.name),
   219            `Found label ${volume.name}`
   220          );
   221          assert.ok(
   222            volumesText.includes(volume.source),
   223            `Found value ${volume.source}`
   224          );
   225        });
   226      });
   227    });
   228  
   229    test('each task row should link to the task detail page', async function (assert) {
   230      const task = server.db.taskStates
   231        .where({ allocationId: allocation.id })
   232        .sortBy('name')[0];
   233  
   234      await Allocation.tasks.objectAt(0).clickLink();
   235  
   236      // Make sure the allocation is pending in order to ensure there are no tasks
   237      assert.equal(
   238        currentURL(),
   239        `/allocations/${allocation.id}/${task.name}`,
   240        'Task name in task row links to task detail'
   241      );
   242  
   243      await Allocation.visit({ id: allocation.id });
   244      await Allocation.tasks.objectAt(0).clickRow();
   245  
   246      assert.equal(
   247        currentURL(),
   248        `/allocations/${allocation.id}/${task.name}`,
   249        'Task row links to task detail'
   250      );
   251    });
   252  
   253    test('tasks with an unhealthy driver have a warning icon', async function (assert) {
   254      assert.ok(
   255        Allocation.firstUnhealthyTask().hasUnhealthyDriver,
   256        'Warning is shown'
   257      );
   258    });
   259  
   260    test('proxy task has a proxy tag', async function (assert) {
   261      // Must create a new job as existing one has loaded and it contains the tasks
   262      job = server.create('job', {
   263        groupsCount: 1,
   264        withGroupServices: true,
   265        createAllocations: false,
   266      });
   267  
   268      allocation = server.create('allocation', 'withTaskWithPorts', {
   269        clientStatus: 'running',
   270        jobId: job.id,
   271      });
   272  
   273      const taskState = allocation.taskStates.models.sortBy('name')[0];
   274      const task = server.schema.tasks.findBy({ name: taskState.name });
   275      task.update('kind', 'connect-proxy:task');
   276      task.save();
   277  
   278      await Allocation.visit({ id: allocation.id });
   279  
   280      assert.ok(Allocation.tasks[0].hasProxyTag);
   281    });
   282  
   283    test('when there are no tasks, an empty state is shown', async function (assert) {
   284      // Make sure the allocation is pending in order to ensure there are no tasks
   285      allocation = server.create('allocation', 'withTaskWithPorts', {
   286        clientStatus: 'pending',
   287      });
   288      await Allocation.visit({ id: allocation.id });
   289  
   290      assert.ok(Allocation.isEmpty, 'Task table empty state is shown');
   291    });
   292  
   293    test('when the allocation has not been rescheduled, the reschedule events section is not rendered', async function (assert) {
   294      assert.notOk(
   295        Allocation.hasRescheduleEvents,
   296        'Reschedule Events section exists'
   297      );
   298    });
   299  
   300    test('ports are listed', async function (assert) {
   301      const allServerPorts = allocation.taskResources.models[0].resources.Ports;
   302  
   303      allServerPorts.sortBy('Label').forEach((serverPort, index) => {
   304        const renderedPort = Allocation.ports[index];
   305  
   306        assert.equal(renderedPort.name, serverPort.Label);
   307        assert.equal(renderedPort.to, serverPort.To);
   308        assert.equal(
   309          renderedPort.address,
   310          formatHost(serverPort.HostIP, serverPort.Value)
   311        );
   312      });
   313    });
   314  
   315    test('services are listed', async function (assert) {
   316      const taskGroup = server.schema.taskGroups.findBy({
   317        name: allocation.taskGroup,
   318      });
   319  
   320      assert.equal(Allocation.services.length, taskGroup.services.length);
   321  
   322      taskGroup.services.models.sortBy('name').forEach((serverService, index) => {
   323        const renderedService = Allocation.services[index];
   324  
   325        assert.equal(renderedService.name, serverService.name);
   326        assert.equal(renderedService.port, serverService.portLabel);
   327        assert.equal(renderedService.tags, (serverService.tags || []).join(' '));
   328      });
   329    });
   330  
   331    test('when the allocation is not found, an error message is shown, but the URL persists', async function (assert) {
   332      await Allocation.visit({ id: 'not-a-real-allocation' });
   333  
   334      assert.equal(
   335        server.pretender.handledRequests
   336          .filter((request) => !request.url.includes('policy'))
   337          .findBy('status', 404).url,
   338        '/v1/allocation/not-a-real-allocation',
   339        'A request to the nonexistent allocation is made'
   340      );
   341      assert.equal(
   342        currentURL(),
   343        '/allocations/not-a-real-allocation',
   344        'The URL persists'
   345      );
   346      assert.ok(Allocation.error.isShown, 'Error message is shown');
   347      assert.equal(
   348        Allocation.error.title,
   349        'Not Found',
   350        'Error message is for 404'
   351      );
   352    });
   353  
   354    test('allocation can be stopped', async function (assert) {
   355      await Allocation.stop.idle();
   356      await Allocation.stop.confirm();
   357  
   358      assert.equal(
   359        server.pretender.handledRequests
   360          .reject((request) => request.url.includes('fuzzy'))
   361          .findBy('method', 'POST').url,
   362        `/v1/allocation/${allocation.id}/stop`,
   363        'Stop request is made for the allocation'
   364      );
   365    });
   366  
   367    test('allocation can be restarted', async function (assert) {
   368      await Allocation.restartAll.idle();
   369      await Allocation.restart.idle();
   370      await Allocation.restart.confirm();
   371  
   372      assert.equal(
   373        server.pretender.handledRequests.findBy('method', 'PUT').url,
   374        `/v1/client/allocation/${allocation.id}/restart`,
   375        'Restart request is made for the allocation'
   376      );
   377  
   378      await Allocation.restart.idle();
   379      await Allocation.restartAll.idle();
   380      await Allocation.restartAll.confirm();
   381  
   382      assert.ok(
   383        server.pretender.handledRequests.filterBy(
   384          'requestBody',
   385          JSON.stringify({ AllTasks: true })
   386        ),
   387        'Restart all tasks request is made for the allocation'
   388      );
   389    });
   390  
   391    test('while an allocation is being restarted, the stop button is disabled', async function (assert) {
   392      server.pretender.post('/v1/allocation/:id/stop', () => [204, {}, ''], true);
   393  
   394      await Allocation.stop.idle();
   395  
   396      run.later(() => {
   397        assert.ok(Allocation.stop.isRunning, 'Stop is loading');
   398        assert.ok(Allocation.restart.isDisabled, 'Restart is disabled');
   399        assert.ok(Allocation.restartAll.isDisabled, 'Restart All is disabled');
   400        server.pretender.resolve(server.pretender.requestReferences[0].request);
   401      }, 500);
   402  
   403      await Allocation.stop.confirm();
   404    });
   405  
   406    test('if stopping or restarting fails, an error message is shown', async function (assert) {
   407      server.pretender.post('/v1/allocation/:id/stop', () => [403, {}, '']);
   408  
   409      await Allocation.stop.idle();
   410      await Allocation.stop.confirm();
   411  
   412      assert.ok(Allocation.inlineError.isShown, 'Inline error is shown');
   413      assert.ok(
   414        Allocation.inlineError.title.includes('Could Not Stop Allocation'),
   415        'Title is descriptive'
   416      );
   417      assert.ok(
   418        /ACL token.+?allocation lifecycle/.test(Allocation.inlineError.message),
   419        'Message mentions ACLs and the appropriate permission'
   420      );
   421  
   422      await Allocation.inlineError.dismiss();
   423  
   424      assert.notOk(
   425        Allocation.inlineError.isShown,
   426        'Inline error is no longer shown'
   427      );
   428    });
   429  });
   430  
   431  module('Acceptance | allocation detail (rescheduled)', function (hooks) {
   432    setupApplicationTest(hooks);
   433    setupMirage(hooks);
   434  
   435    hooks.beforeEach(async function () {
   436      server.create('agent');
   437  
   438      node = server.create('node');
   439      job = server.create('job', { createAllocations: false });
   440      allocation = server.create('allocation', 'rescheduled');
   441  
   442      await Allocation.visit({ id: allocation.id });
   443    });
   444  
   445    test('when the allocation has been rescheduled, the reschedule events section is rendered', async function (assert) {
   446      assert.ok(
   447        Allocation.hasRescheduleEvents,
   448        'Reschedule Events section exists'
   449      );
   450    });
   451  });
   452  
   453  module('Acceptance | allocation detail (not running)', function (hooks) {
   454    setupApplicationTest(hooks);
   455    setupMirage(hooks);
   456  
   457    hooks.beforeEach(async function () {
   458      server.create('agent');
   459  
   460      node = server.create('node');
   461      job = server.create('job', { createAllocations: false });
   462      allocation = server.create('allocation', { clientStatus: 'pending' });
   463  
   464      await Allocation.visit({ id: allocation.id });
   465    });
   466  
   467    test('when the allocation is not running, the utilization graphs are replaced by an empty message', async function (assert) {
   468      assert.equal(Allocation.resourceCharts.length, 0, 'No resource charts');
   469      assert.equal(
   470        Allocation.resourceEmptyMessage,
   471        "Allocation isn't running",
   472        'Empty message is appropriate'
   473      );
   474    });
   475  
   476    test('the exec and stop/restart buttons are absent', async function (assert) {
   477      assert.notOk(Allocation.execButton.isPresent);
   478      assert.notOk(Allocation.stop.isPresent);
   479      assert.notOk(Allocation.restart.isPresent);
   480      assert.notOk(Allocation.restartAll.isPresent);
   481    });
   482  });
   483  
   484  module('Acceptance | allocation detail (preemptions)', function (hooks) {
   485    setupApplicationTest(hooks);
   486    setupMirage(hooks);
   487  
   488    hooks.beforeEach(async function () {
   489      server.create('agent');
   490      node = server.create('node');
   491      job = server.create('job', { createAllocations: false });
   492    });
   493  
   494    test('shows a dedicated section to the allocation that preempted this allocation', async function (assert) {
   495      allocation = server.create('allocation', 'preempted');
   496      const preempter = server.schema.find(
   497        'allocation',
   498        allocation.preemptedByAllocation
   499      );
   500      const preempterJob = server.schema.find('job', preempter.jobId);
   501      const preempterClient = server.schema.find('node', preempter.nodeId);
   502  
   503      await Allocation.visit({ id: allocation.id });
   504      assert.ok(Allocation.wasPreempted, 'Preempted allocation section is shown');
   505      assert.equal(
   506        Allocation.preempter.status,
   507        preempter.clientStatus,
   508        'Preempter status matches'
   509      );
   510      assert.equal(
   511        Allocation.preempter.name,
   512        preempter.name,
   513        'Preempter name matches'
   514      );
   515      assert.equal(
   516        Allocation.preempter.priority,
   517        preempterJob.priority,
   518        'Preempter priority matches'
   519      );
   520  
   521      await Allocation.preempter.visit();
   522      assert.equal(
   523        currentURL(),
   524        `/allocations/${preempter.id}`,
   525        'Clicking the preempter id navigates to the preempter allocation detail page'
   526      );
   527  
   528      await Allocation.visit({ id: allocation.id });
   529  
   530      await Allocation.preempter.visitJob();
   531      assert.equal(
   532        currentURL(),
   533        `/jobs/${preempterJob.id}@default`,
   534        'Clicking the preempter job link navigates to the preempter job page'
   535      );
   536  
   537      await Allocation.visit({ id: allocation.id });
   538      await Allocation.preempter.visitClient();
   539      assert.equal(
   540        currentURL(),
   541        `/clients/${preempterClient.id}`,
   542        'Clicking the preempter client link navigates to the preempter client page'
   543      );
   544    });
   545  
   546    test('shows a dedicated section to the allocations this allocation preempted', async function (assert) {
   547      allocation = server.create('allocation', 'preempter');
   548      await Allocation.visit({ id: allocation.id });
   549      assert.ok(
   550        Allocation.preempted,
   551        'The allocations this allocation preempted are shown'
   552      );
   553    });
   554  
   555    test('each preempted allocation in the table lists basic allocation information', async function (assert) {
   556      allocation = server.create('allocation', 'preempter');
   557      await Allocation.visit({ id: allocation.id });
   558  
   559      const preemption = allocation.preemptedAllocations
   560        .map((id) => server.schema.find('allocation', id))
   561        .sortBy('modifyIndex')
   562        .reverse()[0];
   563      const preemptionRow = Allocation.preemptions.objectAt(0);
   564  
   565      assert.equal(
   566        Allocation.preemptions.length,
   567        allocation.preemptedAllocations.length,
   568        'The preemptions table has a row for each preempted allocation'
   569      );
   570  
   571      assert.equal(
   572        preemptionRow.shortId,
   573        preemption.id.split('-')[0],
   574        'Preemption short id'
   575      );
   576      assert.equal(
   577        preemptionRow.createTime,
   578        moment(preemption.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'),
   579        'Preemption create time'
   580      );
   581      assert.equal(
   582        preemptionRow.modifyTime,
   583        moment(preemption.modifyTime / 1000000).fromNow(),
   584        'Preemption modify time'
   585      );
   586      assert.equal(
   587        preemptionRow.status,
   588        preemption.clientStatus,
   589        'Client status'
   590      );
   591      assert.equal(
   592        preemptionRow.jobVersion,
   593        preemption.jobVersion,
   594        'Job Version'
   595      );
   596      assert.equal(
   597        preemptionRow.client,
   598        server.db.nodes.find(preemption.nodeId).id.split('-')[0],
   599        'Node ID'
   600      );
   601  
   602      await preemptionRow.visitClient();
   603      assert.equal(
   604        currentURL(),
   605        `/clients/${preemption.nodeId}`,
   606        'Node links to node page'
   607      );
   608    });
   609  
   610    test('when an allocation both preempted allocations and was preempted itself, both preemptions sections are shown', async function (assert) {
   611      allocation = server.create('allocation', 'preempter', 'preempted');
   612      await Allocation.visit({ id: allocation.id });
   613      assert.ok(
   614        Allocation.preempted,
   615        'The allocations this allocation preempted are shown'
   616      );
   617      assert.ok(Allocation.wasPreempted, 'Preempted allocation section is shown');
   618    });
   619  });
   620  
   621  module('Acceptance | allocation detail (services)', function (hooks) {
   622    setupApplicationTest(hooks);
   623    setupMirage(hooks);
   624  
   625    hooks.beforeEach(async function () {
   626      server.create('feature', { name: 'Dynamic Application Sizing' });
   627      server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
   628      server.createList('node', 5);
   629      server.createList('job', 1, { createRecommendations: true });
   630      const job = server.create('job', {
   631        withGroupServices: true,
   632        withTaskServices: true,
   633        name: 'Service-haver',
   634        id: 'service-haver',
   635        namespaceId: 'default',
   636      });
   637  
   638      const currentAlloc = server.db.allocations.findBy({ jobId: job.id });
   639      const otherAlloc = server.db.allocations.reject((j) => j.jobId !== job.id);
   640  
   641      server.db.serviceFragments.update({
   642        healthChecks: [
   643          {
   644            Status: 'success',
   645            Check: 'check1',
   646            Timestamp: 99,
   647            Alloc: currentAlloc.id,
   648          },
   649          {
   650            Status: 'failure',
   651            Check: 'check2',
   652            Output: 'One',
   653            propThatDoesntMatter:
   654              'this object will be ignored, since it shared a Check name with a later one.',
   655            Timestamp: 98,
   656            Alloc: currentAlloc.id,
   657          },
   658          {
   659            Status: 'success',
   660            Check: 'check2',
   661            Output: 'Two',
   662            Timestamp: 99,
   663            Alloc: currentAlloc.id,
   664          },
   665          {
   666            Status: 'failure',
   667            Check: 'check3',
   668            Output: 'Oh no!',
   669            Timestamp: 99,
   670            Alloc: currentAlloc.id,
   671          },
   672          {
   673            Status: 'success',
   674            Check: 'check3',
   675            Output: 'Wont be seen',
   676            propThatDoesntMatter:
   677              'this object will be ignored, in spite of its later timestamp, since it exists on a different alloc',
   678            Timestamp: 100,
   679            Alloc: otherAlloc.id,
   680          },
   681        ],
   682      });
   683    });
   684  
   685    test('Allocation has a list of services with active checks', async function (assert) {
   686      await visit('jobs/service-haver@default');
   687      await click('.allocation-row');
   688      assert.dom('[data-test-service]').exists();
   689      assert.dom('.service-sidebar').exists();
   690      assert.dom('.service-sidebar').doesNotHaveClass('open');
   691      assert
   692        .dom('[data-test-service-status-bar]')
   693        .exists('At least one allocation has service health');
   694      await click('[data-test-service-status-bar]');
   695      assert.dom('.service-sidebar').hasClass('open');
   696      assert
   697        .dom('table.health-checks tr[data-service-health="success"]')
   698        .exists({ count: 2 }, 'Two successful health checks');
   699      assert
   700        .dom('table.health-checks tr[data-service-health="failure"]')
   701        .exists({ count: 1 }, 'One failing health check');
   702      assert
   703        .dom(
   704          'table.health-checks tr[data-service-health="failure"] td.service-output'
   705        )
   706        .containsText('Oh no!');
   707  
   708      await triggerEvent('.page-layout', 'keydown', { key: 'Escape' });
   709      assert.dom('.service-sidebar').doesNotHaveClass('open');
   710    });
   711  });