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  });