github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/evaluations-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 import { 8 click, 9 currentRouteName, 10 currentURL, 11 typeIn, 12 visit, 13 waitFor, 14 waitUntil, 15 } from '@ember/test-helpers'; 16 import { module, test } from 'qunit'; 17 import { setupApplicationTest } from 'ember-qunit'; 18 import { setupMirage } from 'ember-cli-mirage/test-support'; 19 import { Response } from 'ember-cli-mirage'; 20 import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; 21 import { 22 selectChoose, 23 clickTrigger, 24 } from 'ember-power-select/test-support/helpers'; 25 import { generateAcceptanceTestEvalMock } from '../../mirage/utils'; 26 import percySnapshot from '@percy/ember'; 27 import faker from 'nomad-ui/mirage/faker'; 28 29 const getStandardRes = () => [ 30 { 31 CreateIndex: 1249, 32 CreateTime: 1640181894162724000, 33 DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', 34 ID: '5fb1b8cd-00f8-fff8-de0c-197dc37f5053', 35 JobID: 'cores-example', 36 JobModifyIndex: 694, 37 ModifyIndex: 1251, 38 ModifyTime: 1640181894167194000, 39 Namespace: 'ted-lasso', 40 Priority: 50, 41 QueuedAllocations: { 42 lb: 0, 43 webapp: 0, 44 }, 45 SnapshotIndex: 1249, 46 Status: 'complete', 47 TriggeredBy: 'job-register', 48 Type: 'service', 49 }, 50 { 51 CreateIndex: 1304, 52 CreateTime: 1640183201719510000, 53 DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', 54 ID: '66cb98a6-7740-d5ef-37e4-fa0f8b1de44b', 55 JobID: 'cores-example', 56 JobModifyIndex: 1304, 57 ModifyIndex: 1306, 58 ModifyTime: 1640183201721418000, 59 Namespace: 'default', 60 Priority: 50, 61 QueuedAllocations: { 62 webapp: 0, 63 lb: 0, 64 }, 65 SnapshotIndex: 1304, 66 Status: 'complete', 67 TriggeredBy: 'job-register', 68 Type: 'service', 69 }, 70 { 71 CreateIndex: 1267, 72 CreateTime: 1640182198255685000, 73 DeploymentID: '12efbb28-840e-7794-b215-a7b112e40a4f', 74 ID: '78009518-574d-eee6-919a-e83879175dd3', 75 JobID: 'cores-example', 76 JobModifyIndex: 1250, 77 ModifyIndex: 1274, 78 ModifyTime: 1640182228112823000, 79 Namespace: 'ted-lasso', 80 PreviousEval: '84f1082f-3e6e-034d-6df4-c6a321e7bd63', 81 Priority: 50, 82 QueuedAllocations: { 83 lb: 0, 84 }, 85 SnapshotIndex: 1272, 86 Status: 'complete', 87 TriggeredBy: 'alloc-failure', 88 Type: 'service', 89 WaitUntil: '2021-12-22T14:10:28.108136Z', 90 }, 91 { 92 CreateIndex: 1322, 93 CreateTime: 1640183505760099000, 94 DeploymentID: '878435bf-7265-62b1-7902-d45c44b23b79', 95 ID: 'c184f72b-68a3-5180-afd6-af01860ad371', 96 JobID: 'cores-example', 97 JobModifyIndex: 1305, 98 ModifyIndex: 1329, 99 ModifyTime: 1640183535540881000, 100 Namespace: 'default', 101 PreviousEval: '9a917a93-7bc3-6991-ffc9-15919a38f04b', 102 Priority: 50, 103 QueuedAllocations: { 104 lb: 0, 105 }, 106 SnapshotIndex: 1326, 107 Status: 'complete', 108 TriggeredBy: 'alloc-failure', 109 Type: 'service', 110 WaitUntil: '2021-12-22T14:32:15.539556Z', 111 }, 112 ]; 113 114 module('Acceptance | evaluations list', function (hooks) { 115 setupApplicationTest(hooks); 116 setupMirage(hooks); 117 118 test('it passes an accessibility audit', async function (assert) { 119 assert.expect(2); 120 121 await visit('/evaluations'); 122 123 assert.equal( 124 currentRouteName(), 125 'evaluations.index', 126 'The default route in evaluations is evaluations index' 127 ); 128 129 await a11yAudit(assert); 130 }); 131 132 test('it renders an empty message if there are no evaluations rendered', async function (assert) { 133 faker.seed(1); 134 135 await visit('/evaluations'); 136 assert.expect(2); 137 138 await percySnapshot(assert); 139 140 assert 141 .dom('[data-test-empty-evaluations-list]') 142 .exists('We display empty table message.'); 143 assert 144 .dom('[data-test-no-eval]') 145 .exists('We display a message saying there are no evaluations.'); 146 }); 147 148 test('it renders a list of evaluations', async function (assert) { 149 faker.seed(1); 150 assert.expect(3); 151 server.get('/evaluations', function (_server, fakeRequest) { 152 assert.deepEqual( 153 fakeRequest.queryParams, 154 { 155 namespace: '*', 156 per_page: '25', 157 next_token: '', 158 filter: '', 159 reverse: 'true', 160 }, 161 'Forwards the correct query parameters on default query when route initially loads' 162 ); 163 return getStandardRes(); 164 }); 165 166 await visit('/evaluations'); 167 168 await percySnapshot(assert); 169 170 assert 171 .dom('[data-test-eval-table]') 172 .exists('Evaluations table should render'); 173 assert 174 .dom('[data-test-evaluation]') 175 .exists({ count: 4 }, 'Should render the correct number of evaluations'); 176 }); 177 178 module('filters', function () { 179 test('it should enable filtering by evaluation status', async function (assert) { 180 assert.expect(2); 181 182 server.get('/evaluations', getStandardRes); 183 184 await visit('/evaluations'); 185 186 server.get('/evaluations', function (_server, fakeRequest) { 187 assert.deepEqual( 188 fakeRequest.queryParams, 189 { 190 namespace: '*', 191 per_page: '25', 192 next_token: '', 193 filter: 'Status contains "pending"', 194 reverse: 'true', 195 }, 196 'It makes another server request using the options selected by the user' 197 ); 198 return []; 199 }); 200 201 await clickTrigger('[data-test-evaluation-status-facet]'); 202 await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); 203 204 assert 205 .dom('[data-test-no-eval-match]') 206 .exists('Renders a message saying no evaluations match filter status'); 207 }); 208 209 test('it should enable filtering by namespace', async function (assert) { 210 assert.expect(2); 211 212 server.get('/evaluations', getStandardRes); 213 214 await visit('/evaluations'); 215 216 server.get('/evaluations', function (_server, fakeRequest) { 217 assert.deepEqual( 218 fakeRequest.queryParams, 219 { 220 namespace: 'default', 221 per_page: '25', 222 next_token: '', 223 filter: '', 224 reverse: 'true', 225 }, 226 'It makes another server request using the options selected by the user' 227 ); 228 return []; 229 }); 230 231 await clickTrigger('[data-test-evaluation-namespace-facet]'); 232 await selectChoose('[data-test-evaluation-namespace-facet]', 'default'); 233 234 assert 235 .dom('[data-test-empty-evaluations-list]') 236 .exists('Renders a message saying no evaluations match filter status'); 237 }); 238 239 test('it should enable filtering by triggered by', async function (assert) { 240 assert.expect(2); 241 242 server.get('/evaluations', getStandardRes); 243 244 await visit('/evaluations'); 245 246 server.get('/evaluations', function (_server, fakeRequest) { 247 assert.deepEqual( 248 fakeRequest.queryParams, 249 { 250 namespace: '*', 251 per_page: '25', 252 next_token: '', 253 filter: `TriggeredBy contains "periodic-job"`, 254 reverse: 'true', 255 }, 256 'It makes another server request using the options selected by the user' 257 ); 258 return []; 259 }); 260 261 await clickTrigger('[data-test-evaluation-triggered-by-facet]'); 262 await selectChoose( 263 '[data-test-evaluation-triggered-by-facet]', 264 'Periodic Job' 265 ); 266 267 assert 268 .dom('[data-test-empty-evaluations-list]') 269 .exists('Renders a message saying no evaluations match filter status'); 270 }); 271 272 test('it should enable filtering by type', async function (assert) { 273 assert.expect(2); 274 275 server.get('/evaluations', getStandardRes); 276 277 await visit('/evaluations'); 278 279 server.get('/evaluations', function (_server, fakeRequest) { 280 assert.deepEqual( 281 fakeRequest.queryParams, 282 { 283 namespace: '*', 284 per_page: '25', 285 next_token: '', 286 filter: 'NodeID is not empty', 287 reverse: 'true', 288 }, 289 'It makes another server request using the options selected by the user' 290 ); 291 return []; 292 }); 293 294 await clickTrigger('[data-test-evaluation-type-facet]'); 295 await selectChoose('[data-test-evaluation-type-facet]', 'Client'); 296 297 assert 298 .dom('[data-test-empty-evaluations-list]') 299 .exists('Renders a message saying no evaluations match filter status'); 300 }); 301 302 test('it should enable filtering by search term', async function (assert) { 303 assert.expect(2); 304 305 server.get('/evaluations', getStandardRes); 306 307 await visit('/evaluations'); 308 309 const searchTerm = 'Lasso'; 310 server.get('/evaluations', function (_server, fakeRequest) { 311 assert.deepEqual( 312 fakeRequest.queryParams, 313 { 314 namespace: '*', 315 per_page: '25', 316 next_token: '', 317 filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, 318 reverse: 'true', 319 }, 320 'It makes another server request using the options selected by the user' 321 ); 322 return []; 323 }); 324 325 await typeIn('[data-test-evaluations-search] input', searchTerm); 326 327 assert 328 .dom('[data-test-empty-evaluations-list]') 329 .exists('Renders a message saying no evaluations match filter status'); 330 }); 331 332 test('it should enable combining filters and search', async function (assert) { 333 assert.expect(5); 334 335 server.get('/evaluations', getStandardRes); 336 337 await visit('/evaluations'); 338 339 const searchTerm = 'Lasso'; 340 server.get('/evaluations', function (_server, fakeRequest) { 341 assert.deepEqual( 342 fakeRequest.queryParams, 343 { 344 namespace: '*', 345 per_page: '25', 346 next_token: '', 347 filter: `ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}"`, 348 reverse: 'true', 349 }, 350 'It makes another server request using the options selected by the user' 351 ); 352 return []; 353 }); 354 await typeIn('[data-test-evaluations-search] input', searchTerm); 355 356 server.get('/evaluations', function (_server, fakeRequest) { 357 assert.deepEqual( 358 fakeRequest.queryParams, 359 { 360 namespace: '*', 361 per_page: '25', 362 next_token: '', 363 filter: `(ID contains "${searchTerm}" or JobID contains "${searchTerm}" or NodeID contains "${searchTerm}" or TriggeredBy contains "${searchTerm}") and NodeID is not empty`, 364 reverse: 'true', 365 }, 366 'It makes another server request using the options selected by the user' 367 ); 368 return []; 369 }); 370 await clickTrigger('[data-test-evaluation-type-facet]'); 371 await selectChoose('[data-test-evaluation-type-facet]', 'Client'); 372 373 server.get('/evaluations', function (_server, fakeRequest) { 374 assert.deepEqual( 375 fakeRequest.queryParams, 376 { 377 namespace: '*', 378 per_page: '25', 379 next_token: '', 380 filter: `NodeID is not empty`, 381 reverse: 'true', 382 }, 383 'It makes another server request using the options selected by the user' 384 ); 385 return []; 386 }); 387 await click('[data-test-evaluations-search] button'); 388 389 server.get('/evaluations', function (_server, fakeRequest) { 390 assert.deepEqual( 391 fakeRequest.queryParams, 392 { 393 namespace: '*', 394 per_page: '25', 395 next_token: '', 396 filter: `NodeID is not empty and Status contains "complete"`, 397 reverse: 'true', 398 }, 399 'It makes another server request using the options selected by the user' 400 ); 401 return []; 402 }); 403 await clickTrigger('[data-test-evaluation-status-facet]'); 404 await selectChoose('[data-test-evaluation-status-facet]', 'Complete'); 405 406 assert 407 .dom('[data-test-empty-evaluations-list]') 408 .exists('Renders a message saying no evaluations match filter status'); 409 }); 410 }); 411 412 module('page size', function (hooks) { 413 hooks.afterEach(function () { 414 // PageSizeSelect and the Evaluations Controller are both using localStorage directly 415 // Will come back and invert the dependency 416 window.localStorage.clear(); 417 }); 418 419 test('it is possible to change page size', async function (assert) { 420 assert.expect(1); 421 422 server.get('/evaluations', getStandardRes); 423 424 await visit('/evaluations'); 425 426 server.get('/evaluations', function (_server, fakeRequest) { 427 assert.deepEqual( 428 fakeRequest.queryParams, 429 { 430 namespace: '*', 431 per_page: '50', 432 next_token: '', 433 filter: '', 434 reverse: 'true', 435 }, 436 'It makes a request with the per_page set by the user' 437 ); 438 return getStandardRes(); 439 }); 440 441 await clickTrigger('[data-test-per-page]'); 442 await selectChoose('[data-test-per-page]', 50); 443 }); 444 }); 445 446 module('pagination', function () { 447 test('it should enable pagination by using next tokens', async function (assert) { 448 assert.expect(7); 449 450 server.get('/evaluations', function () { 451 return new Response( 452 200, 453 { 'x-nomad-nexttoken': 'next-token-1' }, 454 getStandardRes() 455 ); 456 }); 457 458 await visit('/evaluations'); 459 460 server.get('/evaluations', function (_server, fakeRequest) { 461 assert.deepEqual( 462 fakeRequest.queryParams, 463 { 464 namespace: '*', 465 per_page: '25', 466 next_token: 'next-token-1', 467 filter: '', 468 reverse: 'true', 469 }, 470 'It makes another server request using the options selected by the user' 471 ); 472 return new Response( 473 200, 474 { 'x-nomad-nexttoken': 'next-token-2' }, 475 getStandardRes() 476 ); 477 }); 478 479 assert 480 .dom('[data-test-eval-pagination-next]') 481 .isEnabled( 482 'If there is a next-token in the API response the next button should be enabled.' 483 ); 484 await click('[data-test-eval-pagination-next]'); 485 486 server.get('/evaluations', function (_server, fakeRequest) { 487 assert.deepEqual( 488 fakeRequest.queryParams, 489 { 490 namespace: '*', 491 per_page: '25', 492 next_token: 'next-token-2', 493 filter: '', 494 reverse: 'true', 495 }, 496 'It makes another server request using the options selected by the user' 497 ); 498 return getStandardRes(); 499 }); 500 await click('[data-test-eval-pagination-next]'); 501 502 assert 503 .dom('[data-test-eval-pagination-next]') 504 .isDisabled('If there is no next-token, the next button is disabled.'); 505 506 assert 507 .dom('[data-test-eval-pagination-prev]') 508 .isEnabled( 509 'After we transition to the next page, the previous page button is enabled.' 510 ); 511 512 server.get('/evaluations', function (_server, fakeRequest) { 513 assert.deepEqual( 514 fakeRequest.queryParams, 515 { 516 namespace: '*', 517 per_page: '25', 518 next_token: 'next-token-1', 519 filter: '', 520 reverse: 'true', 521 }, 522 'It makes a request using the stored old token.' 523 ); 524 return new Response( 525 200, 526 { 'x-nomad-nexttoken': 'next-token-2' }, 527 getStandardRes() 528 ); 529 }); 530 531 await click('[data-test-eval-pagination-prev]'); 532 533 server.get('/evaluations', function (_server, fakeRequest) { 534 assert.deepEqual( 535 fakeRequest.queryParams, 536 { 537 namespace: '*', 538 per_page: '25', 539 next_token: '', 540 filter: '', 541 reverse: 'true', 542 }, 543 'When there are no more stored previous tokens, we will request with no next-token.' 544 ); 545 return new Response( 546 200, 547 { 'x-nomad-nexttoken': 'next-token-1' }, 548 getStandardRes() 549 ); 550 }); 551 552 await click('[data-test-eval-pagination-prev]'); 553 }); 554 555 test('it should clear all query parameters on refresh', async function (assert) { 556 assert.expect(1); 557 558 server.get('/evaluations', function () { 559 return new Response( 560 200, 561 { 'x-nomad-nexttoken': 'next-token-1' }, 562 getStandardRes() 563 ); 564 }); 565 566 await visit('/evaluations'); 567 568 server.get('/evaluations', function () { 569 return getStandardRes(); 570 }); 571 572 await click('[data-test-eval-pagination-next]'); 573 574 await clickTrigger('[data-test-evaluation-status-facet]'); 575 await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); 576 577 server.get('/evaluations', function (_server, fakeRequest) { 578 assert.deepEqual( 579 fakeRequest.queryParams, 580 { 581 namespace: '*', 582 per_page: '25', 583 next_token: '', 584 filter: '', 585 reverse: 'true', 586 }, 587 'It clears all query parameters when making a refresh' 588 ); 589 return new Response( 590 200, 591 { 'x-nomad-nexttoken': 'next-token-1' }, 592 getStandardRes() 593 ); 594 }); 595 596 await click('[data-test-eval-refresh]'); 597 }); 598 599 test('it should reset pagination when filters are applied', async function (assert) { 600 assert.expect(1); 601 602 server.get('/evaluations', function () { 603 return new Response( 604 200, 605 { 'x-nomad-nexttoken': 'next-token-1' }, 606 getStandardRes() 607 ); 608 }); 609 610 await visit('/evaluations'); 611 612 server.get('/evaluations', function () { 613 return new Response( 614 200, 615 { 'x-nomad-nexttoken': 'next-token-2' }, 616 getStandardRes() 617 ); 618 }); 619 620 await click('[data-test-eval-pagination-next]'); 621 622 server.get('/evaluations', getStandardRes); 623 await click('[data-test-eval-pagination-next]'); 624 625 server.get('/evaluations', function (_server, fakeRequest) { 626 assert.deepEqual( 627 fakeRequest.queryParams, 628 { 629 namespace: '*', 630 per_page: '25', 631 next_token: '', 632 filter: 'Status contains "pending"', 633 reverse: 'true', 634 }, 635 'It clears all next token when filtered request is made' 636 ); 637 return getStandardRes(); 638 }); 639 await clickTrigger('[data-test-evaluation-status-facet]'); 640 await selectChoose('[data-test-evaluation-status-facet]', 'Pending'); 641 }); 642 }); 643 644 module('resource linking', function () { 645 test('it should generate a link to the job resource', async function (assert) { 646 server.create('node-pool'); 647 server.create('node'); 648 const job = server.create('job', { id: 'example', shallow: true }); 649 server.create('evaluation', { jobId: job.id }); 650 651 await visit('/evaluations'); 652 assert 653 .dom('[data-test-evaluation-resource]') 654 .hasText( 655 job.name, 656 'It conditionally renders the correct resource name' 657 ); 658 659 await click('[data-test-evaluation-resource]'); 660 assert 661 .dom('[data-test-job-name]') 662 .includesText(job.name, 'We navigate to the correct job page.'); 663 }); 664 665 test('it should generate a link to the node resource', async function (assert) { 666 server.create('node-pool'); 667 const node = server.create('node'); 668 server.create('evaluation', { nodeId: node.id }); 669 await visit('/evaluations'); 670 671 const shortNodeId = node.id.split('-')[0]; 672 assert 673 .dom('[data-test-evaluation-resource]') 674 .hasText( 675 shortNodeId, 676 'It conditionally renders the correct resource name' 677 ); 678 679 await click('[data-test-evaluation-resource]'); 680 681 assert 682 .dom('[data-test-title]') 683 .includesText(node.name, 'We navigate to the correct client page.'); 684 }); 685 }); 686 687 module('evaluation detail', function () { 688 test('clicking an evaluation opens the detail view', async function (assert) { 689 faker.seed(1); 690 server.get('/evaluations', getStandardRes); 691 server.get('/evaluation/:id', function (_, { queryParams, params }) { 692 const expectedNamespaces = ['default', 'ted-lasso']; 693 assert.notEqual( 694 expectedNamespaces.indexOf(queryParams.namespace), 695 -1, 696 'Eval details request has namespace query param' 697 ); 698 699 return { ...generateAcceptanceTestEvalMock(params.id), ID: params.id }; 700 }); 701 702 await visit('/evaluations'); 703 704 const evalId = '5fb1b8cd'; 705 await click(`[data-test-evaluation='${evalId}']`); 706 707 await percySnapshot(assert); 708 709 assert 710 .dom('[data-test-eval-detail-is-open]') 711 .exists( 712 'A sidebar portal mounts to the dom after clicking an evaluation' 713 ); 714 715 assert 716 .dom('[data-test-rel-eval]') 717 .exists( 718 { count: 12 }, 719 'all related evaluations and the current evaluation are displayed' 720 ); 721 722 click(`[data-test-rel-eval='fd1cd898-d655-c7e4-17f6-a1a2e98b18ef']`); 723 await waitFor('[data-test-eval-loading]'); 724 assert 725 .dom('[data-test-eval-loading]') 726 .exists( 727 'transition to loading state after clicking related evaluation' 728 ); 729 730 await waitFor('[data-test-eval-detail-header]'); 731 732 assert.equal( 733 currentURL(), 734 '/evaluations?currentEval=fd1cd898-d655-c7e4-17f6-a1a2e98b18ef' 735 ); 736 assert 737 .dom('[data-test-title]') 738 .includesText('fd1cd898', 'New evaluation hash appears in the title'); 739 740 await click(`[data-test-evaluation='66cb98a6']`); 741 assert.equal( 742 currentURL(), 743 '/evaluations?currentEval=66cb98a6-7740-d5ef-37e4-fa0f8b1de44b', 744 'Clicking an evaluation in the table updates the sidebar' 745 ); 746 747 click('[data-test-eval-sidebar-x]'); 748 749 // We wait until the sidebar closes since it uses a transition of 300ms 750 await waitUntil( 751 () => !document.querySelector('[data-test-eval-detail-is-open]') 752 ); 753 754 assert.equal( 755 currentURL(), 756 '/evaluations', 757 'When the user clicks the x button the sidebar closes' 758 ); 759 }); 760 761 test('it should provide an error state when loading an invalid evaluation', async function (assert) { 762 server.get('/evaluations', getStandardRes); 763 server.get('/evaluation/:id', function () { 764 return new Response(404, {}, ''); 765 }); 766 767 await visit('/evaluations'); 768 769 const evalId = '5fb1b8cd'; 770 await click(`[data-test-evaluation='${evalId}']`); 771 772 assert 773 .dom('[data-test-eval-detail-is-open]') 774 .exists( 775 'A sidebar portal mounts to the dom after clicking an evaluation' 776 ); 777 778 assert 779 .dom('[data-test-eval-error]') 780 .exists( 781 'all related evaluations and the current evaluation are displayed' 782 ); 783 784 click('[data-test-eval-sidebar-x]'); 785 786 // We wait until the sidebar closes since it uses a transition of 300ms 787 await waitUntil( 788 () => !document.querySelector('[data-test-eval-detail-is-open]') 789 ); 790 791 assert.equal( 792 currentURL(), 793 '/evaluations', 794 'When the user clicks the x button the sidebar closes' 795 ); 796 }); 797 }); 798 });