github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/tests/acceptance/jobs-list-test.js (about) 1 /* eslint-disable qunit/require-expect */ 2 import { currentURL } from '@ember/test-helpers'; 3 import { module, test } from 'qunit'; 4 import { setupApplicationTest } from 'ember-qunit'; 5 import { setupMirage } from 'ember-cli-mirage/test-support'; 6 import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; 7 import pageSizeSelect from './behaviors/page-size-select'; 8 import JobsList from 'nomad-ui/tests/pages/jobs/list'; 9 import percySnapshot from '@percy/ember'; 10 import faker from 'nomad-ui/mirage/faker'; 11 12 let managementToken, clientToken; 13 14 module('Acceptance | jobs list', function (hooks) { 15 setupApplicationTest(hooks); 16 setupMirage(hooks); 17 18 hooks.beforeEach(function () { 19 // Required for placing allocations (a result of creating jobs) 20 server.create('node'); 21 22 managementToken = server.create('token'); 23 clientToken = server.create('token'); 24 25 window.localStorage.clear(); 26 window.localStorage.nomadTokenSecret = managementToken.secretId; 27 }); 28 29 test('it passes an accessibility audit', async function (assert) { 30 await JobsList.visit(); 31 await a11yAudit(assert); 32 }); 33 34 test('visiting /jobs', async function (assert) { 35 await JobsList.visit(); 36 37 assert.equal(currentURL(), '/jobs'); 38 assert.equal(document.title, 'Jobs - Nomad'); 39 }); 40 41 test('/jobs should list the first page of jobs sorted by modify index', async function (assert) { 42 faker.seed(1); 43 const jobsCount = JobsList.pageSize + 1; 44 server.createList('job', jobsCount, { createAllocations: false }); 45 46 await JobsList.visit(); 47 48 await percySnapshot(assert); 49 50 const sortedJobs = server.db.jobs.sortBy('modifyIndex').reverse(); 51 assert.equal(JobsList.jobs.length, JobsList.pageSize); 52 JobsList.jobs.forEach((job, index) => { 53 assert.equal(job.name, sortedJobs[index].name, 'Jobs are ordered'); 54 }); 55 }); 56 57 test('each job row should contain information about the job', async function (assert) { 58 server.createList('job', 2); 59 const job = server.db.jobs.sortBy('modifyIndex').reverse()[0]; 60 const taskGroups = server.db.taskGroups.where({ jobId: job.id }); 61 62 await JobsList.visit(); 63 64 const jobRow = JobsList.jobs.objectAt(0); 65 66 assert.equal(jobRow.name, job.name, 'Name'); 67 assert.notOk(jobRow.hasNamespace); 68 assert.equal(jobRow.link, `/ui/jobs/${job.id}@default`, 'Detail Link'); 69 assert.equal(jobRow.status, job.status, 'Status'); 70 assert.equal(jobRow.type, typeForJob(job), 'Type'); 71 assert.equal(jobRow.priority, job.priority, 'Priority'); 72 assert.equal(jobRow.taskGroups, taskGroups.length, '# Groups'); 73 }); 74 75 test('each job row should link to the corresponding job', async function (assert) { 76 server.create('job'); 77 const job = server.db.jobs[0]; 78 79 await JobsList.visit(); 80 await JobsList.jobs.objectAt(0).clickName(); 81 82 assert.equal(currentURL(), `/jobs/${job.id}@default`); 83 }); 84 85 test('the new job button transitions to the new job page', async function (assert) { 86 await JobsList.visit(); 87 await JobsList.runJobButton.click(); 88 89 assert.equal(currentURL(), '/jobs/run'); 90 }); 91 92 test('the job run button is disabled when the token lacks permission', async function (assert) { 93 window.localStorage.nomadTokenSecret = clientToken.secretId; 94 95 await JobsList.visit(); 96 97 assert.ok(JobsList.runJobButton.isDisabled); 98 }); 99 100 test('the anonymous policy is fetched to check whether to show the job run button', async function (assert) { 101 window.localStorage.removeItem('nomadTokenSecret'); 102 103 server.create('policy', { 104 id: 'anonymous', 105 name: 'anonymous', 106 rulesJSON: { 107 Namespaces: [ 108 { 109 Name: 'default', 110 Capabilities: ['list-jobs', 'submit-job'], 111 }, 112 ], 113 }, 114 }); 115 116 await JobsList.visit(); 117 assert.notOk(JobsList.runJobButton.isDisabled); 118 }); 119 120 test('when there are no jobs, there is an empty message', async function (assert) { 121 faker.seed(1); 122 await JobsList.visit(); 123 124 await percySnapshot(assert); 125 126 assert.ok(JobsList.isEmpty, 'There is an empty message'); 127 assert.equal( 128 JobsList.emptyState.headline, 129 'No Jobs', 130 'The message is appropriate' 131 ); 132 }); 133 134 test('when there are jobs, but no matches for a search result, there is an empty message', async function (assert) { 135 server.create('job', { name: 'cat 1' }); 136 server.create('job', { name: 'cat 2' }); 137 138 await JobsList.visit(); 139 140 await JobsList.search.fillIn('dog'); 141 assert.ok(JobsList.isEmpty, 'The empty message is shown'); 142 assert.equal( 143 JobsList.emptyState.headline, 144 'No Matches', 145 'The message is appropriate' 146 ); 147 }); 148 149 test('searching resets the current page', async function (assert) { 150 server.createList('job', JobsList.pageSize + 1, { 151 createAllocations: false, 152 }); 153 154 await JobsList.visit(); 155 await JobsList.nextPage(); 156 157 assert.equal( 158 currentURL(), 159 '/jobs?page=2', 160 'Page query param captures page=2' 161 ); 162 163 await JobsList.search.fillIn('foobar'); 164 165 assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param'); 166 }); 167 168 test('when a cluster has namespaces, each job row includes the job namespace', async function (assert) { 169 server.createList('namespace', 2); 170 server.createList('job', 2); 171 const job = server.db.jobs.sortBy('modifyIndex').reverse()[0]; 172 173 await JobsList.visit({ namespace: '*' }); 174 175 const jobRow = JobsList.jobs.objectAt(0); 176 assert.equal(jobRow.namespace, job.namespaceId); 177 }); 178 179 test('when the namespace query param is set, only matching jobs are shown', async function (assert) { 180 server.createList('namespace', 2); 181 const job1 = server.create('job', { 182 namespaceId: server.db.namespaces[0].id, 183 }); 184 const job2 = server.create('job', { 185 namespaceId: server.db.namespaces[1].id, 186 }); 187 188 await JobsList.visit(); 189 assert.equal(JobsList.jobs.length, 2, 'All jobs by default'); 190 191 const firstNamespace = server.db.namespaces[0]; 192 await JobsList.visit({ namespace: firstNamespace.id }); 193 assert.equal(JobsList.jobs.length, 1, 'One job in the default namespace'); 194 assert.equal( 195 JobsList.jobs.objectAt(0).name, 196 job1.name, 197 'The correct job is shown' 198 ); 199 200 const secondNamespace = server.db.namespaces[1]; 201 await JobsList.visit({ namespace: secondNamespace.id }); 202 203 assert.equal( 204 JobsList.jobs.length, 205 1, 206 `One job in the ${secondNamespace.name} namespace` 207 ); 208 assert.equal( 209 JobsList.jobs.objectAt(0).name, 210 job2.name, 211 'The correct job is shown' 212 ); 213 }); 214 215 test('when accessing jobs is forbidden, show a message with a link to the tokens page', async function (assert) { 216 server.pretender.get('/v1/jobs', () => [403, {}, null]); 217 218 await JobsList.visit(); 219 assert.equal(JobsList.error.title, 'Not Authorized'); 220 221 await JobsList.error.seekHelp(); 222 assert.equal(currentURL(), '/settings/tokens'); 223 }); 224 225 function typeForJob(job) { 226 return job.periodic 227 ? 'periodic' 228 : job.parameterized 229 ? 'parameterized' 230 : job.type; 231 } 232 233 test('the jobs list page has appropriate faceted search options', async function (assert) { 234 await JobsList.visit(); 235 236 assert.ok( 237 JobsList.facets.namespace.isHidden, 238 'Namespace facet not found (no namespaces)' 239 ); 240 assert.ok(JobsList.facets.type.isPresent, 'Type facet found'); 241 assert.ok(JobsList.facets.status.isPresent, 'Status facet found'); 242 assert.ok(JobsList.facets.datacenter.isPresent, 'Datacenter facet found'); 243 assert.ok(JobsList.facets.prefix.isPresent, 'Prefix facet found'); 244 }); 245 246 testSingleSelectFacet('Namespace', { 247 facet: JobsList.facets.namespace, 248 paramName: 'namespace', 249 expectedOptions: ['All (*)', 'default', 'namespace-2'], 250 optionToSelect: 'namespace-2', 251 async beforeEach() { 252 server.create('namespace', { id: 'default' }); 253 server.create('namespace', { id: 'namespace-2' }); 254 server.createList('job', 2, { namespaceId: 'default' }); 255 server.createList('job', 2, { namespaceId: 'namespace-2' }); 256 await JobsList.visit(); 257 }, 258 filter(job, selection) { 259 return job.namespaceId === selection; 260 }, 261 }); 262 263 testFacet('Type', { 264 facet: JobsList.facets.type, 265 paramName: 'type', 266 expectedOptions: [ 267 'Batch', 268 'Parameterized', 269 'Periodic', 270 'Service', 271 'System', 272 'System Batch', 273 ], 274 async beforeEach() { 275 server.createList('job', 2, { createAllocations: false, type: 'batch' }); 276 server.createList('job', 2, { 277 createAllocations: false, 278 type: 'batch', 279 periodic: true, 280 childrenCount: 0, 281 }); 282 server.createList('job', 2, { 283 createAllocations: false, 284 type: 'batch', 285 parameterized: true, 286 childrenCount: 0, 287 }); 288 server.createList('job', 2, { 289 createAllocations: false, 290 type: 'service', 291 }); 292 await JobsList.visit(); 293 }, 294 filter(job, selection) { 295 let displayType = job.type; 296 if (job.parameterized) displayType = 'parameterized'; 297 if (job.periodic) displayType = 'periodic'; 298 return selection.includes(displayType); 299 }, 300 }); 301 302 testFacet('Status', { 303 facet: JobsList.facets.status, 304 paramName: 'status', 305 expectedOptions: ['Pending', 'Running', 'Dead'], 306 async beforeEach() { 307 server.createList('job', 2, { 308 status: 'pending', 309 createAllocations: false, 310 childrenCount: 0, 311 }); 312 server.createList('job', 2, { 313 status: 'running', 314 createAllocations: false, 315 childrenCount: 0, 316 }); 317 server.createList('job', 2, { 318 status: 'dead', 319 createAllocations: false, 320 childrenCount: 0, 321 }); 322 await JobsList.visit(); 323 }, 324 filter: (job, selection) => selection.includes(job.status), 325 }); 326 327 testFacet('Datacenter', { 328 facet: JobsList.facets.datacenter, 329 paramName: 'dc', 330 expectedOptions(jobs) { 331 const allDatacenters = new Set( 332 jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), []) 333 ); 334 return Array.from(allDatacenters).sort(); 335 }, 336 async beforeEach() { 337 server.create('job', { 338 datacenters: ['pdx', 'lax'], 339 createAllocations: false, 340 childrenCount: 0, 341 }); 342 server.create('job', { 343 datacenters: ['pdx', 'ord'], 344 createAllocations: false, 345 childrenCount: 0, 346 }); 347 server.create('job', { 348 datacenters: ['lax', 'jfk'], 349 createAllocations: false, 350 childrenCount: 0, 351 }); 352 server.create('job', { 353 datacenters: ['jfk', 'dfw'], 354 createAllocations: false, 355 childrenCount: 0, 356 }); 357 server.create('job', { 358 datacenters: ['pdx'], 359 createAllocations: false, 360 childrenCount: 0, 361 }); 362 await JobsList.visit(); 363 }, 364 filter: (job, selection) => 365 job.datacenters.find((dc) => selection.includes(dc)), 366 }); 367 368 testFacet('Prefix', { 369 facet: JobsList.facets.prefix, 370 paramName: 'prefix', 371 expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'], 372 async beforeEach() { 373 [ 374 'pre-one', 375 'hashi_one', 376 'nmd.one', 377 'one-alone', 378 'pre_two', 379 'hashi.two', 380 'hashi-three', 381 'nmd_two', 382 'noprefix', 383 ].forEach((name) => { 384 server.create('job', { 385 name, 386 createAllocations: false, 387 childrenCount: 0, 388 }); 389 }); 390 await JobsList.visit(); 391 }, 392 filter: (job, selection) => 393 selection.find((prefix) => job.name.startsWith(prefix)), 394 }); 395 396 test('when the facet selections result in no matches, the empty state states why', async function (assert) { 397 server.createList('job', 2, { 398 status: 'pending', 399 createAllocations: false, 400 childrenCount: 0, 401 }); 402 403 await JobsList.visit(); 404 405 await JobsList.facets.status.toggle(); 406 await JobsList.facets.status.options.objectAt(1).toggle(); 407 assert.ok(JobsList.isEmpty, 'There is an empty message'); 408 assert.equal( 409 JobsList.emptyState.headline, 410 'No Matches', 411 'The message is appropriate' 412 ); 413 }); 414 415 test('the jobs list is immediately filtered based on query params', async function (assert) { 416 server.create('job', { type: 'batch', createAllocations: false }); 417 server.create('job', { type: 'service', createAllocations: false }); 418 419 await JobsList.visit({ type: JSON.stringify(['batch']) }); 420 421 assert.equal( 422 JobsList.jobs.length, 423 1, 424 'Only one job shown due to query param' 425 ); 426 }); 427 428 test('when the user has a client token that has a namespace with a policy to run a job', async function (assert) { 429 const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace'; 430 const READ_ONLY_NAMESPACE = 'read-only-namespace'; 431 432 server.create('namespace', { id: READ_AND_WRITE_NAMESPACE }); 433 server.create('namespace', { id: READ_ONLY_NAMESPACE }); 434 435 const policy = server.create('policy', { 436 id: 'something', 437 name: 'something', 438 rulesJSON: { 439 Namespaces: [ 440 { 441 Name: READ_AND_WRITE_NAMESPACE, 442 Capabilities: ['submit-job'], 443 }, 444 { 445 Name: READ_ONLY_NAMESPACE, 446 Capabilities: ['list-job'], 447 }, 448 ], 449 }, 450 }); 451 452 clientToken.policyIds = [policy.id]; 453 clientToken.save(); 454 455 window.localStorage.nomadTokenSecret = clientToken.secretId; 456 457 await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); 458 assert.notOk(JobsList.runJobButton.isDisabled); 459 460 await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); 461 assert.notOk(JobsList.runJobButton.isDisabled); 462 }); 463 464 test('when the user has no client tokens that allow them to run a job', async function (assert) { 465 const READ_AND_WRITE_NAMESPACE = 'read-and-write-namespace'; 466 const READ_ONLY_NAMESPACE = 'read-only-namespace'; 467 468 server.create('namespace', { id: READ_ONLY_NAMESPACE }); 469 470 const policy = server.create('policy', { 471 id: 'something', 472 name: 'something', 473 rulesJSON: { 474 Namespaces: [ 475 { 476 Name: READ_ONLY_NAMESPACE, 477 Capabilities: ['list-job'], 478 }, 479 ], 480 }, 481 }); 482 483 clientToken.policyIds = [policy.id]; 484 clientToken.save(); 485 486 window.localStorage.nomadTokenSecret = clientToken.secretId; 487 488 await JobsList.visit({ namespace: READ_AND_WRITE_NAMESPACE }); 489 assert.ok(JobsList.runJobButton.isDisabled); 490 491 await JobsList.visit({ namespace: READ_ONLY_NAMESPACE }); 492 assert.ok(JobsList.runJobButton.isDisabled); 493 }); 494 495 pageSizeSelect({ 496 resourceName: 'job', 497 pageObject: JobsList, 498 pageObjectList: JobsList.jobs, 499 async setup() { 500 server.createList('job', JobsList.pageSize, { 501 shallow: true, 502 createAllocations: false, 503 }); 504 await JobsList.visit(); 505 }, 506 }); 507 508 async function facetOptions(assert, beforeEach, facet, expectedOptions) { 509 await beforeEach(); 510 await facet.toggle(); 511 512 let expectation; 513 if (typeof expectedOptions === 'function') { 514 expectation = expectedOptions(server.db.jobs); 515 } else { 516 expectation = expectedOptions; 517 } 518 519 assert.deepEqual( 520 facet.options.map((option) => option.label.trim()), 521 expectation, 522 'Options for facet are as expected' 523 ); 524 } 525 526 function testSingleSelectFacet( 527 label, 528 { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect } 529 ) { 530 test(`the ${label} facet has the correct options`, async function (assert) { 531 await facetOptions(assert, beforeEach, facet, expectedOptions); 532 }); 533 534 test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) { 535 await beforeEach(); 536 await facet.toggle(); 537 538 const option = facet.options.findOneBy('label', optionToSelect); 539 const selection = option.key; 540 await option.select(); 541 542 const expectedJobs = server.db.jobs 543 .filter((job) => filter(job, selection)) 544 .sortBy('modifyIndex') 545 .reverse(); 546 547 JobsList.jobs.forEach((job, index) => { 548 assert.equal( 549 job.id, 550 expectedJobs[index].id, 551 `Job at ${index} is ${expectedJobs[index].id}` 552 ); 553 }); 554 }); 555 556 test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function (assert) { 557 await beforeEach(); 558 await facet.toggle(); 559 560 const option = facet.options.objectAt(1); 561 const selection = option.key; 562 await option.select(); 563 564 assert.ok( 565 currentURL().includes(`${paramName}=${selection}`), 566 'URL has the correct query param key and value' 567 ); 568 }); 569 } 570 571 function testFacet( 572 label, 573 { facet, paramName, beforeEach, filter, expectedOptions } 574 ) { 575 test(`the ${label} facet has the correct options`, async function (assert) { 576 await facetOptions(assert, beforeEach, facet, expectedOptions); 577 }); 578 579 test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) { 580 let option; 581 582 await beforeEach(); 583 await facet.toggle(); 584 585 option = facet.options.objectAt(0); 586 await option.toggle(); 587 588 const selection = [option.key]; 589 const expectedJobs = server.db.jobs 590 .filter((job) => filter(job, selection)) 591 .sortBy('modifyIndex') 592 .reverse(); 593 594 JobsList.jobs.forEach((job, index) => { 595 assert.equal( 596 job.id, 597 expectedJobs[index].id, 598 `Job at ${index} is ${expectedJobs[index].id}` 599 ); 600 }); 601 }); 602 603 test(`selecting multiple options in the ${label} facet results in a broader search`, async function (assert) { 604 const selection = []; 605 606 await beforeEach(); 607 await facet.toggle(); 608 609 const option1 = facet.options.objectAt(0); 610 const option2 = facet.options.objectAt(1); 611 await option1.toggle(); 612 selection.push(option1.key); 613 await option2.toggle(); 614 selection.push(option2.key); 615 616 const expectedJobs = server.db.jobs 617 .filter((job) => filter(job, selection)) 618 .sortBy('modifyIndex') 619 .reverse(); 620 621 JobsList.jobs.forEach((job, index) => { 622 assert.equal( 623 job.id, 624 expectedJobs[index].id, 625 `Job at ${index} is ${expectedJobs[index].id}` 626 ); 627 }); 628 }); 629 630 test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) { 631 const selection = []; 632 633 await beforeEach(); 634 await facet.toggle(); 635 636 const option1 = facet.options.objectAt(0); 637 const option2 = facet.options.objectAt(1); 638 await option1.toggle(); 639 selection.push(option1.key); 640 await option2.toggle(); 641 selection.push(option2.key); 642 643 assert.ok( 644 currentURL().includes(encodeURIComponent(JSON.stringify(selection))), 645 'URL has the correct query param key and value' 646 ); 647 }); 648 649 test('the run job button works when filters are set', async function (assert) { 650 ['pre-one', 'pre-two', 'pre-three'].forEach((name) => { 651 server.create('job', { 652 name, 653 createAllocations: false, 654 childrenCount: 0, 655 }); 656 }); 657 658 await JobsList.visit(); 659 660 await JobsList.facets.prefix.toggle(); 661 await JobsList.facets.prefix.options[0].toggle(); 662 663 await JobsList.runJobButton.click(); 664 assert.equal(currentURL(), '/jobs/run'); 665 }); 666 } 667 });