github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/optimize-test.js (about)

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