github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/client-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 /* eslint-disable qunit/no-conditional-assertions */ 8 /* Mirage fixtures are random so we can't expect a set number of assertions */ 9 import { 10 currentURL, 11 waitUntil, 12 settled, 13 click, 14 fillIn, 15 triggerEvent, 16 findAll, 17 } from '@ember/test-helpers'; 18 import { assign } from '@ember/polyfills'; 19 import { module, test } from 'qunit'; 20 import { setupApplicationTest } from 'ember-qunit'; 21 import { setupMirage } from 'ember-cli-mirage/test-support'; 22 import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; 23 import { formatBytes, formatHertz } from 'nomad-ui/utils/units'; 24 import moment from 'moment'; 25 import ClientDetail from 'nomad-ui/tests/pages/clients/detail'; 26 import Clients from 'nomad-ui/tests/pages/clients/list'; 27 import Jobs from 'nomad-ui/tests/pages/jobs/list'; 28 import Layout from 'nomad-ui/tests/pages/layout'; 29 30 let node; 31 let managementToken; 32 let clientToken; 33 34 const wasPreemptedFilter = (allocation) => !!allocation.preemptedByAllocation; 35 36 function nonSearchPOSTS() { 37 return server.pretender.handledRequests 38 .reject((request) => request.url.includes('fuzzy')) 39 .filterBy('method', 'POST'); 40 } 41 42 module('Acceptance | client detail', function (hooks) { 43 setupApplicationTest(hooks); 44 setupMirage(hooks); 45 46 hooks.beforeEach(function () { 47 window.localStorage.clear(); 48 49 server.create('node-pool'); 50 server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' }); 51 node = server.db.nodes[0]; 52 53 managementToken = server.create('token'); 54 clientToken = server.create('token'); 55 56 window.localStorage.nomadTokenSecret = managementToken.secretId; 57 58 // Related models 59 server.create('agent'); 60 server.create('job', { createAllocations: false }); 61 server.createList('allocation', 3); 62 server.create('allocation', 'preempted'); 63 64 // Force all allocations into the running state so now allocation rows are missing 65 // CPU/Mem runtime metrics 66 server.schema.allocations.all().models.forEach((allocation) => { 67 allocation.update({ clientStatus: 'running' }); 68 }); 69 }); 70 71 test('it passes an accessibility audit', async function (assert) { 72 await ClientDetail.visit({ id: node.id }); 73 await a11yAudit(assert); 74 }); 75 76 test('/clients/:id should have a breadcrumb trail linking back to clients', async function (assert) { 77 await ClientDetail.visit({ id: node.id }); 78 79 assert.ok(document.title.includes(`Client ${node.name}`)); 80 81 assert.equal( 82 Layout.breadcrumbFor('clients.index').text, 83 'Clients', 84 'First breadcrumb says clients' 85 ); 86 assert.equal( 87 Layout.breadcrumbFor('clients.client').text, 88 `Client ${node.id.split('-')[0]}`, 89 'Second breadcrumb is a titled breadcrumb saying the node short id' 90 ); 91 await Layout.breadcrumbFor('clients.index').visit(); 92 assert.equal( 93 currentURL(), 94 '/clients', 95 'First breadcrumb links back to clients' 96 ); 97 }); 98 99 test('/clients/:id should list immediate details for the node in the title', async function (assert) { 100 node = server.create('node', 'forceIPv4', { 101 schedulingEligibility: 'eligible', 102 drain: false, 103 }); 104 105 await ClientDetail.visit({ id: node.id }); 106 107 assert.ok(ClientDetail.title.includes(node.name), 'Title includes name'); 108 assert.ok(ClientDetail.clientId.includes(node.id), 'Title includes id'); 109 assert.equal( 110 ClientDetail.statusLight.objectAt(0).id, 111 node.status, 112 'Title includes status light' 113 ); 114 }); 115 116 test('/clients/:id should list additional detail for the node below the title', async function (assert) { 117 await ClientDetail.visit({ id: node.id }); 118 119 assert.ok( 120 ClientDetail.statusDefinition.includes(node.status), 121 'Status is in additional details' 122 ); 123 assert.ok( 124 ClientDetail.statusDecorationClass.includes(`node-${node.status}`), 125 'Status is decorated with a status class' 126 ); 127 assert.ok( 128 ClientDetail.addressDefinition.includes(node.httpAddr), 129 'Address is in additional details' 130 ); 131 assert.ok( 132 ClientDetail.datacenterDefinition.includes(node.datacenter), 133 'Datacenter is in additional details' 134 ); 135 }); 136 137 test('/clients/:id should include resource utilization graphs', async function (assert) { 138 await ClientDetail.visit({ id: node.id }); 139 140 assert.equal( 141 ClientDetail.resourceCharts.length, 142 2, 143 'Two resource utilization graphs' 144 ); 145 assert.equal( 146 ClientDetail.resourceCharts.objectAt(0).name, 147 'CPU', 148 'First chart is CPU' 149 ); 150 assert.equal( 151 ClientDetail.resourceCharts.objectAt(1).name, 152 'Memory', 153 'Second chart is Memory' 154 ); 155 }); 156 157 test('/clients/:id should list all allocations on the node', async function (assert) { 158 const allocationsCount = server.db.allocations.where({ 159 nodeId: node.id, 160 }).length; 161 162 await ClientDetail.visit({ id: node.id }); 163 164 assert.equal( 165 ClientDetail.allocations.length, 166 allocationsCount, 167 `Allocations table lists all ${allocationsCount} associated allocations` 168 ); 169 }); 170 171 test('/clients/:id should show empty message if there are no allocations on the node', async function (assert) { 172 const emptyNode = server.create('node'); 173 174 await ClientDetail.visit({ id: emptyNode.id }); 175 176 assert.true( 177 ClientDetail.emptyAllocations.isVisible, 178 'Empty message is visible' 179 ); 180 assert.equal(ClientDetail.emptyAllocations.headline, 'No Allocations'); 181 }); 182 183 test('each allocation should have high-level details for the allocation', async function (assert) { 184 const allocation = server.db.allocations 185 .where({ nodeId: node.id }) 186 .sortBy('modifyIndex') 187 .reverse()[0]; 188 189 const allocStats = server.db.clientAllocationStats.find(allocation.id); 190 const taskGroup = server.db.taskGroups.findBy({ 191 name: allocation.taskGroup, 192 jobId: allocation.jobId, 193 }); 194 195 const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id)); 196 const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0); 197 const memoryUsed = tasks.reduce( 198 (sum, task) => sum + task.resources.MemoryMB, 199 0 200 ); 201 202 await ClientDetail.visit({ id: node.id }); 203 204 const allocationRow = ClientDetail.allocations.objectAt(0); 205 206 assert.equal( 207 allocationRow.shortId, 208 allocation.id.split('-')[0], 209 'Allocation short ID' 210 ); 211 assert.equal( 212 allocationRow.createTime, 213 moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'), 214 'Allocation create time' 215 ); 216 assert.equal( 217 allocationRow.modifyTime, 218 moment(allocation.modifyTime / 1000000).fromNow(), 219 'Allocation modify time' 220 ); 221 assert.equal( 222 allocationRow.status, 223 allocation.clientStatus, 224 'Client status' 225 ); 226 assert.equal( 227 allocationRow.job, 228 server.db.jobs.find(allocation.jobId).name, 229 'Job name' 230 ); 231 assert.ok(allocationRow.taskGroup, 'Task group name'); 232 assert.ok(allocationRow.jobVersion, 'Job Version'); 233 assert.equal(allocationRow.volume, 'Yes', 'Volume'); 234 assert.equal( 235 allocationRow.cpu, 236 Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed, 237 'CPU %' 238 ); 239 const roundedTicks = Math.floor( 240 allocStats.resourceUsage.CpuStats.TotalTicks 241 ); 242 assert.equal( 243 allocationRow.cpuTooltip, 244 `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`, 245 'Detailed CPU information is in a tooltip' 246 ); 247 assert.equal( 248 allocationRow.mem, 249 allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed, 250 'Memory used' 251 ); 252 assert.equal( 253 allocationRow.memTooltip, 254 `${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes( 255 memoryUsed, 256 'MiB' 257 )}`, 258 'Detailed memory information is in a tooltip' 259 ); 260 }); 261 262 test('each allocation should show job information even if the job is incomplete and already in the store', async function (assert) { 263 // First, visit clients to load the allocations for each visible node. 264 // Don't load the job belongsTo of the allocation! Leave it unfulfilled. 265 266 await Clients.visit(); 267 268 // Then, visit jobs to load all jobs, which should implicitly fulfill 269 // the job belongsTo of each allocation pointed at each job. 270 271 await Jobs.visit(); 272 273 // Finally, visit a node to assert that the job name and task group name are 274 // present. This will require reloading the job, since task groups aren't a 275 // part of the jobs list response. 276 277 await ClientDetail.visit({ id: node.id }); 278 279 const allocationRow = ClientDetail.allocations.objectAt(0); 280 const allocation = server.db.allocations 281 .where({ nodeId: node.id }) 282 .sortBy('modifyIndex') 283 .reverse()[0]; 284 285 assert.equal( 286 allocationRow.job, 287 server.db.jobs.find(allocation.jobId).name, 288 'Job name' 289 ); 290 assert.ok( 291 allocationRow.taskGroup.includes(allocation.taskGroup), 292 'Task group name' 293 ); 294 }); 295 296 test('each allocation should link to the allocation detail page', async function (assert) { 297 const allocation = server.db.allocations 298 .where({ nodeId: node.id }) 299 .sortBy('modifyIndex') 300 .reverse()[0]; 301 302 await ClientDetail.visit({ id: node.id }); 303 await ClientDetail.allocations.objectAt(0).visit(); 304 305 assert.equal( 306 currentURL(), 307 `/allocations/${allocation.id}`, 308 'Allocation rows link to allocation detail pages' 309 ); 310 }); 311 312 test('each allocation should link to the job the allocation belongs to', async function (assert) { 313 await ClientDetail.visit({ id: node.id }); 314 315 const allocation = server.db.allocations.where({ nodeId: node.id })[0]; 316 const job = server.db.jobs.find(allocation.jobId); 317 318 await ClientDetail.allocations.objectAt(0).visitJob(); 319 320 assert.equal( 321 currentURL(), 322 `/jobs/${job.id}@default`, 323 'Allocation rows link to the job detail page for the allocation' 324 ); 325 }); 326 327 test('the allocation section should show the count of preempted allocations on the client', async function (assert) { 328 const allocations = server.db.allocations.where({ nodeId: node.id }); 329 330 await ClientDetail.visit({ id: node.id }); 331 332 assert.equal( 333 ClientDetail.allocationFilter.allCount, 334 allocations.length, 335 'All filter/badge shows all allocations count' 336 ); 337 assert.ok( 338 ClientDetail.allocationFilter.preemptionsCount.startsWith( 339 allocations.filter(wasPreemptedFilter).length 340 ), 341 'Preemptions filter/badge shows preempted allocations count' 342 ); 343 }); 344 345 test('clicking the preemption badge filters the allocations table and sets a query param', async function (assert) { 346 const allocations = server.db.allocations.where({ nodeId: node.id }); 347 348 await ClientDetail.visit({ id: node.id }); 349 await ClientDetail.allocationFilter.preemptions(); 350 351 assert.equal( 352 ClientDetail.allocations.length, 353 allocations.filter(wasPreemptedFilter).length, 354 'Only preempted allocations are shown' 355 ); 356 assert.equal( 357 currentURL(), 358 `/clients/${node.id}?preemptions=true`, 359 'Filter is persisted in the URL' 360 ); 361 }); 362 363 test('clicking the total allocations badge resets the filter and removes the query param', async function (assert) { 364 const allocations = server.db.allocations.where({ nodeId: node.id }); 365 366 await ClientDetail.visit({ id: node.id }); 367 await ClientDetail.allocationFilter.preemptions(); 368 await ClientDetail.allocationFilter.all(); 369 370 assert.equal( 371 ClientDetail.allocations.length, 372 allocations.length, 373 'All allocations are shown' 374 ); 375 assert.equal( 376 currentURL(), 377 `/clients/${node.id}`, 378 'Filter is persisted in the URL' 379 ); 380 }); 381 382 test('navigating directly to the client detail page with the preemption query param set will filter the allocations table', async function (assert) { 383 const allocations = server.db.allocations.where({ nodeId: node.id }); 384 385 await ClientDetail.visit({ id: node.id, preemptions: true }); 386 387 assert.equal( 388 ClientDetail.allocations.length, 389 allocations.filter(wasPreemptedFilter).length, 390 'Only preempted allocations are shown' 391 ); 392 }); 393 394 test('/clients/:id should list all attributes for the node', async function (assert) { 395 await ClientDetail.visit({ id: node.id }); 396 397 assert.ok(ClientDetail.attributesTable, 'Attributes table is on the page'); 398 }); 399 400 test('/clients/:id lists all meta attributes', async function (assert) { 401 node = server.create('node', 'forceIPv4', 'withMeta'); 402 403 await ClientDetail.visit({ id: node.id }); 404 405 assert.ok(ClientDetail.metaTable, 'Meta attributes table is on the page'); 406 assert.notOk(ClientDetail.emptyMetaMessage, 'Meta attributes is not empty'); 407 408 const firstMetaKey = Object.keys(node.meta)[0]; 409 const firstMetaAttribute = ClientDetail.metaAttributes.objectAt(0); 410 assert.equal( 411 firstMetaAttribute.key, 412 firstMetaKey, 413 'Meta attributes for the node are bound to the attributes table' 414 ); 415 assert.equal( 416 firstMetaAttribute.value, 417 node.meta[firstMetaKey], 418 'Meta attributes for the node are bound to the attributes table' 419 ); 420 }); 421 422 test('node metadata is uneditable by default', async function (assert) { 423 window.localStorage.nomadTokenSecret = clientToken.secretId; 424 node = server.create('node', 'forceIPv4', 'withMeta'); 425 await ClientDetail.visit({ id: node.id }); 426 427 assert.dom('.edit-existing-metadata-button').exists({ count: 0 }); 428 assert.dom('.add-dynamic-metadata').doesNotExist(); 429 }); 430 431 test('node metadata is editable by managers', async function (assert) { 432 window.localStorage.nomadTokenSecret = managementToken.secretId; 433 node = server.create('node', 'forceIPv4', 'withMeta'); 434 await ClientDetail.visit({ id: node.id }); 435 436 const numberOfExistingMetaKeys = Object.keys(node.meta).length; 437 assert 438 .dom('.edit-existing-metadata-button') 439 .exists({ count: numberOfExistingMetaKeys }); 440 assert.dom('.add-dynamic-metadata').exists(); 441 }); 442 443 test('metadata can be added and removed', async function (assert) { 444 window.localStorage.nomadTokenSecret = managementToken.secretId; 445 node = server.create('node', 'forceIPv4', 'withMeta'); 446 await ClientDetail.visit({ id: node.id }); 447 448 const numberOfExistingMetaKeys = Object.keys(node.meta).length; 449 assert 450 .dom('.edit-existing-metadata-button') 451 .exists({ count: numberOfExistingMetaKeys }); 452 assert.dom('.add-dynamic-metadata').exists(); 453 await click('.add-dynamic-metadata button'); 454 assert.dom('[data-test-new-metadata-button]').isDisabled(); 455 await fillIn('#new-meta-key', 'newKey'); 456 await fillIn('[data-test-metadata-editor-value]', 'newValue'); 457 assert.dom('[data-test-new-metadata-button]').isNotDisabled(); 458 await click('[data-test-new-metadata-button]'); 459 assert 460 .dom('.edit-existing-metadata-button') 461 .exists( 462 { count: numberOfExistingMetaKeys + 1 }, 463 'newly added item appears' 464 ); 465 466 // find the newly added one and edit it 467 assert.dom('.metadata-editor').doesNotExist(); 468 const newMetaRow = [...findAll('[data-test-attributes-section]')].filter( 469 (a) => a.textContent.includes('newKey') 470 )[0]; 471 472 await click(newMetaRow.querySelector('.edit-existing-metadata-button')); 473 assert.dom('.metadata-editor').exists(); 474 assert.dom('.constant-key').exists('existing key shown but uneditable'); 475 await click('[data-test-delete-metadata]'); 476 assert 477 .dom('.edit-existing-metadata-button') 478 .exists({ count: numberOfExistingMetaKeys }, 'newly added item is gone'); 479 }); 480 481 test('metadata can be edited', async function (assert) { 482 window.localStorage.nomadTokenSecret = managementToken.secretId; 483 node = server.create( 484 'node', 485 { 486 meta: { 487 existingKey: 'existingValue', 488 'existing.nested.foo': '1', 489 'existing.nested.bar': '2', 490 }, 491 }, 492 'forceIPv4', 493 'withMeta' 494 ); 495 await ClientDetail.visit({ id: node.id }); 496 497 const numberOfExistingMetaKeys = Object.keys(node.meta).length; 498 assert 499 .dom('.edit-existing-metadata-button') 500 .exists({ count: numberOfExistingMetaKeys }); 501 502 const topLevelMetaRow = [ 503 ...findAll('[data-test-attributes-section]'), 504 ].filter((a) => a.textContent.includes('existingKey'))[0]; 505 506 await click( 507 topLevelMetaRow.querySelector('.edit-existing-metadata-button') 508 ); 509 assert.dom('.metadata-editor').exists(); 510 assert.dom('.constant-key').exists('existing key shown but uneditable'); 511 assert.dom('[data-test-metadata-editor-value]').hasValue('existingValue'); 512 await fillIn('[data-test-metadata-editor-value]', 'newValue'); 513 await click('[data-test-update-metadata]'); 514 assert.dom('.metadata-editor').doesNotExist(); 515 const editedRow = [...findAll('[data-test-attributes-section]')].filter( 516 (a) => a.textContent.includes('existingKey') 517 )[0]; 518 assert.dom(editedRow).containsText('newValue', 'value updated'); 519 520 // Cancellable by click 521 await click(editedRow.querySelector('.edit-existing-metadata-button')); 522 assert.dom('.metadata-editor').exists(); 523 await click('[data-test-cancel-metadata]'); 524 assert.dom('.metadata-editor').doesNotExist(); 525 526 // Cancellable by typing escape 527 await click(editedRow.querySelector('.edit-existing-metadata-button')); 528 assert.dom('.metadata-editor').exists(); 529 await triggerEvent('[data-test-metadata-editor-value]', 'keyup', { 530 key: 'Escape', 531 }); 532 assert.dom('.metadata-editor').doesNotExist(); 533 }); 534 535 test('/clients/:id shows an empty message when there is no meta data', async function (assert) { 536 await ClientDetail.visit({ id: node.id }); 537 538 assert.notOk( 539 ClientDetail.metaTable, 540 'Meta attributes table is not on the page' 541 ); 542 assert.ok(ClientDetail.emptyMetaMessage, 'Meta attributes is empty'); 543 }); 544 545 test('when the node is not found, an error message is shown, but the URL persists', async function (assert) { 546 await ClientDetail.visit({ id: 'not-a-real-node' }); 547 548 assert.equal( 549 server.pretender.handledRequests 550 .filter((request) => !request.url.includes('policy')) 551 .findBy('status', 404).url, 552 '/v1/node/not-a-real-node', 553 'A request to the nonexistent node is made' 554 ); 555 assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists'); 556 assert.ok(ClientDetail.error.isShown, 'Error message is shown'); 557 assert.equal( 558 ClientDetail.error.title, 559 'Not Found', 560 'Error message is for 404' 561 ); 562 }); 563 564 test('/clients/:id shows the recent events list', async function (assert) { 565 await ClientDetail.visit({ id: node.id }); 566 567 assert.ok(ClientDetail.hasEvents, 'Client events section exists'); 568 }); 569 570 test('each node event shows basic node event information', async function (assert) { 571 const event = server.db.nodeEvents 572 .where({ nodeId: node.id }) 573 .sortBy('time') 574 .reverse()[0]; 575 576 await ClientDetail.visit({ id: node.id }); 577 578 const eventRow = ClientDetail.events.objectAt(0); 579 assert.equal( 580 eventRow.time, 581 moment(event.time).format("MMM DD, 'YY HH:mm:ss ZZ"), 582 'Event timestamp' 583 ); 584 assert.equal(eventRow.subsystem, event.subsystem, 'Event subsystem'); 585 assert.equal(eventRow.message, event.message, 'Event message'); 586 }); 587 588 test('/clients/:id shows the driver status of every driver for the node', async function (assert) { 589 // Set the drivers up so health and detection is well tested 590 const nodeDrivers = node.drivers; 591 const undetectedDriver = 'raw_exec'; 592 593 Object.values(nodeDrivers).forEach((driver) => { 594 driver.Detected = true; 595 }); 596 597 nodeDrivers[undetectedDriver].Detected = false; 598 node.drivers = nodeDrivers; 599 600 const drivers = Object.keys(node.drivers) 601 .map((driverName) => 602 assign({ Name: driverName }, node.drivers[driverName]) 603 ) 604 .sortBy('Name'); 605 606 assert.ok(drivers.length > 0, 'Node has drivers'); 607 608 await ClientDetail.visit({ id: node.id }); 609 610 drivers.forEach((driver, index) => { 611 const driverHead = ClientDetail.driverHeads.objectAt(index); 612 613 assert.equal( 614 driverHead.name, 615 driver.Name, 616 `${driver.Name}: Name is correct` 617 ); 618 assert.equal( 619 driverHead.detected, 620 driver.Detected ? 'Yes' : 'No', 621 `${driver.Name}: Detection is correct` 622 ); 623 assert.equal( 624 driverHead.lastUpdated, 625 moment(driver.UpdateTime).fromNow(), 626 `${driver.Name}: Last updated shows time since now` 627 ); 628 629 if (driver.Name === undetectedDriver) { 630 assert.notOk( 631 driverHead.healthIsShown, 632 `${driver.Name}: No health for the undetected driver` 633 ); 634 } else { 635 assert.equal( 636 driverHead.health, 637 driver.Healthy ? 'Healthy' : 'Unhealthy', 638 `${driver.Name}: Health is correct` 639 ); 640 assert.ok( 641 driverHead.healthClass.includes( 642 driver.Healthy ? 'running' : 'failed' 643 ), 644 `${driver.Name}: Swatch with correct class is shown` 645 ); 646 } 647 }); 648 }); 649 650 test('each driver can be opened to see a message and attributes', async function (assert) { 651 // Only detected drivers can be expanded 652 const nodeDrivers = node.drivers; 653 Object.values(nodeDrivers).forEach((driver) => { 654 driver.Detected = true; 655 }); 656 node.drivers = nodeDrivers; 657 658 const driver = Object.keys(node.drivers) 659 .map((driverName) => 660 assign({ Name: driverName }, node.drivers[driverName]) 661 ) 662 .sortBy('Name')[0]; 663 664 await ClientDetail.visit({ id: node.id }); 665 const driverHead = ClientDetail.driverHeads.objectAt(0); 666 const driverBody = ClientDetail.driverBodies.objectAt(0); 667 668 assert.notOk( 669 driverBody.descriptionIsShown, 670 'Driver health description is not shown' 671 ); 672 assert.notOk( 673 driverBody.attributesAreShown, 674 'Driver attributes section is not shown' 675 ); 676 677 await driverHead.toggle(); 678 assert.equal( 679 driverBody.description, 680 driver.HealthDescription, 681 'Driver health description is now shown' 682 ); 683 assert.ok( 684 driverBody.attributesAreShown, 685 'Driver attributes section is now shown' 686 ); 687 }); 688 689 test('the status light indicates when the node is ineligible for scheduling', async function (assert) { 690 node = server.create('node', { 691 drain: false, 692 schedulingEligibility: 'ineligible', 693 status: 'ready', 694 }); 695 696 await ClientDetail.visit({ id: node.id }); 697 698 assert.equal( 699 ClientDetail.statusLight.objectAt(0).id, 700 'ineligible', 701 'Title status light is in the ineligible state' 702 ); 703 }); 704 705 test('when the node has a drain strategy with a positive deadline, the drain stategy section prints the duration', async function (assert) { 706 const deadline = 5400000000000; // 1.5 hours in nanoseconds 707 const forceDeadline = moment().add(1, 'd'); 708 709 node = server.create('node', { 710 drain: true, 711 schedulingEligibility: 'ineligible', 712 drainStrategy: { 713 Deadline: deadline, 714 ForceDeadline: forceDeadline.toISOString(), 715 IgnoreSystemJobs: false, 716 }, 717 }); 718 719 await ClientDetail.visit({ id: node.id }); 720 721 assert.ok( 722 ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)), 723 'Deadline is shown in a human formatted way' 724 ); 725 726 assert.equal( 727 ClientDetail.drainDetails.deadlineTooltip, 728 forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"), 729 'The tooltip for deadline shows the force deadline as an absolute date' 730 ); 731 732 assert.ok( 733 ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'), 734 'Drain System Jobs state is shown' 735 ); 736 }); 737 738 test('when the node has a drain stategy with no deadline, the drain stategy section mentions that and omits the force deadline', async function (assert) { 739 const deadline = 0; 740 741 node = server.create('node', { 742 drain: true, 743 schedulingEligibility: 'ineligible', 744 drainStrategy: { 745 Deadline: deadline, 746 ForceDeadline: '0001-01-01T00:00:00Z', // null as a date 747 IgnoreSystemJobs: true, 748 }, 749 }); 750 751 await ClientDetail.visit({ id: node.id }); 752 753 assert.notOk( 754 ClientDetail.drainDetails.durationIsShown, 755 'Duration is omitted' 756 ); 757 758 assert.ok( 759 ClientDetail.drainDetails.deadline.includes('No deadline'), 760 'The value for Deadline is "no deadline"' 761 ); 762 763 assert.ok( 764 ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'), 765 'Drain System Jobs state is shown' 766 ); 767 }); 768 769 test('when the node has a drain stategy with a negative deadline, the drain strategy section shows the force badge', async function (assert) { 770 const deadline = -1; 771 772 node = server.create('node', { 773 drain: true, 774 schedulingEligibility: 'ineligible', 775 drainStrategy: { 776 Deadline: deadline, 777 ForceDeadline: '0001-01-01T00:00:00Z', // null as a date 778 IgnoreSystemJobs: false, 779 }, 780 }); 781 782 await ClientDetail.visit({ id: node.id }); 783 784 assert.ok( 785 ClientDetail.drainDetails.forceDrainText.endsWith('Yes'), 786 'Forced Drain is described' 787 ); 788 789 assert.ok( 790 ClientDetail.drainDetails.duration.includes('--'), 791 'Duration is shown but unset' 792 ); 793 794 assert.ok( 795 ClientDetail.drainDetails.deadline.includes('--'), 796 'Deadline is shown but unset' 797 ); 798 799 assert.ok( 800 ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'), 801 'Drain System Jobs state is shown' 802 ); 803 }); 804 805 test('toggling node eligibility disables the toggle and sends the correct POST request', async function (assert) { 806 node = server.create('node', { 807 drain: false, 808 schedulingEligibility: 'eligible', 809 }); 810 811 server.pretender.post( 812 '/v1/node/:id/eligibility', 813 () => [200, {}, ''], 814 true 815 ); 816 817 await ClientDetail.visit({ id: node.id }); 818 assert.ok(ClientDetail.eligibilityToggle.isActive); 819 820 ClientDetail.eligibilityToggle.toggle(); 821 await waitUntil(() => nonSearchPOSTS()); 822 823 assert.ok(ClientDetail.eligibilityToggle.isDisabled); 824 server.pretender.resolve(server.pretender.requestReferences[0].request); 825 826 await settled(); 827 828 assert.notOk(ClientDetail.eligibilityToggle.isActive); 829 assert.notOk(ClientDetail.eligibilityToggle.isDisabled); 830 831 const request = nonSearchPOSTS()[0]; 832 assert.equal(request.url, `/v1/node/${node.id}/eligibility`); 833 assert.deepEqual(JSON.parse(request.requestBody), { 834 NodeID: node.id, 835 Eligibility: 'ineligible', 836 }); 837 838 ClientDetail.eligibilityToggle.toggle(); 839 await waitUntil(() => nonSearchPOSTS().length === 2); 840 server.pretender.resolve(server.pretender.requestReferences[0].request); 841 842 assert.ok(ClientDetail.eligibilityToggle.isActive); 843 const request2 = nonSearchPOSTS()[1]; 844 845 assert.equal(request2.url, `/v1/node/${node.id}/eligibility`); 846 assert.deepEqual(JSON.parse(request2.requestBody), { 847 NodeID: node.id, 848 Eligibility: 'eligible', 849 }); 850 }); 851 852 test('starting a drain sends the correct POST request', async function (assert) { 853 let request; 854 855 node = server.create('node', { 856 drain: false, 857 schedulingEligibility: 'eligible', 858 }); 859 860 await ClientDetail.visit({ id: node.id }); 861 await ClientDetail.drainPopover.toggle(); 862 await ClientDetail.drainPopover.submit(); 863 864 request = nonSearchPOSTS().pop(); 865 866 assert.equal(request.url, `/v1/node/${node.id}/drain`); 867 assert.deepEqual( 868 JSON.parse(request.requestBody), 869 { 870 NodeID: node.id, 871 DrainSpec: { 872 Deadline: 0, 873 IgnoreSystemJobs: false, 874 }, 875 }, 876 'Drain with default settings' 877 ); 878 879 await ClientDetail.drainPopover.toggle(); 880 await ClientDetail.drainPopover.deadlineToggle.toggle(); 881 await ClientDetail.drainPopover.submit(); 882 883 request = nonSearchPOSTS().pop(); 884 885 assert.deepEqual( 886 JSON.parse(request.requestBody), 887 { 888 NodeID: node.id, 889 DrainSpec: { 890 Deadline: 60 * 60 * 1000 * 1000000, 891 IgnoreSystemJobs: false, 892 }, 893 }, 894 'Drain with deadline toggled' 895 ); 896 897 await ClientDetail.drainPopover.toggle(); 898 await ClientDetail.drainPopover.deadlineOptions.open(); 899 await ClientDetail.drainPopover.deadlineOptions.options[1].choose(); 900 await ClientDetail.drainPopover.submit(); 901 902 request = nonSearchPOSTS().pop(); 903 904 assert.deepEqual( 905 JSON.parse(request.requestBody), 906 { 907 NodeID: node.id, 908 DrainSpec: { 909 Deadline: 4 * 60 * 60 * 1000 * 1000000, 910 IgnoreSystemJobs: false, 911 }, 912 }, 913 'Drain with non-default preset deadline set' 914 ); 915 916 await ClientDetail.drainPopover.toggle(); 917 await ClientDetail.drainPopover.deadlineOptions.open(); 918 const optionsCount = 919 ClientDetail.drainPopover.deadlineOptions.options.length; 920 await ClientDetail.drainPopover.deadlineOptions.options 921 .objectAt(optionsCount - 1) 922 .choose(); 923 await ClientDetail.drainPopover.setCustomDeadline('1h40m20s'); 924 await ClientDetail.drainPopover.submit(); 925 926 request = nonSearchPOSTS().pop(); 927 928 assert.deepEqual( 929 JSON.parse(request.requestBody), 930 { 931 NodeID: node.id, 932 DrainSpec: { 933 Deadline: ((1 * 60 + 40) * 60 + 20) * 1000 * 1000000, 934 IgnoreSystemJobs: false, 935 }, 936 }, 937 'Drain with custom deadline set' 938 ); 939 940 await ClientDetail.drainPopover.toggle(); 941 await ClientDetail.drainPopover.deadlineToggle.toggle(); 942 await ClientDetail.drainPopover.forceDrainToggle.toggle(); 943 await ClientDetail.drainPopover.submit(); 944 945 request = nonSearchPOSTS().pop(); 946 947 assert.deepEqual( 948 JSON.parse(request.requestBody), 949 { 950 NodeID: node.id, 951 DrainSpec: { 952 Deadline: -1, 953 IgnoreSystemJobs: false, 954 }, 955 }, 956 'Drain with force set' 957 ); 958 959 await ClientDetail.drainPopover.toggle(); 960 await ClientDetail.drainPopover.systemJobsToggle.toggle(); 961 await ClientDetail.drainPopover.submit(); 962 963 request = nonSearchPOSTS().pop(); 964 965 assert.deepEqual( 966 JSON.parse(request.requestBody), 967 { 968 NodeID: node.id, 969 DrainSpec: { 970 Deadline: -1, 971 IgnoreSystemJobs: true, 972 }, 973 }, 974 'Drain system jobs unset' 975 ); 976 }); 977 978 test('starting a drain persists options to localstorage', async function (assert) { 979 const nodes = server.createList('node', 2, { 980 drain: false, 981 schedulingEligibility: 'eligible', 982 }); 983 984 await ClientDetail.visit({ id: nodes[0].id }); 985 await ClientDetail.drainPopover.toggle(); 986 987 // Change all options to non-default values. 988 await ClientDetail.drainPopover.deadlineToggle.toggle(); 989 await ClientDetail.drainPopover.deadlineOptions.open(); 990 const optionsCount = 991 ClientDetail.drainPopover.deadlineOptions.options.length; 992 await ClientDetail.drainPopover.deadlineOptions.options 993 .objectAt(optionsCount - 1) 994 .choose(); 995 await ClientDetail.drainPopover.setCustomDeadline('1h40m20s'); 996 await ClientDetail.drainPopover.forceDrainToggle.toggle(); 997 await ClientDetail.drainPopover.systemJobsToggle.toggle(); 998 999 await ClientDetail.drainPopover.submit(); 1000 1001 const got = JSON.parse(window.localStorage.nomadDrainOptions); 1002 const want = { 1003 deadlineEnabled: true, 1004 customDuration: '1h40m20s', 1005 selectedDurationQuickOption: { label: 'Custom', value: 'custom' }, 1006 drainSystemJobs: false, 1007 forceDrain: true, 1008 }; 1009 assert.deepEqual(got, want); 1010 1011 // Visit another node and check that drain config is persisted. 1012 await ClientDetail.visit({ id: nodes[1].id }); 1013 await ClientDetail.drainPopover.toggle(); 1014 assert.true(ClientDetail.drainPopover.deadlineToggle.isActive); 1015 assert.equal(ClientDetail.drainPopover.customDeadline, '1h40m20s'); 1016 assert.true(ClientDetail.drainPopover.forceDrainToggle.isActive); 1017 assert.false(ClientDetail.drainPopover.systemJobsToggle.isActive); 1018 }); 1019 1020 test('the drain popover cancel button closes the popover', async function (assert) { 1021 node = server.create('node', { 1022 drain: false, 1023 schedulingEligibility: 'eligible', 1024 }); 1025 1026 await ClientDetail.visit({ id: node.id }); 1027 assert.notOk(ClientDetail.drainPopover.isOpen); 1028 1029 await ClientDetail.drainPopover.toggle(); 1030 assert.ok(ClientDetail.drainPopover.isOpen); 1031 1032 await ClientDetail.drainPopover.cancel(); 1033 assert.notOk(ClientDetail.drainPopover.isOpen); 1034 assert.equal(nonSearchPOSTS(), 0); 1035 }); 1036 1037 test('toggling eligibility is disabled while a drain is active', async function (assert) { 1038 node = server.create('node', { 1039 drain: true, 1040 schedulingEligibility: 'ineligible', 1041 }); 1042 1043 await ClientDetail.visit({ id: node.id }); 1044 assert.ok(ClientDetail.eligibilityToggle.isDisabled); 1045 }); 1046 1047 test('stopping a drain sends the correct POST request', async function (assert) { 1048 node = server.create('node', { 1049 drain: true, 1050 schedulingEligibility: 'ineligible', 1051 }); 1052 1053 await ClientDetail.visit({ id: node.id }); 1054 assert.ok(ClientDetail.stopDrainIsPresent); 1055 1056 await ClientDetail.stopDrain.idle(); 1057 await ClientDetail.stopDrain.confirm(); 1058 1059 const request = nonSearchPOSTS()[0]; 1060 assert.equal(request.url, `/v1/node/${node.id}/drain`); 1061 assert.deepEqual(JSON.parse(request.requestBody), { 1062 NodeID: node.id, 1063 DrainSpec: null, 1064 }); 1065 }); 1066 1067 test('when a drain is active, the "drain" popover is labeled as the "update" popover', async function (assert) { 1068 node = server.create('node', { 1069 drain: true, 1070 schedulingEligibility: 'ineligible', 1071 }); 1072 1073 await ClientDetail.visit({ id: node.id }); 1074 assert.equal(ClientDetail.drainPopover.label, 'Update Drain'); 1075 }); 1076 1077 test('forcing a drain sends the correct POST request', async function (assert) { 1078 node = server.create('node', { 1079 drain: true, 1080 schedulingEligibility: 'ineligible', 1081 drainStrategy: { 1082 Deadline: 0, 1083 IgnoreSystemJobs: true, 1084 }, 1085 }); 1086 1087 await ClientDetail.visit({ id: node.id }); 1088 await ClientDetail.drainDetails.force.idle(); 1089 await ClientDetail.drainDetails.force.confirm(); 1090 1091 const request = nonSearchPOSTS()[0]; 1092 assert.equal(request.url, `/v1/node/${node.id}/drain`); 1093 assert.deepEqual(JSON.parse(request.requestBody), { 1094 NodeID: node.id, 1095 DrainSpec: { 1096 Deadline: -1, 1097 IgnoreSystemJobs: true, 1098 }, 1099 }); 1100 }); 1101 1102 test('when stopping a drain fails, an error is shown', async function (assert) { 1103 node = server.create('node', { 1104 drain: true, 1105 schedulingEligibility: 'ineligible', 1106 }); 1107 1108 server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']); 1109 1110 await ClientDetail.visit({ id: node.id }); 1111 await ClientDetail.stopDrain.idle(); 1112 await ClientDetail.stopDrain.confirm(); 1113 1114 assert.ok(ClientDetail.stopDrainError.isPresent); 1115 assert.ok(ClientDetail.stopDrainError.title.includes('Stop Drain Error')); 1116 1117 await ClientDetail.stopDrainError.dismiss(); 1118 assert.notOk(ClientDetail.stopDrainError.isPresent); 1119 }); 1120 1121 test('when starting a drain fails, an error message is shown', async function (assert) { 1122 node = server.create('node', { 1123 drain: false, 1124 schedulingEligibility: 'eligible', 1125 }); 1126 1127 server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']); 1128 1129 await ClientDetail.visit({ id: node.id }); 1130 await ClientDetail.drainPopover.toggle(); 1131 await ClientDetail.drainPopover.submit(); 1132 1133 assert.ok(ClientDetail.drainError.isPresent); 1134 assert.ok(ClientDetail.drainError.title.includes('Drain Error')); 1135 1136 await ClientDetail.drainError.dismiss(); 1137 assert.notOk(ClientDetail.drainError.isPresent); 1138 }); 1139 1140 test('when updating a drain fails, an error message is shown', async function (assert) { 1141 node = server.create('node', { 1142 drain: true, 1143 schedulingEligibility: 'ineligible', 1144 }); 1145 1146 server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']); 1147 1148 await ClientDetail.visit({ id: node.id }); 1149 await ClientDetail.drainPopover.toggle(); 1150 await ClientDetail.drainPopover.submit(); 1151 1152 assert.ok(ClientDetail.drainError.isPresent); 1153 assert.ok(ClientDetail.drainError.title.includes('Drain Error')); 1154 1155 await ClientDetail.drainError.dismiss(); 1156 assert.notOk(ClientDetail.drainError.isPresent); 1157 }); 1158 1159 test('when toggling eligibility fails, an error message is shown', async function (assert) { 1160 node = server.create('node', { 1161 drain: false, 1162 schedulingEligibility: 'eligible', 1163 }); 1164 1165 server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']); 1166 1167 await ClientDetail.visit({ id: node.id }); 1168 await ClientDetail.eligibilityToggle.toggle(); 1169 1170 assert.ok(ClientDetail.eligibilityError.isPresent); 1171 assert.ok( 1172 ClientDetail.eligibilityError.title.includes('Eligibility Error') 1173 ); 1174 1175 await ClientDetail.eligibilityError.dismiss(); 1176 assert.notOk(ClientDetail.eligibilityError.isPresent); 1177 }); 1178 1179 test('when navigating away from a client that has an error message to another client, the error is not shown', async function (assert) { 1180 node = server.create('node', { 1181 drain: false, 1182 schedulingEligibility: 'eligible', 1183 }); 1184 1185 const node2 = server.create('node'); 1186 1187 server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']); 1188 1189 await ClientDetail.visit({ id: node.id }); 1190 await ClientDetail.eligibilityToggle.toggle(); 1191 1192 assert.ok(ClientDetail.eligibilityError.isPresent); 1193 assert.ok( 1194 ClientDetail.eligibilityError.title.includes('Eligibility Error') 1195 ); 1196 1197 await ClientDetail.visit({ id: node2.id }); 1198 1199 assert.notOk(ClientDetail.eligibilityError.isPresent); 1200 }); 1201 1202 test('toggling eligibility and node drain are disabled when the active ACL token does not permit node write', async function (assert) { 1203 window.localStorage.nomadTokenSecret = clientToken.secretId; 1204 1205 await ClientDetail.visit({ id: node.id }); 1206 assert.ok(ClientDetail.eligibilityToggle.isDisabled); 1207 assert.ok(ClientDetail.drainPopover.isDisabled); 1208 }); 1209 1210 test('the host volumes table lists all host volumes in alphabetical order by name', async function (assert) { 1211 await ClientDetail.visit({ id: node.id }); 1212 1213 const sortedHostVolumes = Object.keys(node.hostVolumes) 1214 .map((key) => node.hostVolumes[key]) 1215 .sortBy('Name'); 1216 1217 assert.ok(ClientDetail.hasHostVolumes); 1218 assert.equal( 1219 ClientDetail.hostVolumes.length, 1220 Object.keys(node.hostVolumes).length 1221 ); 1222 1223 ClientDetail.hostVolumes.forEach((volume, index) => { 1224 assert.equal(volume.name, sortedHostVolumes[index].Name); 1225 }); 1226 }); 1227 1228 test('each host volume row contains information about the host volume', async function (assert) { 1229 await ClientDetail.visit({ id: node.id }); 1230 1231 const sortedHostVolumes = Object.keys(node.hostVolumes) 1232 .map((key) => node.hostVolumes[key]) 1233 .sortBy('Name'); 1234 1235 ClientDetail.hostVolumes[0].as((volume) => { 1236 const volumeRow = sortedHostVolumes[0]; 1237 assert.equal(volume.name, volumeRow.Name); 1238 assert.equal(volume.path, volumeRow.Path); 1239 assert.equal( 1240 volume.permissions, 1241 volumeRow.ReadOnly ? 'Read' : 'Read/Write' 1242 ); 1243 }); 1244 }); 1245 1246 test('the host volumes table is not shown if the client has no host volumes', async function (assert) { 1247 node = server.create('node', 'noHostVolumes'); 1248 1249 await ClientDetail.visit({ id: node.id }); 1250 1251 assert.notOk(ClientDetail.hasHostVolumes); 1252 }); 1253 1254 testFacet('Job', { 1255 facet: ClientDetail.facets.job, 1256 paramName: 'job', 1257 expectedOptions(allocs) { 1258 return Array.from(new Set(allocs.mapBy('jobId'))).sort(); 1259 }, 1260 async beforeEach() { 1261 server.create('node-pool'); 1262 server.createList('job', 5); 1263 await ClientDetail.visit({ id: node.id }); 1264 }, 1265 filter: (alloc, selection) => selection.includes(alloc.jobId), 1266 }); 1267 1268 testFacet('Status', { 1269 facet: ClientDetail.facets.status, 1270 paramName: 'status', 1271 expectedOptions: [ 1272 'Pending', 1273 'Running', 1274 'Complete', 1275 'Failed', 1276 'Lost', 1277 'Unknown', 1278 ], 1279 async beforeEach() { 1280 server.create('node-pool'); 1281 server.createList('job', 5, { createAllocations: false }); 1282 ['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach( 1283 (s) => { 1284 server.createList('allocation', 5, { clientStatus: s }); 1285 } 1286 ); 1287 1288 await ClientDetail.visit({ id: node.id }); 1289 }, 1290 filter: (alloc, selection) => selection.includes(alloc.clientStatus), 1291 }); 1292 1293 test('fiter results with no matches display empty message', async function (assert) { 1294 const job = server.create('job', { createAllocations: false }); 1295 server.create('allocation', { jobId: job.id, clientStatus: 'running' }); 1296 1297 await ClientDetail.visit({ id: node.id }); 1298 const statusFacet = ClientDetail.facets.status; 1299 await statusFacet.toggle(); 1300 await statusFacet.options.objectAt(0).toggle(); 1301 1302 assert.true(ClientDetail.emptyAllocations.isVisible); 1303 assert.equal(ClientDetail.emptyAllocations.headline, 'No Matches'); 1304 }); 1305 }); 1306 1307 module('Acceptance | client detail (multi-namespace)', function (hooks) { 1308 setupApplicationTest(hooks); 1309 setupMirage(hooks); 1310 1311 hooks.beforeEach(function () { 1312 server.create('node-pool'); 1313 server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' }); 1314 node = server.db.nodes[0]; 1315 1316 // Related models 1317 server.create('namespace'); 1318 server.create('namespace', { id: 'other-namespace' }); 1319 1320 server.create('agent'); 1321 1322 // Make a job for each namespace, but have both scheduled on the same node 1323 server.create('job', { 1324 id: 'job-1', 1325 namespaceId: 'default', 1326 createAllocations: false, 1327 }); 1328 server.createList('allocation', 3, { 1329 nodeId: node.id, 1330 jobId: 'job-1', 1331 clientStatus: 'running', 1332 }); 1333 1334 server.create('job', { 1335 id: 'job-2', 1336 namespaceId: 'other-namespace', 1337 createAllocations: false, 1338 }); 1339 server.createList('allocation', 3, { 1340 nodeId: node.id, 1341 jobId: 'job-2', 1342 clientStatus: 'running', 1343 }); 1344 }); 1345 1346 test('when the node has allocations on different namespaces, the associated jobs are fetched correctly', async function (assert) { 1347 window.localStorage.nomadActiveNamespace = 'other-namespace'; 1348 1349 await ClientDetail.visit({ id: node.id }); 1350 1351 assert.equal( 1352 ClientDetail.allocations.length, 1353 server.db.allocations.length, 1354 'All allocations are scheduled on this node' 1355 ); 1356 assert.ok( 1357 server.pretender.handledRequests.findBy('url', '/v1/job/job-1'), 1358 'Job One fetched correctly' 1359 ); 1360 assert.ok( 1361 server.pretender.handledRequests.findBy( 1362 'url', 1363 '/v1/job/job-2?namespace=other-namespace' 1364 ), 1365 'Job Two fetched correctly' 1366 ); 1367 }); 1368 1369 testFacet('Namespace', { 1370 facet: ClientDetail.facets.namespace, 1371 paramName: 'namespace', 1372 expectedOptions(allocs) { 1373 return Array.from(new Set(allocs.mapBy('namespace'))).sort(); 1374 }, 1375 async beforeEach() { 1376 await ClientDetail.visit({ id: node.id }); 1377 }, 1378 filter: (alloc, selection) => selection.includes(alloc.namespace), 1379 }); 1380 1381 test('facet Namespace | selecting namespace filters job options', async function (assert) { 1382 await ClientDetail.visit({ id: node.id }); 1383 1384 const nsFacet = ClientDetail.facets.namespace; 1385 const jobFacet = ClientDetail.facets.job; 1386 1387 // Select both namespaces. 1388 await nsFacet.toggle(); 1389 await nsFacet.options.objectAt(0).toggle(); 1390 await nsFacet.options.objectAt(1).toggle(); 1391 await jobFacet.toggle(); 1392 1393 assert.deepEqual( 1394 jobFacet.options.map((option) => option.label.trim()), 1395 ['job-1', 'job-2'] 1396 ); 1397 1398 // Select juse one namespace. 1399 await nsFacet.toggle(); 1400 await nsFacet.options.objectAt(1).toggle(); // deselect second option 1401 await jobFacet.toggle(); 1402 1403 assert.deepEqual( 1404 jobFacet.options.map((option) => option.label.trim()), 1405 ['job-1'] 1406 ); 1407 }); 1408 }); 1409 1410 function testFacet( 1411 label, 1412 { facet, paramName, beforeEach, filter, expectedOptions } 1413 ) { 1414 test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) { 1415 await beforeEach(); 1416 await facet.toggle(); 1417 1418 let expectation; 1419 if (typeof expectedOptions === 'function') { 1420 expectation = expectedOptions(server.db.allocations); 1421 } else { 1422 expectation = expectedOptions; 1423 } 1424 1425 assert.deepEqual( 1426 facet.options.map((option) => option.label.trim()), 1427 expectation, 1428 'Options for facet are as expected' 1429 ); 1430 }); 1431 1432 test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) { 1433 let option; 1434 1435 await beforeEach(); 1436 1437 await facet.toggle(); 1438 option = facet.options.objectAt(0); 1439 await option.toggle(); 1440 1441 const selection = [option.key]; 1442 const expectedAllocs = server.db.allocations 1443 .filter((alloc) => filter(alloc, selection)) 1444 .sortBy('modifyIndex') 1445 .reverse(); 1446 1447 ClientDetail.allocations.forEach((alloc, index) => { 1448 assert.equal( 1449 alloc.id, 1450 expectedAllocs[index].id, 1451 `Allocation at ${index} is ${expectedAllocs[index].id}` 1452 ); 1453 }); 1454 }); 1455 1456 test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) { 1457 const selection = []; 1458 1459 await beforeEach(); 1460 await facet.toggle(); 1461 1462 const option1 = facet.options.objectAt(0); 1463 const option2 = facet.options.objectAt(1); 1464 await option1.toggle(); 1465 selection.push(option1.key); 1466 await option2.toggle(); 1467 selection.push(option2.key); 1468 1469 const expectedAllocs = server.db.allocations 1470 .filter((alloc) => filter(alloc, selection)) 1471 .sortBy('modifyIndex') 1472 .reverse(); 1473 1474 ClientDetail.allocations.forEach((alloc, index) => { 1475 assert.equal( 1476 alloc.id, 1477 expectedAllocs[index].id, 1478 `Allocation at ${index} is ${expectedAllocs[index].id}` 1479 ); 1480 }); 1481 }); 1482 1483 test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) { 1484 const selection = []; 1485 1486 await beforeEach(); 1487 await facet.toggle(); 1488 1489 const option1 = facet.options.objectAt(0); 1490 const option2 = facet.options.objectAt(1); 1491 await option1.toggle(); 1492 selection.push(option1.key); 1493 await option2.toggle(); 1494 selection.push(option2.key); 1495 1496 assert.equal( 1497 currentURL(), 1498 `/clients/${node.id}?${paramName}=${encodeURIComponent( 1499 JSON.stringify(selection) 1500 )}`, 1501 'URL has the correct query param key and value' 1502 ); 1503 }); 1504 }