github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/tests/acceptance/optimize-test.js (about)

     1  /* eslint-disable qunit/require-expect */
     2  /* eslint-disable qunit/no-conditional-assertions */
     3  import { module, test } from 'qunit';
     4  import { setupApplicationTest } from 'ember-qunit';
     5  import { currentURL, visit } from '@ember/test-helpers';
     6  import { setupMirage } from 'ember-cli-mirage/test-support';
     7  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
     8  import Response from 'ember-cli-mirage/response';
     9  import moment from 'moment';
    10  import { formatBytes, formatHertz, replaceMinus } from 'nomad-ui/utils/units';
    11  
    12  import Optimize from 'nomad-ui/tests/pages/optimize';
    13  import Layout from 'nomad-ui/tests/pages/layout';
    14  import JobsList from 'nomad-ui/tests/pages/jobs/list';
    15  import collapseWhitespace from '../helpers/collapse-whitespace';
    16  
    17  let managementToken, clientToken;
    18  
    19  function getLatestRecommendationSubmitTimeForJob(job) {
    20    const tasks = job.taskGroups.models
    21      .mapBy('tasks.models')
    22      .reduce((tasks, taskModels) => tasks.concat(taskModels), []);
    23    const recommendations = tasks.reduce(
    24      (recommendations, task) =>
    25        recommendations.concat(task.recommendations.models),
    26      []
    27    );
    28    return Math.max(...recommendations.mapBy('submitTime'));
    29  }
    30  
    31  module('Acceptance | optimize', function (hooks) {
    32    setupApplicationTest(hooks);
    33    setupMirage(hooks);
    34  
    35    hooks.beforeEach(async function () {
    36      server.create('feature', { name: 'Dynamic Application Sizing' });
    37  
    38      server.create('node');
    39  
    40      server.createList('namespace', 2);
    41  
    42      const jobs = server.createList('job', 2, {
    43        createRecommendations: true,
    44        groupsCount: 1,
    45        groupTaskCount: 2,
    46        namespaceId: server.db.namespaces[1].id,
    47      });
    48  
    49      jobs.sort((jobA, jobB) => {
    50        return (
    51          getLatestRecommendationSubmitTimeForJob(jobB) -
    52          getLatestRecommendationSubmitTimeForJob(jobA)
    53        );
    54      });
    55  
    56      [this.job1, this.job2] = jobs;
    57  
    58      managementToken = server.create('token');
    59      clientToken = server.create('token');
    60  
    61      window.localStorage.clear();
    62      window.localStorage.nomadTokenSecret = managementToken.secretId;
    63    });
    64  
    65    test('it passes an accessibility audit', async function (assert) {
    66      await Optimize.visit();
    67      await a11yAudit(assert);
    68    });
    69  
    70    test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function (assert) {
    71      const currentTaskGroup = this.job1.taskGroups.models[0];
    72      const nextTaskGroup = this.job2.taskGroups.models[0];
    73  
    74      const currentTaskGroupHasCPURecommendation = currentTaskGroup.tasks.models
    75        .mapBy('recommendations.models')
    76        .flat()
    77        .find((r) => r.resource === 'CPU');
    78  
    79      const currentTaskGroupHasMemoryRecommendation =
    80        currentTaskGroup.tasks.models
    81          .mapBy('recommendations.models')
    82          .flat()
    83          .find((r) => r.resource === 'MemoryMB');
    84  
    85      // If no CPU recommendation, will not be able to accept recommendation with all memory recommendations turned off
    86  
    87      if (!currentTaskGroupHasCPURecommendation) {
    88        const currentTaskGroupTask = currentTaskGroup.tasks.models[0];
    89        this.server.create('recommendation', {
    90          task: currentTaskGroupTask,
    91          resource: 'CPU',
    92        });
    93      }
    94      if (!currentTaskGroupHasMemoryRecommendation) {
    95        const currentTaskGroupTask = currentTaskGroup.tasks.models[0];
    96        this.server.create('recommendation', {
    97          task: currentTaskGroupTask,
    98          resource: 'MemoryMB',
    99        });
   100      }
   101  
   102      await Optimize.visit();
   103  
   104      assert.equal(Layout.breadcrumbFor('optimize').text, 'Recommendations');
   105  
   106      assert.equal(
   107        Optimize.recommendationSummaries[0].slug,
   108        `${this.job1.name} / ${currentTaskGroup.name}`
   109      );
   110  
   111      assert.equal(
   112        Layout.breadcrumbFor('optimize.summary').text,
   113        `${this.job1.name} / ${currentTaskGroup.name}`
   114      );
   115  
   116      assert.equal(
   117        Optimize.recommendationSummaries[0].namespace,
   118        this.job1.namespace
   119      );
   120  
   121      assert.equal(
   122        Optimize.recommendationSummaries[1].slug,
   123        `${this.job2.name} / ${nextTaskGroup.name}`
   124      );
   125  
   126      const currentRecommendations = currentTaskGroup.tasks.models.reduce(
   127        (recommendations, task) =>
   128          recommendations.concat(task.recommendations.models),
   129        []
   130      );
   131      const latestSubmitTime = Math.max(
   132        ...currentRecommendations.mapBy('submitTime')
   133      );
   134  
   135      Optimize.recommendationSummaries[0].as((summary) => {
   136        assert.equal(
   137          summary.date,
   138          moment(new Date(latestSubmitTime / 1000000)).format(
   139            'MMM DD HH:mm:ss ZZ'
   140          )
   141        );
   142  
   143        const currentTaskGroupAllocations = server.schema.allocations.where({
   144          jobId: currentTaskGroup.job.name,
   145          taskGroup: currentTaskGroup.name,
   146        });
   147        assert.equal(summary.allocationCount, currentTaskGroupAllocations.length);
   148  
   149        const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce(
   150          (currentResources, task) => {
   151            currentResources.currCpu += task.resources.CPU;
   152            currentResources.currMem += task.resources.MemoryMB;
   153            return currentResources;
   154          },
   155          { currCpu: 0, currMem: 0 }
   156        );
   157  
   158        const { recCpu, recMem } = currentRecommendations.reduce(
   159          (recommendedResources, recommendation) => {
   160            if (recommendation.resource === 'CPU') {
   161              recommendedResources.recCpu += recommendation.value;
   162            } else {
   163              recommendedResources.recMem += recommendation.value;
   164            }
   165  
   166            return recommendedResources;
   167          },
   168          { recCpu: 0, recMem: 0 }
   169        );
   170  
   171        const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0;
   172        const memDiff = recMem > 0 ? recMem - currMem : 0;
   173  
   174        const cpuSign = cpuDiff > 0 ? '+' : '';
   175        const memSign = memDiff > 0 ? '+' : '';
   176  
   177        const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu);
   178        const memDiffPercent = Math.round((100 * memDiff) / currMem);
   179  
   180        assert.equal(
   181          replaceMinus(summary.cpu),
   182          cpuDiff
   183            ? `${cpuSign}${formatHertz(
   184                cpuDiff,
   185                'MHz'
   186              )} ${cpuSign}${cpuDiffPercent}%`
   187            : ''
   188        );
   189        assert.equal(
   190          replaceMinus(summary.memory),
   191          memDiff
   192            ? `${memSign}${formattedMemDiff(
   193                memDiff
   194              )} ${memSign}${memDiffPercent}%`
   195            : ''
   196        );
   197  
   198        assert.equal(
   199          replaceMinus(summary.aggregateCpu),
   200          cpuDiff
   201            ? `${cpuSign}${formatHertz(
   202                cpuDiff * currentTaskGroupAllocations.length,
   203                'MHz'
   204              )}`
   205            : ''
   206        );
   207  
   208        assert.equal(
   209          replaceMinus(summary.aggregateMemory),
   210          memDiff
   211            ? `${memSign}${formattedMemDiff(
   212                memDiff * currentTaskGroupAllocations.length
   213              )}`
   214            : ''
   215        );
   216      });
   217  
   218      assert.ok(Optimize.recommendationSummaries[0].isActive);
   219      assert.notOk(Optimize.recommendationSummaries[1].isActive);
   220  
   221      assert.equal(Optimize.card.slug.jobName, this.job1.name);
   222      assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name);
   223  
   224      const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory;
   225  
   226      let toggledAnything = true;
   227  
   228      // Toggle off all memory
   229      if (Optimize.card.togglesTable.toggleAllMemory.isPresent) {
   230        await Optimize.card.togglesTable.toggleAllMemory.toggle();
   231  
   232        assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive);
   233        assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive);
   234      } else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) {
   235        await Optimize.card.togglesTable.tasks[0].memory.toggle();
   236      } else {
   237        toggledAnything = false;
   238      }
   239  
   240      assert.equal(
   241        Optimize.recommendationSummaries[0].memory,
   242        summaryMemoryBefore,
   243        'toggling recommendations doesn’t affect the summary table diffs'
   244      );
   245  
   246      const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
   247      const taskIdFilter = (task) => currentTaskIds.includes(task.taskId);
   248  
   249      const cpuRecommendationIds = server.schema.recommendations
   250        .where({ resource: 'CPU' })
   251        .models.filter(taskIdFilter)
   252        .mapBy('id');
   253  
   254      const memoryRecommendationIds = server.schema.recommendations
   255        .where({ resource: 'MemoryMB' })
   256        .models.filter(taskIdFilter)
   257        .mapBy('id');
   258  
   259      const appliedIds = toggledAnything
   260        ? cpuRecommendationIds
   261        : memoryRecommendationIds;
   262      const dismissedIds = toggledAnything ? memoryRecommendationIds : [];
   263  
   264      await Optimize.card.acceptButton.click();
   265  
   266      const request = server.pretender.handledRequests
   267        .filterBy('method', 'POST')
   268        .pop();
   269      const { Apply, Dismiss } = JSON.parse(request.requestBody);
   270  
   271      assert.equal(request.url, '/v1/recommendations/apply');
   272  
   273      assert.deepEqual(Apply, appliedIds);
   274      assert.deepEqual(Dismiss, dismissedIds);
   275  
   276      assert.equal(Optimize.card.slug.jobName, this.job2.name);
   277      assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name);
   278  
   279      assert.ok(Optimize.recommendationSummaries[1].isActive);
   280    });
   281  
   282    test('can navigate between summaries via the table', async function (assert) {
   283      server.createList('job', 10, {
   284        createRecommendations: true,
   285        groupsCount: 1,
   286        groupTaskCount: 2,
   287        namespaceId: server.db.namespaces[1].id,
   288      });
   289  
   290      await Optimize.visit();
   291      await Optimize.recommendationSummaries[1].click();
   292  
   293      assert.equal(
   294        `${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
   295        Optimize.recommendationSummaries[1].slug
   296      );
   297      assert.ok(Optimize.recommendationSummaries[1].isActive);
   298    });
   299  
   300    test('can visit a summary directly via URL', async function (assert) {
   301      server.createList('job', 10, {
   302        createRecommendations: true,
   303        groupsCount: 1,
   304        groupTaskCount: 2,
   305        namespaceId: server.db.namespaces[1].id,
   306      });
   307  
   308      await Optimize.visit();
   309  
   310      const lastSummary =
   311        Optimize.recommendationSummaries[
   312          Optimize.recommendationSummaries.length - 1
   313        ];
   314      const collapsedSlug = lastSummary.slug.replace(' / ', '/');
   315  
   316      // preferable to use page object’s visitable but it encodes the slash
   317      await visit(
   318        `/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`
   319      );
   320  
   321      assert.equal(
   322        `${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
   323        lastSummary.slug
   324      );
   325      assert.ok(lastSummary.isActive);
   326      assert.equal(
   327        currentURL(),
   328        `/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`
   329      );
   330    });
   331  
   332    test('when a summary is not found, an error message is shown, but the URL persists', async function (assert) {
   333      await visit('/optimize/nonexistent/summary?namespace=anamespace');
   334  
   335      assert.equal(
   336        currentURL(),
   337        '/optimize/nonexistent/summary?namespace=anamespace'
   338      );
   339      assert.ok(Optimize.applicationError.isPresent);
   340      assert.equal(Optimize.applicationError.title, 'Not Found');
   341    });
   342  
   343    test('cannot return to already-processed summaries', async function (assert) {
   344      await Optimize.visit();
   345      await Optimize.card.acceptButton.click();
   346  
   347      assert.ok(Optimize.recommendationSummaries[0].isDisabled);
   348  
   349      await Optimize.recommendationSummaries[0].click();
   350  
   351      assert.ok(Optimize.recommendationSummaries[1].isActive);
   352    });
   353  
   354    test('can dismiss a set of recommendations', async function (assert) {
   355      await Optimize.visit();
   356  
   357      const currentTaskGroup = this.job1.taskGroups.models[0];
   358      const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
   359      const taskIdFilter = (task) => currentTaskIds.includes(task.taskId);
   360  
   361      const idsBeforeDismissal = server.schema.recommendations
   362        .all()
   363        .models.filter(taskIdFilter)
   364        .mapBy('id');
   365  
   366      await Optimize.card.dismissButton.click();
   367  
   368      const request = server.pretender.handledRequests
   369        .filterBy('method', 'POST')
   370        .pop();
   371      const { Apply, Dismiss } = JSON.parse(request.requestBody);
   372  
   373      assert.equal(request.url, '/v1/recommendations/apply');
   374  
   375      assert.deepEqual(Apply, []);
   376      assert.deepEqual(Dismiss, idsBeforeDismissal);
   377    });
   378  
   379    test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismissed', async function (assert) {
   380      server.post('/recommendations/apply', function () {
   381        return new Response(500, {}, null);
   382      });
   383  
   384      await Optimize.visit();
   385      await Optimize.card.acceptButton.click();
   386  
   387      assert.ok(Optimize.error.isPresent);
   388      assert.equal(Optimize.error.headline, 'Recommendation error');
   389      assert.equal(
   390        Optimize.error.errors,
   391        'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)'
   392      );
   393  
   394      await Optimize.error.dismiss();
   395      assert.equal(Optimize.card.slug.jobName, this.job2.name);
   396    });
   397  
   398    test('it displays an empty message when there are no recommendations', async function (assert) {
   399      server.db.recommendations.remove();
   400      await Optimize.visit();
   401  
   402      assert.ok(Optimize.empty.isPresent);
   403      assert.equal(Optimize.empty.headline, 'No Recommendations');
   404    });
   405  
   406    test('it displays an empty message after all recommendations have been processed', async function (assert) {
   407      await Optimize.visit();
   408  
   409      await Optimize.card.acceptButton.click();
   410      await Optimize.card.acceptButton.click();
   411  
   412      assert.ok(Optimize.empty.isPresent);
   413    });
   414  
   415    test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function (assert) {
   416      window.localStorage.nomadTokenSecret = clientToken.secretId;
   417      await Optimize.visit();
   418  
   419      assert.equal(currentURL(), '/jobs?namespace=*');
   420      assert.ok(Layout.gutter.optimize.isHidden);
   421    });
   422  
   423    test('it reloads partially-loaded jobs', async function (assert) {
   424      await JobsList.visit();
   425      await Optimize.visit();
   426  
   427      assert.equal(Optimize.recommendationSummaries.length, 2);
   428    });
   429  });
   430  
   431  module('Acceptance | optimize search and facets', function (hooks) {
   432    setupApplicationTest(hooks);
   433    setupMirage(hooks);
   434  
   435    hooks.beforeEach(async function () {
   436      server.create('feature', { name: 'Dynamic Application Sizing' });
   437  
   438      server.create('node');
   439  
   440      server.createList('namespace', 2);
   441  
   442      managementToken = server.create('token');
   443  
   444      window.localStorage.clear();
   445      window.localStorage.nomadTokenSecret = managementToken.secretId;
   446    });
   447  
   448    test('search field narrows summary table results, changes the active summary if it no longer matches, and displays a no matches message when there are none', async function (assert) {
   449      server.create('job', {
   450        name: 'zzzzzz',
   451        createRecommendations: true,
   452        groupsCount: 1,
   453        groupTaskCount: 6,
   454      });
   455  
   456      // Ensure this job’s recommendations are sorted to the top of the table
   457      const futureSubmitTime = (Date.now() + 10000) * 1000000;
   458      server.db.recommendations.update({ submitTime: futureSubmitTime });
   459  
   460      server.create('job', {
   461        name: 'oooooo',
   462        createRecommendations: true,
   463        groupsCount: 2,
   464        groupTaskCount: 4,
   465      });
   466  
   467      server.create('job', {
   468        name: 'pppppp',
   469        createRecommendations: true,
   470        groupsCount: 2,
   471        groupTaskCount: 4,
   472      });
   473  
   474      await Optimize.visit();
   475  
   476      assert.equal(Optimize.card.slug.jobName, 'zzzzzz');
   477  
   478      assert.equal(
   479        collapseWhitespace(Optimize.search.placeholder),
   480        `Search ${Optimize.recommendationSummaries.length} recommendations...`
   481      );
   482  
   483      await Optimize.search.fillIn('ooo');
   484  
   485      assert.equal(Optimize.recommendationSummaries.length, 2);
   486      assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('oooooo'));
   487  
   488      assert.equal(Optimize.card.slug.jobName, 'oooooo');
   489      assert.ok(currentURL().includes('oooooo'));
   490  
   491      await Optimize.search.fillIn('qqq');
   492  
   493      assert.notOk(Optimize.card.isPresent);
   494      assert.ok(Optimize.empty.isPresent);
   495      assert.equal(Optimize.empty.headline, 'No Matches');
   496      assert.equal(currentURL(), '/optimize?search=qqq');
   497  
   498      await Optimize.search.fillIn('');
   499  
   500      assert.equal(Optimize.card.slug.jobName, 'zzzzzz');
   501      assert.ok(Optimize.recommendationSummaries[0].isActive);
   502    });
   503  
   504    test('the namespaces toggle doesn’t show when there aren’t namespaces', async function (assert) {
   505      server.db.namespaces.remove();
   506  
   507      server.create('job', {
   508        createRecommendations: true,
   509        groupsCount: 1,
   510        groupTaskCount: 4,
   511      });
   512  
   513      await Optimize.visit();
   514  
   515      assert.ok(Optimize.facets.namespace.isHidden);
   516    });
   517  
   518    test('processing a summary moves to the next one in the sorted list', async function (assert) {
   519      server.create('job', {
   520        name: 'ooo111',
   521        createRecommendations: true,
   522        groupsCount: 1,
   523        groupTaskCount: 4,
   524      });
   525  
   526      server.create('job', {
   527        name: 'pppppp',
   528        createRecommendations: true,
   529        groupsCount: 1,
   530        groupTaskCount: 4,
   531      });
   532  
   533      server.create('job', {
   534        name: 'ooo222',
   535        createRecommendations: true,
   536        groupsCount: 1,
   537        groupTaskCount: 4,
   538      });
   539  
   540      // Directly set the sorting of the above jobs’s summaries in the table
   541      const futureSubmitTime = (Date.now() + 10000) * 1000000;
   542      const nowSubmitTime = Date.now() * 1000000;
   543      const pastSubmitTime = (Date.now() - 10000) * 1000000;
   544  
   545      const jobNameToRecommendationSubmitTime = {
   546        ooo111: futureSubmitTime,
   547        pppppp: nowSubmitTime,
   548        ooo222: pastSubmitTime,
   549      };
   550  
   551      server.schema.recommendations.all().models.forEach((recommendation) => {
   552        const parentJob = recommendation.task.taskGroup.job;
   553        const submitTimeForJob =
   554          jobNameToRecommendationSubmitTime[parentJob.name];
   555        recommendation.submitTime = submitTimeForJob;
   556        recommendation.save();
   557      });
   558  
   559      await Optimize.visit();
   560      await Optimize.search.fillIn('ooo');
   561      await Optimize.card.acceptButton.click();
   562  
   563      assert.equal(Optimize.card.slug.jobName, 'ooo222');
   564    });
   565  
   566    test('the optimize page has appropriate faceted search options', async function (assert) {
   567      server.createList('job', 4, {
   568        status: 'running',
   569        createRecommendations: true,
   570        childrenCount: 0,
   571      });
   572  
   573      await Optimize.visit();
   574  
   575      assert.ok(Optimize.facets.namespace.isPresent, 'Namespace facet found');
   576      assert.ok(Optimize.facets.type.isPresent, 'Type facet found');
   577      assert.ok(Optimize.facets.status.isPresent, 'Status facet found');
   578      assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found');
   579      assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found');
   580    });
   581  
   582    testSingleSelectFacet('Namespace', {
   583      facet: Optimize.facets.namespace,
   584      paramName: 'namespace',
   585      expectedOptions: ['All (*)', 'default', 'namespace-1'],
   586      optionToSelect: 'namespace-1',
   587      async beforeEach() {
   588        server.createList('job', 2, {
   589          namespaceId: 'default',
   590          createRecommendations: true,
   591        });
   592        server.createList('job', 2, {
   593          namespaceId: 'namespace-1',
   594          createRecommendations: true,
   595        });
   596        await Optimize.visit();
   597      },
   598      filter(taskGroup, selection) {
   599        return taskGroup.job.namespaceId === selection;
   600      },
   601    });
   602  
   603    testFacet('Type', {
   604      facet: Optimize.facets.type,
   605      paramName: 'type',
   606      expectedOptions: ['Service', 'System'],
   607      async beforeEach() {
   608        server.createList('job', 2, {
   609          type: 'service',
   610          createRecommendations: true,
   611          groupsCount: 1,
   612          groupTaskCount: 2,
   613        });
   614  
   615        server.createList('job', 2, {
   616          type: 'system',
   617          createRecommendations: true,
   618          groupsCount: 1,
   619          groupTaskCount: 2,
   620        });
   621        await Optimize.visit();
   622      },
   623      filter(taskGroup, selection) {
   624        let displayType = taskGroup.job.type;
   625        return selection.includes(displayType);
   626      },
   627    });
   628  
   629    testFacet('Status', {
   630      facet: Optimize.facets.status,
   631      paramName: 'status',
   632      expectedOptions: ['Pending', 'Running', 'Dead'],
   633      async beforeEach() {
   634        server.createList('job', 2, {
   635          status: 'pending',
   636          createRecommendations: true,
   637          groupsCount: 1,
   638          groupTaskCount: 2,
   639          childrenCount: 0,
   640        });
   641        server.createList('job', 2, {
   642          status: 'running',
   643          createRecommendations: true,
   644          groupsCount: 1,
   645          groupTaskCount: 2,
   646          childrenCount: 0,
   647        });
   648        server.createList('job', 2, {
   649          status: 'dead',
   650          createRecommendations: true,
   651          childrenCount: 0,
   652        });
   653        await Optimize.visit();
   654      },
   655      filter: (taskGroup, selection) => selection.includes(taskGroup.job.status),
   656    });
   657  
   658    testFacet('Datacenter', {
   659      facet: Optimize.facets.datacenter,
   660      paramName: 'dc',
   661      expectedOptions(jobs) {
   662        const allDatacenters = new Set(
   663          jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), [])
   664        );
   665        return Array.from(allDatacenters).sort();
   666      },
   667      async beforeEach() {
   668        server.create('job', {
   669          datacenters: ['pdx', 'lax'],
   670          createRecommendations: true,
   671          groupsCount: 1,
   672          groupTaskCount: 2,
   673          childrenCount: 0,
   674        });
   675        server.create('job', {
   676          datacenters: ['pdx', 'ord'],
   677          createRecommendations: true,
   678          groupsCount: 1,
   679          groupTaskCount: 2,
   680          childrenCount: 0,
   681        });
   682        server.create('job', {
   683          datacenters: ['lax', 'jfk'],
   684          createRecommendations: true,
   685          groupsCount: 1,
   686          groupTaskCount: 2,
   687          childrenCount: 0,
   688        });
   689        server.create('job', {
   690          datacenters: ['jfk', 'dfw'],
   691          createRecommendations: true,
   692          groupsCount: 1,
   693          groupTaskCount: 2,
   694          childrenCount: 0,
   695        });
   696        server.create('job', {
   697          datacenters: ['pdx'],
   698          createRecommendations: true,
   699          childrenCount: 0,
   700        });
   701        await Optimize.visit();
   702      },
   703      filter: (taskGroup, selection) =>
   704        taskGroup.job.datacenters.find((dc) => selection.includes(dc)),
   705    });
   706  
   707    testFacet('Prefix', {
   708      facet: Optimize.facets.prefix,
   709      paramName: 'prefix',
   710      expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'],
   711      async beforeEach() {
   712        [
   713          'pre-one',
   714          'hashi_one',
   715          'nmd.one',
   716          'one-alone',
   717          'pre_two',
   718          'hashi.two',
   719          'hashi-three',
   720          'nmd_two',
   721          'noprefix',
   722        ].forEach((name) => {
   723          server.create('job', {
   724            name,
   725            createRecommendations: true,
   726            createAllocations: true,
   727            groupsCount: 1,
   728            groupTaskCount: 2,
   729            childrenCount: 0,
   730          });
   731        });
   732        await Optimize.visit();
   733      },
   734      filter: (taskGroup, selection) =>
   735        selection.find((prefix) => taskGroup.job.name.startsWith(prefix)),
   736    });
   737  
   738    async function facetOptions(assert, beforeEach, facet, expectedOptions) {
   739      await beforeEach();
   740      await facet.toggle();
   741  
   742      let expectation;
   743      if (typeof expectedOptions === 'function') {
   744        expectation = expectedOptions(server.db.jobs);
   745      } else {
   746        expectation = expectedOptions;
   747      }
   748  
   749      assert.deepEqual(
   750        facet.options.map((option) => option.label.trim()),
   751        expectation,
   752        'Options for facet are as expected'
   753      );
   754    }
   755  
   756    function testSingleSelectFacet(
   757      label,
   758      { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
   759    ) {
   760      test(`the ${label} facet has the correct options`, async function (assert) {
   761        await facetOptions.call(this, assert, beforeEach, facet, expectedOptions);
   762      });
   763  
   764      test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) {
   765        await beforeEach();
   766        await facet.toggle();
   767  
   768        const option = facet.options.findOneBy('label', optionToSelect);
   769        const selection = option.key;
   770        await option.select();
   771  
   772        const sortedRecommendations = server.db.recommendations
   773          .sortBy('submitTime')
   774          .reverse();
   775  
   776        const recommendationTaskGroups = server.schema.tasks
   777          .find(sortedRecommendations.mapBy('taskId').uniq())
   778          .models.mapBy('taskGroup')
   779          .uniqBy('id')
   780          .filter((group) => filter(group, selection));
   781  
   782        Optimize.recommendationSummaries.forEach((summary, index) => {
   783          const group = recommendationTaskGroups[index];
   784          assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
   785        });
   786      });
   787  
   788      test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function (assert) {
   789        await beforeEach();
   790        await facet.toggle();
   791  
   792        const option = facet.options.objectAt(1);
   793        const selection = option.key;
   794        await option.select();
   795  
   796        assert.ok(
   797          currentURL().includes(`${paramName}=${selection}`),
   798          'URL has the correct query param key and value'
   799        );
   800      });
   801    }
   802  
   803    function testFacet(
   804      label,
   805      { facet, paramName, beforeEach, filter, expectedOptions }
   806    ) {
   807      test(`the ${label} facet has the correct options`, async function (assert) {
   808        await facetOptions.call(this, assert, beforeEach, facet, expectedOptions);
   809      });
   810  
   811      test(`the ${label} facet filters the recommendation summaries by ${label}`, async function (assert) {
   812        let option;
   813  
   814        await beforeEach();
   815        await facet.toggle();
   816  
   817        option = facet.options.objectAt(0);
   818        await option.toggle();
   819  
   820        const selection = [option.key];
   821  
   822        const sortedRecommendations = server.db.recommendations
   823          .sortBy('submitTime')
   824          .reverse();
   825  
   826        const recommendationTaskGroups = server.schema.tasks
   827          .find(sortedRecommendations.mapBy('taskId').uniq())
   828          .models.mapBy('taskGroup')
   829          .uniqBy('id')
   830          .filter((group) => filter(group, selection));
   831  
   832        Optimize.recommendationSummaries.forEach((summary, index) => {
   833          const group = recommendationTaskGroups[index];
   834          assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
   835        });
   836      });
   837  
   838      test(`selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
   839        const selection = [];
   840  
   841        await beforeEach();
   842        await facet.toggle();
   843  
   844        const option1 = facet.options.objectAt(0);
   845        const option2 = facet.options.objectAt(1);
   846        await option1.toggle();
   847        selection.push(option1.key);
   848        await option2.toggle();
   849        selection.push(option2.key);
   850  
   851        const sortedRecommendations = server.db.recommendations
   852          .sortBy('submitTime')
   853          .reverse();
   854  
   855        const recommendationTaskGroups = server.schema.tasks
   856          .find(sortedRecommendations.mapBy('taskId').uniq())
   857          .models.mapBy('taskGroup')
   858          .uniqBy('id')
   859          .filter((group) => filter(group, selection));
   860  
   861        Optimize.recommendationSummaries.forEach((summary, index) => {
   862          const group = recommendationTaskGroups[index];
   863          assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
   864        });
   865      });
   866  
   867      test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
   868        const selection = [];
   869  
   870        await beforeEach();
   871        await facet.toggle();
   872  
   873        const option1 = facet.options.objectAt(0);
   874        const option2 = facet.options.objectAt(1);
   875        await option1.toggle();
   876        selection.push(option1.key);
   877        await option2.toggle();
   878        selection.push(option2.key);
   879  
   880        assert.ok(
   881          currentURL().includes(encodeURIComponent(JSON.stringify(selection)))
   882        );
   883      });
   884    }
   885  });
   886  
   887  function formattedMemDiff(memDiff) {
   888    const absMemDiff = Math.abs(memDiff);
   889    const negativeSign = memDiff < 0 ? '-' : '';
   890  
   891    return negativeSign + formatBytes(absMemDiff, 'MiB');
   892  }