github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/job-detail-test.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 /* eslint-disable ember/no-test-module-for */ 7 /* eslint-disable qunit/require-expect */ 8 import { currentURL, settled } from '@ember/test-helpers'; 9 import { module, test } from 'qunit'; 10 import { setupApplicationTest } from 'ember-qunit'; 11 import { setupMirage } from 'ember-cli-mirage/test-support'; 12 import moment from 'moment'; 13 import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; 14 import moduleForJob, { 15 moduleForJobWithClientStatus, 16 } from 'nomad-ui/tests/helpers/module-for-job'; 17 import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; 18 import percySnapshot from '@percy/ember'; 19 20 moduleForJob('Acceptance | job detail (batch)', 'allocations', () => 21 server.create('job', { 22 type: 'batch', 23 shallow: true, 24 noActiveDeployment: true, 25 createAllocations: true, 26 allocStatusDistribution: { 27 running: 1, 28 }, 29 }) 30 ); 31 32 moduleForJob('Acceptance | job detail (system)', 'allocations', () => 33 server.create('job', { 34 type: 'system', 35 shallow: true, 36 noActiveDeployment: true, 37 createAllocations: true, 38 allocStatusDistribution: { 39 running: 1, 40 }, 41 }) 42 ); 43 44 moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () => 45 server.create('job', { 46 type: 'sysbatch', 47 shallow: true, 48 noActiveDeployment: true, 49 createAllocations: true, 50 allocStatusDistribution: { 51 running: 1, 52 failed: 1, 53 }, 54 }) 55 ); 56 57 moduleForJobWithClientStatus( 58 'Acceptance | job detail with client status (sysbatch)', 59 () => 60 server.create('job', { 61 status: 'running', 62 datacenters: ['dc1'], 63 type: 'sysbatch', 64 createAllocations: false, 65 noActiveDeployment: true, 66 }) 67 ); 68 69 moduleForJobWithClientStatus( 70 'Acceptance | job detail with client status (sysbatch with namespace)', 71 () => { 72 const namespace = server.create('namespace', { id: 'test' }); 73 return server.create('job', { 74 status: 'running', 75 datacenters: ['dc1'], 76 type: 'sysbatch', 77 namespaceId: namespace.name, 78 createAllocations: false, 79 noActiveDeployment: true, 80 }); 81 } 82 ); 83 84 moduleForJobWithClientStatus( 85 'Acceptance | job detail with client status (sysbatch with namespace and wildcard dc)', 86 () => { 87 const namespace = server.create('namespace', { id: 'test' }); 88 return server.create('job', { 89 status: 'running', 90 datacenters: ['*'], 91 type: 'sysbatch', 92 namespaceId: namespace.name, 93 createAllocations: false, 94 noActiveDeployment: true, 95 }); 96 } 97 ); 98 99 moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => { 100 const parent = server.create('job', 'periodicSysbatch', { 101 childrenCount: 1, 102 shallow: true, 103 datacenters: ['dc1'], 104 createAllocations: true, 105 allocStatusDistribution: { 106 running: 1, 107 }, 108 noActiveDeployment: true, 109 }); 110 return server.db.jobs.where({ parentId: parent.id })[0]; 111 }); 112 113 moduleForJobWithClientStatus( 114 'Acceptance | job detail with client status (sysbatch child)', 115 () => { 116 const parent = server.create('job', 'periodicSysbatch', { 117 childrenCount: 1, 118 shallow: true, 119 datacenters: ['dc1'], 120 noActiveDeployment: true, 121 }); 122 return server.db.jobs.where({ parentId: parent.id })[0]; 123 } 124 ); 125 126 moduleForJobWithClientStatus( 127 'Acceptance | job detail with client status (sysbatch child with namespace)', 128 () => { 129 const namespace = server.create('namespace', { id: 'test' }); 130 const parent = server.create('job', 'periodicSysbatch', { 131 childrenCount: 1, 132 shallow: true, 133 namespaceId: namespace.name, 134 datacenters: ['dc1'], 135 noActiveDeployment: true, 136 }); 137 return server.db.jobs.where({ parentId: parent.id })[0]; 138 } 139 ); 140 141 moduleForJobWithClientStatus( 142 'Acceptance | job detail with client status (sysbatch child with namespace and wildcard dc)', 143 () => { 144 const namespace = server.create('namespace', { id: 'test' }); 145 const parent = server.create('job', 'periodicSysbatch', { 146 childrenCount: 1, 147 shallow: true, 148 namespaceId: namespace.name, 149 datacenters: ['*'], 150 noActiveDeployment: true, 151 }); 152 return server.db.jobs.where({ parentId: parent.id })[0]; 153 } 154 ); 155 156 moduleForJob( 157 'Acceptance | job detail (periodic)', 158 'children', 159 () => server.create('job', 'periodic', { shallow: true }), 160 { 161 'the default sort is submitTime descending': async function (job, assert) { 162 const mostRecentLaunch = server.db.jobs 163 .where({ parentId: job.id }) 164 .sortBy('submitTime') 165 .reverse()[0]; 166 167 assert.ok(JobDetail.jobsHeader.hasSubmitTime); 168 assert.equal( 169 JobDetail.jobs[0].submitTime, 170 moment(mostRecentLaunch.submitTime / 1000000).format( 171 'MMM DD HH:mm:ss ZZ' 172 ) 173 ); 174 }, 175 "don't display redundant information in children table": async function ( 176 job, 177 assert 178 ) { 179 assert.notOk(JobDetail.jobsHeader.hasNodePool); 180 assert.notOk(JobDetail.jobsHeader.hasPriority); 181 assert.notOk(JobDetail.jobsHeader.hasType); 182 }, 183 } 184 ); 185 186 moduleForJob( 187 'Acceptance | job detail (periodic in namespace)', 188 'children', 189 () => { 190 const namespace = server.create('namespace', { id: 'test' }); 191 const parent = server.create('job', 'periodic', { 192 shallow: true, 193 namespaceId: namespace.name, 194 }); 195 return parent; 196 }, 197 { 198 "don't display namespace in children table": async function (job, assert) { 199 assert.notOk(JobDetail.jobsHeader.hasNamespace); 200 }, 201 } 202 ); 203 204 moduleForJob( 205 'Acceptance | job detail (parameterized)', 206 'children', 207 () => 208 server.create('job', 'parameterized', { 209 shallow: true, 210 noActiveDeployment: true, 211 }), 212 { 213 'the default sort is submitTime descending': async (job, assert) => { 214 const mostRecentLaunch = server.db.jobs 215 .where({ parentId: job.id }) 216 .sortBy('submitTime') 217 .reverse()[0]; 218 219 assert.ok(JobDetail.jobsHeader.hasSubmitTime); 220 assert.equal( 221 JobDetail.jobs[0].submitTime, 222 moment(mostRecentLaunch.submitTime / 1000000).format( 223 'MMM DD HH:mm:ss ZZ' 224 ) 225 ); 226 }, 227 "don't display redundant information in children table": async function ( 228 job, 229 assert 230 ) { 231 assert.notOk(JobDetail.jobsHeader.hasNodePool); 232 assert.notOk(JobDetail.jobsHeader.hasPriority); 233 assert.notOk(JobDetail.jobsHeader.hasType); 234 }, 235 } 236 ); 237 238 moduleForJob( 239 'Acceptance | job detail (parameterized in namespace)', 240 'children', 241 () => { 242 const namespace = server.create('namespace', { id: 'test' }); 243 const parent = server.create('job', 'parameterized', { 244 shallow: true, 245 namespaceId: namespace.name, 246 }); 247 return parent; 248 }, 249 { 250 "don't display namespace in children table": async function (job, assert) { 251 assert.notOk(JobDetail.jobsHeader.hasNamespace); 252 }, 253 } 254 ); 255 256 moduleForJob('Acceptance | job detail (periodic child)', 'allocations', () => { 257 const parent = server.create('job', 'periodic', { 258 childrenCount: 1, 259 shallow: true, 260 createAllocations: true, 261 allocStatusDistribution: { 262 running: 1, 263 }, 264 noActiveDeployment: true, 265 }); 266 return server.db.jobs.where({ parentId: parent.id })[0]; 267 }); 268 269 moduleForJob( 270 'Acceptance | job detail (parameterized child)', 271 'allocations', 272 () => { 273 const parent = server.create('job', 'parameterized', { 274 childrenCount: 1, 275 shallow: true, 276 noActiveDeployment: true, 277 createAllocations: true, 278 allocStatusDistribution: { 279 running: 1, 280 }, 281 }); 282 return server.db.jobs.where({ parentId: parent.id })[0]; 283 } 284 ); 285 286 moduleForJob( 287 'Acceptance | job detail (service)', 288 'allocations', 289 () => server.create('job', { type: 'service', noActiveDeployment: true }), 290 { 291 'the subnav links to deployment': async (job, assert) => { 292 await JobDetail.tabFor('deployments').visit(); 293 assert.equal(currentURL(), `/jobs/${job.id}/deployments`); 294 }, 295 'when the job is not found, an error message is shown, but the URL persists': 296 async (job, assert) => { 297 await JobDetail.visit({ id: 'not-a-real-job' }); 298 299 assert.equal( 300 server.pretender.handledRequests 301 .filter((request) => !request.url.includes('policy')) 302 .findBy('status', 404).url, 303 '/v1/job/not-a-real-job', 304 'A request to the nonexistent job is made' 305 ); 306 assert.equal(currentURL(), '/jobs/not-a-real-job', 'The URL persists'); 307 assert.ok(JobDetail.error.isPresent, 'Error message is shown'); 308 assert.equal( 309 JobDetail.error.title, 310 'Not Found', 311 'Error message is for 404' 312 ); 313 }, 314 } 315 ); 316 317 module('Acceptance | job detail (with namespaces)', function (hooks) { 318 setupApplicationTest(hooks); 319 setupMirage(hooks); 320 321 let job, managementToken, clientToken; 322 323 hooks.beforeEach(function () { 324 server.createList('namespace', 2); 325 server.create('node-pool'); 326 server.create('node'); 327 job = server.create('job', { 328 type: 'service', 329 status: 'running', 330 namespaceId: server.db.namespaces[1].name, 331 noActiveDeployment: true, 332 }); 333 server.createList('job', 3, { 334 namespaceId: server.db.namespaces[0].name, 335 }); 336 337 managementToken = server.create('token'); 338 clientToken = server.create('token'); 339 }); 340 341 test('it passes an accessibility audit', async function (assert) { 342 const namespace = server.db.namespaces.find(job.namespaceId); 343 await JobDetail.visit({ id: `${job.id}@${namespace.name}` }); 344 await a11yAudit(assert); 345 }); 346 347 test('when there are namespaces, the job detail page states the namespace for the job', async function (assert) { 348 const namespace = server.db.namespaces.find(job.namespaceId); 349 350 await JobDetail.visit({ 351 id: `${job.id}@${namespace.name}`, 352 }); 353 354 assert.ok( 355 JobDetail.statFor('namespace').text, 356 'Namespace included in stats' 357 ); 358 }); 359 360 test('the exec button state can change between namespaces', async function (assert) { 361 const job1 = server.create('job', { 362 status: 'running', 363 namespaceId: server.db.namespaces[0].id, 364 }); 365 const job2 = server.create('job', { 366 status: 'running', 367 namespaceId: server.db.namespaces[1].id, 368 }); 369 370 window.localStorage.nomadTokenSecret = clientToken.secretId; 371 372 const policy = server.create('policy', { 373 id: 'something', 374 name: 'something', 375 rulesJSON: { 376 Namespaces: [ 377 { 378 Name: job1.namespaceId, 379 Capabilities: ['list-jobs', 'alloc-exec'], 380 }, 381 { 382 Name: job2.namespaceId, 383 Capabilities: ['list-jobs'], 384 }, 385 ], 386 }, 387 }); 388 389 clientToken.policyIds = [policy.id]; 390 clientToken.save(); 391 392 await JobDetail.visit({ id: job1.id }); 393 assert.notOk(JobDetail.execButton.isDisabled); 394 395 const secondNamespace = server.db.namespaces[1]; 396 await JobDetail.visit({ id: `${job2.id}@${secondNamespace.name}` }); 397 398 assert.ok(JobDetail.execButton.isDisabled); 399 }); 400 401 test('the anonymous policy is fetched to check whether to show the exec button', async function (assert) { 402 window.localStorage.removeItem('nomadTokenSecret'); 403 404 server.create('policy', { 405 id: 'anonymous', 406 name: 'anonymous', 407 rulesJSON: { 408 Namespaces: [ 409 { 410 Name: 'default', 411 Capabilities: ['list-jobs', 'alloc-exec'], 412 }, 413 ], 414 }, 415 }); 416 417 await JobDetail.visit({ 418 id: `${job.id}@${server.db.namespaces[1].name}`, 419 }); 420 421 assert.notOk(JobDetail.execButton.isDisabled); 422 }); 423 424 test('meta table is displayed if job has meta attributes', async function (assert) { 425 const jobWithMeta = server.create('job', { 426 status: 'running', 427 namespaceId: server.db.namespaces[1].id, 428 meta: { 429 'a.b': 'c', 430 }, 431 }); 432 433 await JobDetail.visit({ 434 id: `${job.id}@${server.db.namespaces[1].name}`, 435 }); 436 437 assert.notOk(JobDetail.metaTable, 'Meta table not present'); 438 439 await JobDetail.visit({ 440 id: `${jobWithMeta.id}@${server.db.namespaces[1].name}`, 441 }); 442 assert.ok(JobDetail.metaTable, 'Meta table is present'); 443 }); 444 445 test('pack details are displayed', async function (assert) { 446 const namespace = server.db.namespaces[1].id; 447 const jobFromPack = server.create('job', { 448 status: 'running', 449 namespaceId: namespace, 450 meta: { 451 'pack.name': 'my-pack', 452 'pack.version': '1.0.0', 453 }, 454 }); 455 456 await JobDetail.visit({ id: `${jobFromPack.id}@${namespace}` }); 457 458 assert.ok(JobDetail.packTag, 'Pack tag is present'); 459 assert.equal( 460 JobDetail.packStatFor('name').text, 461 `Name ${jobFromPack.meta['pack.name']}`, 462 `Pack name is ${jobFromPack.meta['pack.name']}` 463 ); 464 assert.equal( 465 JobDetail.packStatFor('version').text, 466 `Version ${jobFromPack.meta['pack.version']}`, 467 `Pack version is ${jobFromPack.meta['pack.version']}` 468 ); 469 }); 470 471 test('resource recommendations show when they exist and can be expanded, collapsed, and processed', async function (assert) { 472 server.create('feature', { name: 'Dynamic Application Sizing' }); 473 474 job = server.create('job', { 475 type: 'service', 476 status: 'running', 477 namespaceId: server.db.namespaces[1].name, 478 groupsCount: 3, 479 createRecommendations: true, 480 noActiveDeployment: true, 481 }); 482 483 window.localStorage.nomadTokenSecret = managementToken.secretId; 484 await JobDetail.visit({ 485 id: `${job.id}@${server.db.namespaces[1].name}`, 486 }); 487 488 const groupsWithRecommendations = job.taskGroups.filter((group) => 489 group.tasks.models.any((task) => task.recommendations.models.length) 490 ); 491 const jobRecommendationCount = groupsWithRecommendations.length; 492 493 const firstRecommendationGroup = groupsWithRecommendations.models[0]; 494 495 assert.equal(JobDetail.recommendations.length, jobRecommendationCount); 496 497 const recommendation = JobDetail.recommendations[0]; 498 499 assert.equal(recommendation.group, firstRecommendationGroup.name); 500 assert.ok(recommendation.card.isHidden); 501 502 const toggle = recommendation.toggleButton; 503 504 assert.equal(toggle.text, 'Show'); 505 506 await toggle.click(); 507 508 assert.ok(recommendation.card.isPresent); 509 assert.equal(toggle.text, 'Collapse'); 510 511 await toggle.click(); 512 513 assert.ok(recommendation.card.isHidden); 514 515 await toggle.click(); 516 517 assert.equal( 518 recommendation.card.slug.groupName, 519 firstRecommendationGroup.name 520 ); 521 522 await recommendation.card.acceptButton.click(); 523 524 assert.equal(JobDetail.recommendations.length, jobRecommendationCount - 1); 525 526 await JobDetail.tabFor('definition').visit(); 527 await JobDetail.tabFor('overview').visit(); 528 529 assert.equal(JobDetail.recommendations.length, jobRecommendationCount - 1); 530 }); 531 532 test('resource recommendations are not fetched when the feature doesn’t exist', async function (assert) { 533 window.localStorage.nomadTokenSecret = managementToken.secretId; 534 await JobDetail.visit({ 535 id: `${job.id}@${server.db.namespaces[1].name}`, 536 }); 537 538 assert.equal(JobDetail.recommendations.length, 0); 539 540 assert.equal( 541 server.pretender.handledRequests.filter((request) => 542 request.url.includes('recommendations') 543 ).length, 544 0 545 ); 546 }); 547 548 test('when the dynamic autoscaler is applied, you can scale a task within the job detail page', async function (assert) { 549 const SCALE_AND_WRITE_NAMESPACE = 'scale-and-write-namespace'; 550 const READ_ONLY_NAMESPACE = 'read-only-namespace'; 551 const clientToken = server.create('token'); 552 553 const namespace = server.create('namespace', { 554 id: SCALE_AND_WRITE_NAMESPACE, 555 }); 556 const secondNamespace = server.create('namespace', { 557 id: READ_ONLY_NAMESPACE, 558 }); 559 560 job = server.create('job', { 561 groupCount: 0, 562 createAllocations: false, 563 shallow: true, 564 noActiveDeployment: true, 565 namespaceId: SCALE_AND_WRITE_NAMESPACE, 566 }); 567 568 const job2 = server.create('job', { 569 groupCount: 0, 570 createAllocations: false, 571 shallow: true, 572 noActiveDeployment: true, 573 namespaceId: READ_ONLY_NAMESPACE, 574 }); 575 const scalingGroup2 = server.create('task-group', { 576 job: job2, 577 name: 'scaling', 578 count: 1, 579 shallow: true, 580 withScaling: true, 581 }); 582 job2.update({ taskGroupIds: [scalingGroup2.id] }); 583 584 const policy = server.create('policy', { 585 id: 'something', 586 name: 'something', 587 rulesJSON: { 588 Namespaces: [ 589 { 590 Name: SCALE_AND_WRITE_NAMESPACE, 591 Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'], 592 }, 593 { 594 Name: READ_ONLY_NAMESPACE, 595 Capabilities: ['list-jobs', 'read-job'], 596 }, 597 ], 598 }, 599 }); 600 const scalingGroup = server.create('task-group', { 601 job, 602 name: 'scaling', 603 count: 1, 604 shallow: true, 605 withScaling: true, 606 }); 607 job.update({ taskGroupIds: [scalingGroup.id] }); 608 609 clientToken.policyIds = [policy.id]; 610 clientToken.save(); 611 window.localStorage.nomadTokenSecret = clientToken.secretId; 612 613 await JobDetail.visit({ id: `${job.id}@${namespace.name}` }); 614 assert.notOk(JobDetail.incrementButton.isDisabled); 615 616 await JobDetail.visit({ id: `${job2.id}@${secondNamespace.name}` }); 617 assert.ok(JobDetail.incrementButton.isDisabled); 618 }); 619 620 test('handles when a job is remotely purged', async function (assert) { 621 const namespace = server.create('namespace'); 622 const job = server.create('job', { 623 namespaceId: namespace.id, 624 status: 'running', 625 type: 'service', 626 shallow: true, 627 noActiveDeployment: true, 628 createAllocations: true, 629 groupsCount: 1, 630 groupTaskCount: 1, 631 allocStatusDistribution: { 632 running: 1, 633 }, 634 }); 635 636 await JobDetail.visit({ id: `${job.id}@${namespace.id}` }); 637 638 assert.equal(currentURL(), `/jobs/${job.id}%40${namespace.id}`); 639 640 // Simulate a 404 error on the job watcher 641 const controller = this.owner.lookup('controller:jobs.job'); 642 let jobWatcher = controller.watchers.job; 643 jobWatcher.isError = true; 644 jobWatcher.error = { errors: [{ status: '404' }] }; 645 await settled(); 646 647 // User should be booted off the page 648 assert.equal(currentURL(), '/jobs?namespace=*'); 649 650 // A notification should be present 651 assert 652 .dom('.flash-message.alert-critical') 653 .exists('A toast error message pops up.'); 654 655 await percySnapshot(assert); 656 }); 657 658 test('handles when a job is remotely purged, from a job subnav page', async function (assert) { 659 const namespace = server.create('namespace'); 660 const job = server.create('job', { 661 namespaceId: namespace.id, 662 status: 'running', 663 type: 'service', 664 shallow: true, 665 noActiveDeployment: true, 666 createAllocations: true, 667 groupsCount: 1, 668 groupTaskCount: 1, 669 allocStatusDistribution: { 670 running: 1, 671 }, 672 }); 673 674 await JobDetail.visit({ id: `${job.id}@${namespace.id}` }); 675 await JobDetail.tabFor('allocations').visit(); 676 677 assert.equal(currentURL(), `/jobs/${job.id}@${namespace.id}/allocations`); 678 679 // Simulate a 404 error on the job watcher 680 const controller = this.owner.lookup('controller:jobs.job'); 681 let jobWatcher = controller.watchers.job; 682 jobWatcher.isError = true; 683 jobWatcher.error = { errors: [{ status: '404' }] }; 684 await settled(); 685 686 // User should be booted off the page 687 assert.equal(currentURL(), '/jobs?namespace=*'); 688 689 // A notification should be present 690 assert 691 .dom('.flash-message.alert-critical') 692 .exists('A toast error message pops up.'); 693 }); 694 });