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