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

     1  /* eslint-disable qunit/require-expect */
     2  /* eslint-disable qunit/no-conditional-assertions */
     3  /* Mirage fixtures are random so we can't expect a set number of assertions */
     4  import { currentURL, waitUntil, settled } from '@ember/test-helpers';
     5  import { assign } from '@ember/polyfills';
     6  import { module, test } from 'qunit';
     7  import { setupApplicationTest } from 'ember-qunit';
     8  import { setupMirage } from 'ember-cli-mirage/test-support';
     9  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
    10  import { formatBytes, formatHertz } from 'nomad-ui/utils/units';
    11  import moment from 'moment';
    12  import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
    13  import Clients from 'nomad-ui/tests/pages/clients/list';
    14  import Jobs from 'nomad-ui/tests/pages/jobs/list';
    15  import Layout from 'nomad-ui/tests/pages/layout';
    16  
    17  let node;
    18  let managementToken;
    19  let clientToken;
    20  
    21  const wasPreemptedFilter = (allocation) => !!allocation.preemptedByAllocation;
    22  
    23  function nonSearchPOSTS() {
    24    return server.pretender.handledRequests
    25      .reject((request) => request.url.includes('fuzzy'))
    26      .filterBy('method', 'POST');
    27  }
    28  
    29  module('Acceptance | client detail', function (hooks) {
    30    setupApplicationTest(hooks);
    31    setupMirage(hooks);
    32  
    33    hooks.beforeEach(function () {
    34      window.localStorage.clear();
    35  
    36      server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
    37      node = server.db.nodes[0];
    38  
    39      managementToken = server.create('token');
    40      clientToken = server.create('token');
    41  
    42      window.localStorage.nomadTokenSecret = managementToken.secretId;
    43  
    44      // Related models
    45      server.create('agent');
    46      server.create('job', { createAllocations: false });
    47      server.createList('allocation', 3);
    48      server.create('allocation', 'preempted');
    49  
    50      // Force all allocations into the running state so now allocation rows are missing
    51      // CPU/Mem runtime metrics
    52      server.schema.allocations.all().models.forEach((allocation) => {
    53        allocation.update({ clientStatus: 'running' });
    54      });
    55    });
    56  
    57    test('it passes an accessibility audit', async function (assert) {
    58      await ClientDetail.visit({ id: node.id });
    59      await a11yAudit(assert);
    60    });
    61  
    62    test('/clients/:id should have a breadcrumb trail linking back to clients', async function (assert) {
    63      await ClientDetail.visit({ id: node.id });
    64  
    65      assert.equal(document.title, `Client ${node.name} - Nomad`);
    66  
    67      assert.equal(
    68        Layout.breadcrumbFor('clients.index').text,
    69        'Clients',
    70        'First breadcrumb says clients'
    71      );
    72      assert.equal(
    73        Layout.breadcrumbFor('clients.client').text,
    74        `Client ${node.id.split('-')[0]}`,
    75        'Second breadcrumb is a titled breadcrumb saying the node short id'
    76      );
    77      await Layout.breadcrumbFor('clients.index').visit();
    78      assert.equal(
    79        currentURL(),
    80        '/clients',
    81        'First breadcrumb links back to clients'
    82      );
    83    });
    84  
    85    test('/clients/:id should list immediate details for the node in the title', async function (assert) {
    86      node = server.create('node', 'forceIPv4', {
    87        schedulingEligibility: 'eligible',
    88        drain: false,
    89      });
    90  
    91      await ClientDetail.visit({ id: node.id });
    92  
    93      assert.ok(ClientDetail.title.includes(node.name), 'Title includes name');
    94      assert.ok(ClientDetail.clientId.includes(node.id), 'Title includes id');
    95      assert.equal(
    96        ClientDetail.statusLight.objectAt(0).id,
    97        node.status,
    98        'Title includes status light'
    99      );
   100    });
   101  
   102    test('/clients/:id should list additional detail for the node below the title', async function (assert) {
   103      await ClientDetail.visit({ id: node.id });
   104  
   105      assert.ok(
   106        ClientDetail.statusDefinition.includes(node.status),
   107        'Status is in additional details'
   108      );
   109      assert.ok(
   110        ClientDetail.statusDecorationClass.includes(`node-${node.status}`),
   111        'Status is decorated with a status class'
   112      );
   113      assert.ok(
   114        ClientDetail.addressDefinition.includes(node.httpAddr),
   115        'Address is in additional details'
   116      );
   117      assert.ok(
   118        ClientDetail.datacenterDefinition.includes(node.datacenter),
   119        'Datacenter is in additional details'
   120      );
   121    });
   122  
   123    test('/clients/:id should include resource utilization graphs', async function (assert) {
   124      await ClientDetail.visit({ id: node.id });
   125  
   126      assert.equal(
   127        ClientDetail.resourceCharts.length,
   128        2,
   129        'Two resource utilization graphs'
   130      );
   131      assert.equal(
   132        ClientDetail.resourceCharts.objectAt(0).name,
   133        'CPU',
   134        'First chart is CPU'
   135      );
   136      assert.equal(
   137        ClientDetail.resourceCharts.objectAt(1).name,
   138        'Memory',
   139        'Second chart is Memory'
   140      );
   141    });
   142  
   143    test('/clients/:id should list all allocations on the node', async function (assert) {
   144      const allocationsCount = server.db.allocations.where({
   145        nodeId: node.id,
   146      }).length;
   147  
   148      await ClientDetail.visit({ id: node.id });
   149  
   150      assert.equal(
   151        ClientDetail.allocations.length,
   152        allocationsCount,
   153        `Allocations table lists all ${allocationsCount} associated allocations`
   154      );
   155    });
   156  
   157    test('/clients/:id should show empty message if there are no allocations on the node', async function (assert) {
   158      const emptyNode = server.create('node');
   159  
   160      await ClientDetail.visit({ id: emptyNode.id });
   161  
   162      assert.true(
   163        ClientDetail.emptyAllocations.isVisible,
   164        'Empty message is visible'
   165      );
   166      assert.equal(ClientDetail.emptyAllocations.headline, 'No Allocations');
   167    });
   168  
   169    test('each allocation should have high-level details for the allocation', async function (assert) {
   170      const allocation = server.db.allocations
   171        .where({ nodeId: node.id })
   172        .sortBy('modifyIndex')
   173        .reverse()[0];
   174  
   175      const allocStats = server.db.clientAllocationStats.find(allocation.id);
   176      const taskGroup = server.db.taskGroups.findBy({
   177        name: allocation.taskGroup,
   178        jobId: allocation.jobId,
   179      });
   180  
   181      const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
   182      const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
   183      const memoryUsed = tasks.reduce(
   184        (sum, task) => sum + task.resources.MemoryMB,
   185        0
   186      );
   187  
   188      await ClientDetail.visit({ id: node.id });
   189  
   190      const allocationRow = ClientDetail.allocations.objectAt(0);
   191  
   192      assert.equal(
   193        allocationRow.shortId,
   194        allocation.id.split('-')[0],
   195        'Allocation short ID'
   196      );
   197      assert.equal(
   198        allocationRow.createTime,
   199        moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'),
   200        'Allocation create time'
   201      );
   202      assert.equal(
   203        allocationRow.modifyTime,
   204        moment(allocation.modifyTime / 1000000).fromNow(),
   205        'Allocation modify time'
   206      );
   207      assert.equal(
   208        allocationRow.status,
   209        allocation.clientStatus,
   210        'Client status'
   211      );
   212      assert.equal(
   213        allocationRow.job,
   214        server.db.jobs.find(allocation.jobId).name,
   215        'Job name'
   216      );
   217      assert.ok(allocationRow.taskGroup, 'Task group name');
   218      assert.ok(allocationRow.jobVersion, 'Job Version');
   219      assert.equal(allocationRow.volume, 'Yes', 'Volume');
   220      assert.equal(
   221        allocationRow.cpu,
   222        Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
   223        'CPU %'
   224      );
   225      const roundedTicks = Math.floor(
   226        allocStats.resourceUsage.CpuStats.TotalTicks
   227      );
   228      assert.equal(
   229        allocationRow.cpuTooltip,
   230        `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`,
   231        'Detailed CPU information is in a tooltip'
   232      );
   233      assert.equal(
   234        allocationRow.mem,
   235        allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed,
   236        'Memory used'
   237      );
   238      assert.equal(
   239        allocationRow.memTooltip,
   240        `${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes(
   241          memoryUsed,
   242          'MiB'
   243        )}`,
   244        'Detailed memory information is in a tooltip'
   245      );
   246    });
   247  
   248    test('each allocation should show job information even if the job is incomplete and already in the store', async function (assert) {
   249      // First, visit clients to load the allocations for each visible node.
   250      // Don't load the job belongsTo of the allocation! Leave it unfulfilled.
   251  
   252      await Clients.visit();
   253  
   254      // Then, visit jobs to load all jobs, which should implicitly fulfill
   255      // the job belongsTo of each allocation pointed at each job.
   256  
   257      await Jobs.visit();
   258  
   259      // Finally, visit a node to assert that the job name and task group name are
   260      // present. This will require reloading the job, since task groups aren't a
   261      // part of the jobs list response.
   262  
   263      await ClientDetail.visit({ id: node.id });
   264  
   265      const allocationRow = ClientDetail.allocations.objectAt(0);
   266      const allocation = server.db.allocations
   267        .where({ nodeId: node.id })
   268        .sortBy('modifyIndex')
   269        .reverse()[0];
   270  
   271      assert.equal(
   272        allocationRow.job,
   273        server.db.jobs.find(allocation.jobId).name,
   274        'Job name'
   275      );
   276      assert.ok(
   277        allocationRow.taskGroup.includes(allocation.taskGroup),
   278        'Task group name'
   279      );
   280    });
   281  
   282    test('each allocation should link to the allocation detail page', async function (assert) {
   283      const allocation = server.db.allocations
   284        .where({ nodeId: node.id })
   285        .sortBy('modifyIndex')
   286        .reverse()[0];
   287  
   288      await ClientDetail.visit({ id: node.id });
   289      await ClientDetail.allocations.objectAt(0).visit();
   290  
   291      assert.equal(
   292        currentURL(),
   293        `/allocations/${allocation.id}`,
   294        'Allocation rows link to allocation detail pages'
   295      );
   296    });
   297  
   298    test('each allocation should link to the job the allocation belongs to', async function (assert) {
   299      await ClientDetail.visit({ id: node.id });
   300  
   301      const allocation = server.db.allocations.where({ nodeId: node.id })[0];
   302      const job = server.db.jobs.find(allocation.jobId);
   303  
   304      await ClientDetail.allocations.objectAt(0).visitJob();
   305  
   306      assert.equal(
   307        currentURL(),
   308        `/jobs/${job.id}@default`,
   309        'Allocation rows link to the job detail page for the allocation'
   310      );
   311    });
   312  
   313    test('the allocation section should show the count of preempted allocations on the client', async function (assert) {
   314      const allocations = server.db.allocations.where({ nodeId: node.id });
   315  
   316      await ClientDetail.visit({ id: node.id });
   317  
   318      assert.equal(
   319        ClientDetail.allocationFilter.allCount,
   320        allocations.length,
   321        'All filter/badge shows all allocations count'
   322      );
   323      assert.ok(
   324        ClientDetail.allocationFilter.preemptionsCount.startsWith(
   325          allocations.filter(wasPreemptedFilter).length
   326        ),
   327        'Preemptions filter/badge shows preempted allocations count'
   328      );
   329    });
   330  
   331    test('clicking the preemption badge filters the allocations table and sets a query param', async function (assert) {
   332      const allocations = server.db.allocations.where({ nodeId: node.id });
   333  
   334      await ClientDetail.visit({ id: node.id });
   335      await ClientDetail.allocationFilter.preemptions();
   336  
   337      assert.equal(
   338        ClientDetail.allocations.length,
   339        allocations.filter(wasPreemptedFilter).length,
   340        'Only preempted allocations are shown'
   341      );
   342      assert.equal(
   343        currentURL(),
   344        `/clients/${node.id}?preemptions=true`,
   345        'Filter is persisted in the URL'
   346      );
   347    });
   348  
   349    test('clicking the total allocations badge resets the filter and removes the query param', async function (assert) {
   350      const allocations = server.db.allocations.where({ nodeId: node.id });
   351  
   352      await ClientDetail.visit({ id: node.id });
   353      await ClientDetail.allocationFilter.preemptions();
   354      await ClientDetail.allocationFilter.all();
   355  
   356      assert.equal(
   357        ClientDetail.allocations.length,
   358        allocations.length,
   359        'All allocations are shown'
   360      );
   361      assert.equal(
   362        currentURL(),
   363        `/clients/${node.id}`,
   364        'Filter is persisted in the URL'
   365      );
   366    });
   367  
   368    test('navigating directly to the client detail page with the preemption query param set will filter the allocations table', async function (assert) {
   369      const allocations = server.db.allocations.where({ nodeId: node.id });
   370  
   371      await ClientDetail.visit({ id: node.id, preemptions: true });
   372  
   373      assert.equal(
   374        ClientDetail.allocations.length,
   375        allocations.filter(wasPreemptedFilter).length,
   376        'Only preempted allocations are shown'
   377      );
   378    });
   379  
   380    test('/clients/:id should list all attributes for the node', async function (assert) {
   381      await ClientDetail.visit({ id: node.id });
   382  
   383      assert.ok(ClientDetail.attributesTable, 'Attributes table is on the page');
   384    });
   385  
   386    test('/clients/:id lists all meta attributes', async function (assert) {
   387      node = server.create('node', 'forceIPv4', 'withMeta');
   388  
   389      await ClientDetail.visit({ id: node.id });
   390  
   391      assert.ok(ClientDetail.metaTable, 'Meta attributes table is on the page');
   392      assert.notOk(ClientDetail.emptyMetaMessage, 'Meta attributes is not empty');
   393  
   394      const firstMetaKey = Object.keys(node.meta)[0];
   395      const firstMetaAttribute = ClientDetail.metaAttributes.objectAt(0);
   396      assert.equal(
   397        firstMetaAttribute.key,
   398        firstMetaKey,
   399        'Meta attributes for the node are bound to the attributes table'
   400      );
   401      assert.equal(
   402        firstMetaAttribute.value,
   403        node.meta[firstMetaKey],
   404        'Meta attributes for the node are bound to the attributes table'
   405      );
   406    });
   407  
   408    test('/clients/:id shows an empty message when there is no meta data', async function (assert) {
   409      await ClientDetail.visit({ id: node.id });
   410  
   411      assert.notOk(
   412        ClientDetail.metaTable,
   413        'Meta attributes table is not on the page'
   414      );
   415      assert.ok(ClientDetail.emptyMetaMessage, 'Meta attributes is empty');
   416    });
   417  
   418    test('when the node is not found, an error message is shown, but the URL persists', async function (assert) {
   419      await ClientDetail.visit({ id: 'not-a-real-node' });
   420  
   421      assert.equal(
   422        server.pretender.handledRequests
   423          .filter((request) => !request.url.includes('policy'))
   424          .findBy('status', 404).url,
   425        '/v1/node/not-a-real-node',
   426        'A request to the nonexistent node is made'
   427      );
   428      assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists');
   429      assert.ok(ClientDetail.error.isShown, 'Error message is shown');
   430      assert.equal(
   431        ClientDetail.error.title,
   432        'Not Found',
   433        'Error message is for 404'
   434      );
   435    });
   436  
   437    test('/clients/:id shows the recent events list', async function (assert) {
   438      await ClientDetail.visit({ id: node.id });
   439  
   440      assert.ok(ClientDetail.hasEvents, 'Client events section exists');
   441    });
   442  
   443    test('each node event shows basic node event information', async function (assert) {
   444      const event = server.db.nodeEvents
   445        .where({ nodeId: node.id })
   446        .sortBy('time')
   447        .reverse()[0];
   448  
   449      await ClientDetail.visit({ id: node.id });
   450  
   451      const eventRow = ClientDetail.events.objectAt(0);
   452      assert.equal(
   453        eventRow.time,
   454        moment(event.time).format("MMM DD, 'YY HH:mm:ss ZZ"),
   455        'Event timestamp'
   456      );
   457      assert.equal(eventRow.subsystem, event.subsystem, 'Event subsystem');
   458      assert.equal(eventRow.message, event.message, 'Event message');
   459    });
   460  
   461    test('/clients/:id shows the driver status of every driver for the node', async function (assert) {
   462      // Set the drivers up so health and detection is well tested
   463      const nodeDrivers = node.drivers;
   464      const undetectedDriver = 'raw_exec';
   465  
   466      Object.values(nodeDrivers).forEach((driver) => {
   467        driver.Detected = true;
   468      });
   469  
   470      nodeDrivers[undetectedDriver].Detected = false;
   471      node.drivers = nodeDrivers;
   472  
   473      const drivers = Object.keys(node.drivers)
   474        .map((driverName) =>
   475          assign({ Name: driverName }, node.drivers[driverName])
   476        )
   477        .sortBy('Name');
   478  
   479      assert.ok(drivers.length > 0, 'Node has drivers');
   480  
   481      await ClientDetail.visit({ id: node.id });
   482  
   483      drivers.forEach((driver, index) => {
   484        const driverHead = ClientDetail.driverHeads.objectAt(index);
   485  
   486        assert.equal(
   487          driverHead.name,
   488          driver.Name,
   489          `${driver.Name}: Name is correct`
   490        );
   491        assert.equal(
   492          driverHead.detected,
   493          driver.Detected ? 'Yes' : 'No',
   494          `${driver.Name}: Detection is correct`
   495        );
   496        assert.equal(
   497          driverHead.lastUpdated,
   498          moment(driver.UpdateTime).fromNow(),
   499          `${driver.Name}: Last updated shows time since now`
   500        );
   501  
   502        if (driver.Name === undetectedDriver) {
   503          assert.notOk(
   504            driverHead.healthIsShown,
   505            `${driver.Name}: No health for the undetected driver`
   506          );
   507        } else {
   508          assert.equal(
   509            driverHead.health,
   510            driver.Healthy ? 'Healthy' : 'Unhealthy',
   511            `${driver.Name}: Health is correct`
   512          );
   513          assert.ok(
   514            driverHead.healthClass.includes(
   515              driver.Healthy ? 'running' : 'failed'
   516            ),
   517            `${driver.Name}: Swatch with correct class is shown`
   518          );
   519        }
   520      });
   521    });
   522  
   523    test('each driver can be opened to see a message and attributes', async function (assert) {
   524      // Only detected drivers can be expanded
   525      const nodeDrivers = node.drivers;
   526      Object.values(nodeDrivers).forEach((driver) => {
   527        driver.Detected = true;
   528      });
   529      node.drivers = nodeDrivers;
   530  
   531      const driver = Object.keys(node.drivers)
   532        .map((driverName) =>
   533          assign({ Name: driverName }, node.drivers[driverName])
   534        )
   535        .sortBy('Name')[0];
   536  
   537      await ClientDetail.visit({ id: node.id });
   538      const driverHead = ClientDetail.driverHeads.objectAt(0);
   539      const driverBody = ClientDetail.driverBodies.objectAt(0);
   540  
   541      assert.notOk(
   542        driverBody.descriptionIsShown,
   543        'Driver health description is not shown'
   544      );
   545      assert.notOk(
   546        driverBody.attributesAreShown,
   547        'Driver attributes section is not shown'
   548      );
   549  
   550      await driverHead.toggle();
   551      assert.equal(
   552        driverBody.description,
   553        driver.HealthDescription,
   554        'Driver health description is now shown'
   555      );
   556      assert.ok(
   557        driverBody.attributesAreShown,
   558        'Driver attributes section is now shown'
   559      );
   560    });
   561  
   562    test('the status light indicates when the node is ineligible for scheduling', async function (assert) {
   563      node = server.create('node', {
   564        drain: false,
   565        schedulingEligibility: 'ineligible',
   566        status: 'ready',
   567      });
   568  
   569      await ClientDetail.visit({ id: node.id });
   570  
   571      assert.equal(
   572        ClientDetail.statusLight.objectAt(0).id,
   573        'ineligible',
   574        'Title status light is in the ineligible state'
   575      );
   576    });
   577  
   578    test('when the node has a drain strategy with a positive deadline, the drain stategy section prints the duration', async function (assert) {
   579      const deadline = 5400000000000; // 1.5 hours in nanoseconds
   580      const forceDeadline = moment().add(1, 'd');
   581  
   582      node = server.create('node', {
   583        drain: true,
   584        schedulingEligibility: 'ineligible',
   585        drainStrategy: {
   586          Deadline: deadline,
   587          ForceDeadline: forceDeadline.toISOString(),
   588          IgnoreSystemJobs: false,
   589        },
   590      });
   591  
   592      await ClientDetail.visit({ id: node.id });
   593  
   594      assert.ok(
   595        ClientDetail.drainDetails.deadline.includes(forceDeadline.fromNow(true)),
   596        'Deadline is shown in a human formatted way'
   597      );
   598  
   599      assert.equal(
   600        ClientDetail.drainDetails.deadlineTooltip,
   601        forceDeadline.format("MMM DD, 'YY HH:mm:ss ZZ"),
   602        'The tooltip for deadline shows the force deadline as an absolute date'
   603      );
   604  
   605      assert.ok(
   606        ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
   607        'Drain System Jobs state is shown'
   608      );
   609    });
   610  
   611    test('when the node has a drain stategy with no deadline, the drain stategy section mentions that and omits the force deadline', async function (assert) {
   612      const deadline = 0;
   613  
   614      node = server.create('node', {
   615        drain: true,
   616        schedulingEligibility: 'ineligible',
   617        drainStrategy: {
   618          Deadline: deadline,
   619          ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
   620          IgnoreSystemJobs: true,
   621        },
   622      });
   623  
   624      await ClientDetail.visit({ id: node.id });
   625  
   626      assert.notOk(
   627        ClientDetail.drainDetails.durationIsShown,
   628        'Duration is omitted'
   629      );
   630  
   631      assert.ok(
   632        ClientDetail.drainDetails.deadline.includes('No deadline'),
   633        'The value for Deadline is "no deadline"'
   634      );
   635  
   636      assert.ok(
   637        ClientDetail.drainDetails.drainSystemJobsText.endsWith('No'),
   638        'Drain System Jobs state is shown'
   639      );
   640    });
   641  
   642    test('when the node has a drain stategy with a negative deadline, the drain strategy section shows the force badge', async function (assert) {
   643      const deadline = -1;
   644  
   645      node = server.create('node', {
   646        drain: true,
   647        schedulingEligibility: 'ineligible',
   648        drainStrategy: {
   649          Deadline: deadline,
   650          ForceDeadline: '0001-01-01T00:00:00Z', // null as a date
   651          IgnoreSystemJobs: false,
   652        },
   653      });
   654  
   655      await ClientDetail.visit({ id: node.id });
   656  
   657      assert.ok(
   658        ClientDetail.drainDetails.forceDrainText.endsWith('Yes'),
   659        'Forced Drain is described'
   660      );
   661  
   662      assert.ok(
   663        ClientDetail.drainDetails.duration.includes('--'),
   664        'Duration is shown but unset'
   665      );
   666  
   667      assert.ok(
   668        ClientDetail.drainDetails.deadline.includes('--'),
   669        'Deadline is shown but unset'
   670      );
   671  
   672      assert.ok(
   673        ClientDetail.drainDetails.drainSystemJobsText.endsWith('Yes'),
   674        'Drain System Jobs state is shown'
   675      );
   676    });
   677  
   678    test('toggling node eligibility disables the toggle and sends the correct POST request', async function (assert) {
   679      node = server.create('node', {
   680        drain: false,
   681        schedulingEligibility: 'eligible',
   682      });
   683  
   684      server.pretender.post(
   685        '/v1/node/:id/eligibility',
   686        () => [200, {}, ''],
   687        true
   688      );
   689  
   690      await ClientDetail.visit({ id: node.id });
   691      assert.ok(ClientDetail.eligibilityToggle.isActive);
   692  
   693      ClientDetail.eligibilityToggle.toggle();
   694      await waitUntil(() => nonSearchPOSTS());
   695  
   696      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
   697      server.pretender.resolve(server.pretender.requestReferences[0].request);
   698  
   699      await settled();
   700  
   701      assert.notOk(ClientDetail.eligibilityToggle.isActive);
   702      assert.notOk(ClientDetail.eligibilityToggle.isDisabled);
   703  
   704      const request = nonSearchPOSTS()[0];
   705      assert.equal(request.url, `/v1/node/${node.id}/eligibility`);
   706      assert.deepEqual(JSON.parse(request.requestBody), {
   707        NodeID: node.id,
   708        Eligibility: 'ineligible',
   709      });
   710  
   711      ClientDetail.eligibilityToggle.toggle();
   712      await waitUntil(() => nonSearchPOSTS().length === 2);
   713      server.pretender.resolve(server.pretender.requestReferences[0].request);
   714  
   715      assert.ok(ClientDetail.eligibilityToggle.isActive);
   716      const request2 = nonSearchPOSTS()[1];
   717  
   718      assert.equal(request2.url, `/v1/node/${node.id}/eligibility`);
   719      assert.deepEqual(JSON.parse(request2.requestBody), {
   720        NodeID: node.id,
   721        Eligibility: 'eligible',
   722      });
   723    });
   724  
   725    test('starting a drain sends the correct POST request', async function (assert) {
   726      let request;
   727  
   728      node = server.create('node', {
   729        drain: false,
   730        schedulingEligibility: 'eligible',
   731      });
   732  
   733      await ClientDetail.visit({ id: node.id });
   734      await ClientDetail.drainPopover.toggle();
   735      await ClientDetail.drainPopover.submit();
   736  
   737      request = nonSearchPOSTS().pop();
   738  
   739      assert.equal(request.url, `/v1/node/${node.id}/drain`);
   740      assert.deepEqual(
   741        JSON.parse(request.requestBody),
   742        {
   743          NodeID: node.id,
   744          DrainSpec: {
   745            Deadline: 0,
   746            IgnoreSystemJobs: false,
   747          },
   748        },
   749        'Drain with default settings'
   750      );
   751  
   752      await ClientDetail.drainPopover.toggle();
   753      await ClientDetail.drainPopover.deadlineToggle.toggle();
   754      await ClientDetail.drainPopover.submit();
   755  
   756      request = nonSearchPOSTS().pop();
   757  
   758      assert.deepEqual(
   759        JSON.parse(request.requestBody),
   760        {
   761          NodeID: node.id,
   762          DrainSpec: {
   763            Deadline: 60 * 60 * 1000 * 1000000,
   764            IgnoreSystemJobs: false,
   765          },
   766        },
   767        'Drain with deadline toggled'
   768      );
   769  
   770      await ClientDetail.drainPopover.toggle();
   771      await ClientDetail.drainPopover.deadlineOptions.open();
   772      await ClientDetail.drainPopover.deadlineOptions.options[1].choose();
   773      await ClientDetail.drainPopover.submit();
   774  
   775      request = nonSearchPOSTS().pop();
   776  
   777      assert.deepEqual(
   778        JSON.parse(request.requestBody),
   779        {
   780          NodeID: node.id,
   781          DrainSpec: {
   782            Deadline: 4 * 60 * 60 * 1000 * 1000000,
   783            IgnoreSystemJobs: false,
   784          },
   785        },
   786        'Drain with non-default preset deadline set'
   787      );
   788  
   789      await ClientDetail.drainPopover.toggle();
   790      await ClientDetail.drainPopover.deadlineOptions.open();
   791      const optionsCount =
   792        ClientDetail.drainPopover.deadlineOptions.options.length;
   793      await ClientDetail.drainPopover.deadlineOptions.options
   794        .objectAt(optionsCount - 1)
   795        .choose();
   796      await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
   797      await ClientDetail.drainPopover.submit();
   798  
   799      request = nonSearchPOSTS().pop();
   800  
   801      assert.deepEqual(
   802        JSON.parse(request.requestBody),
   803        {
   804          NodeID: node.id,
   805          DrainSpec: {
   806            Deadline: ((1 * 60 + 40) * 60 + 20) * 1000 * 1000000,
   807            IgnoreSystemJobs: false,
   808          },
   809        },
   810        'Drain with custom deadline set'
   811      );
   812  
   813      await ClientDetail.drainPopover.toggle();
   814      await ClientDetail.drainPopover.deadlineToggle.toggle();
   815      await ClientDetail.drainPopover.forceDrainToggle.toggle();
   816      await ClientDetail.drainPopover.submit();
   817  
   818      request = nonSearchPOSTS().pop();
   819  
   820      assert.deepEqual(
   821        JSON.parse(request.requestBody),
   822        {
   823          NodeID: node.id,
   824          DrainSpec: {
   825            Deadline: -1,
   826            IgnoreSystemJobs: false,
   827          },
   828        },
   829        'Drain with force set'
   830      );
   831  
   832      await ClientDetail.drainPopover.toggle();
   833      await ClientDetail.drainPopover.systemJobsToggle.toggle();
   834      await ClientDetail.drainPopover.submit();
   835  
   836      request = nonSearchPOSTS().pop();
   837  
   838      assert.deepEqual(
   839        JSON.parse(request.requestBody),
   840        {
   841          NodeID: node.id,
   842          DrainSpec: {
   843            Deadline: -1,
   844            IgnoreSystemJobs: true,
   845          },
   846        },
   847        'Drain system jobs unset'
   848      );
   849    });
   850  
   851    test('starting a drain persists options to localstorage', async function (assert) {
   852      const nodes = server.createList('node', 2, {
   853        drain: false,
   854        schedulingEligibility: 'eligible',
   855      });
   856  
   857      await ClientDetail.visit({ id: nodes[0].id });
   858      await ClientDetail.drainPopover.toggle();
   859  
   860      // Change all options to non-default values.
   861      await ClientDetail.drainPopover.deadlineToggle.toggle();
   862      await ClientDetail.drainPopover.deadlineOptions.open();
   863      const optionsCount =
   864        ClientDetail.drainPopover.deadlineOptions.options.length;
   865      await ClientDetail.drainPopover.deadlineOptions.options
   866        .objectAt(optionsCount - 1)
   867        .choose();
   868      await ClientDetail.drainPopover.setCustomDeadline('1h40m20s');
   869      await ClientDetail.drainPopover.forceDrainToggle.toggle();
   870      await ClientDetail.drainPopover.systemJobsToggle.toggle();
   871  
   872      await ClientDetail.drainPopover.submit();
   873  
   874      const got = JSON.parse(window.localStorage.nomadDrainOptions);
   875      const want = {
   876        deadlineEnabled: true,
   877        customDuration: '1h40m20s',
   878        selectedDurationQuickOption: { label: 'Custom', value: 'custom' },
   879        drainSystemJobs: false,
   880        forceDrain: true,
   881      };
   882      assert.deepEqual(got, want);
   883  
   884      // Visit another node and check that drain config is persisted.
   885      await ClientDetail.visit({ id: nodes[1].id });
   886      await ClientDetail.drainPopover.toggle();
   887      assert.true(ClientDetail.drainPopover.deadlineToggle.isActive);
   888      assert.equal(ClientDetail.drainPopover.customDeadline, '1h40m20s');
   889      assert.true(ClientDetail.drainPopover.forceDrainToggle.isActive);
   890      assert.false(ClientDetail.drainPopover.systemJobsToggle.isActive);
   891    });
   892  
   893    test('the drain popover cancel button closes the popover', async function (assert) {
   894      node = server.create('node', {
   895        drain: false,
   896        schedulingEligibility: 'eligible',
   897      });
   898  
   899      await ClientDetail.visit({ id: node.id });
   900      assert.notOk(ClientDetail.drainPopover.isOpen);
   901  
   902      await ClientDetail.drainPopover.toggle();
   903      assert.ok(ClientDetail.drainPopover.isOpen);
   904  
   905      await ClientDetail.drainPopover.cancel();
   906      assert.notOk(ClientDetail.drainPopover.isOpen);
   907      assert.equal(nonSearchPOSTS(), 0);
   908    });
   909  
   910    test('toggling eligibility is disabled while a drain is active', async function (assert) {
   911      node = server.create('node', {
   912        drain: true,
   913        schedulingEligibility: 'ineligible',
   914      });
   915  
   916      await ClientDetail.visit({ id: node.id });
   917      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
   918    });
   919  
   920    test('stopping a drain sends the correct POST request', async function (assert) {
   921      node = server.create('node', {
   922        drain: true,
   923        schedulingEligibility: 'ineligible',
   924      });
   925  
   926      await ClientDetail.visit({ id: node.id });
   927      assert.ok(ClientDetail.stopDrainIsPresent);
   928  
   929      await ClientDetail.stopDrain.idle();
   930      await ClientDetail.stopDrain.confirm();
   931  
   932      const request = nonSearchPOSTS()[0];
   933      assert.equal(request.url, `/v1/node/${node.id}/drain`);
   934      assert.deepEqual(JSON.parse(request.requestBody), {
   935        NodeID: node.id,
   936        DrainSpec: null,
   937      });
   938    });
   939  
   940    test('when a drain is active, the "drain" popover is labeled as the "update" popover', async function (assert) {
   941      node = server.create('node', {
   942        drain: true,
   943        schedulingEligibility: 'ineligible',
   944      });
   945  
   946      await ClientDetail.visit({ id: node.id });
   947      assert.equal(ClientDetail.drainPopover.label, 'Update Drain');
   948    });
   949  
   950    test('forcing a drain sends the correct POST request', async function (assert) {
   951      node = server.create('node', {
   952        drain: true,
   953        schedulingEligibility: 'ineligible',
   954        drainStrategy: {
   955          Deadline: 0,
   956          IgnoreSystemJobs: true,
   957        },
   958      });
   959  
   960      await ClientDetail.visit({ id: node.id });
   961      await ClientDetail.drainDetails.force.idle();
   962      await ClientDetail.drainDetails.force.confirm();
   963  
   964      const request = nonSearchPOSTS()[0];
   965      assert.equal(request.url, `/v1/node/${node.id}/drain`);
   966      assert.deepEqual(JSON.parse(request.requestBody), {
   967        NodeID: node.id,
   968        DrainSpec: {
   969          Deadline: -1,
   970          IgnoreSystemJobs: true,
   971        },
   972      });
   973    });
   974  
   975    test('when stopping a drain fails, an error is shown', async function (assert) {
   976      node = server.create('node', {
   977        drain: true,
   978        schedulingEligibility: 'ineligible',
   979      });
   980  
   981      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
   982  
   983      await ClientDetail.visit({ id: node.id });
   984      await ClientDetail.stopDrain.idle();
   985      await ClientDetail.stopDrain.confirm();
   986  
   987      assert.ok(ClientDetail.stopDrainError.isPresent);
   988      assert.ok(ClientDetail.stopDrainError.title.includes('Stop Drain Error'));
   989  
   990      await ClientDetail.stopDrainError.dismiss();
   991      assert.notOk(ClientDetail.stopDrainError.isPresent);
   992    });
   993  
   994    test('when starting a drain fails, an error message is shown', async function (assert) {
   995      node = server.create('node', {
   996        drain: false,
   997        schedulingEligibility: 'eligible',
   998      });
   999  
  1000      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
  1001  
  1002      await ClientDetail.visit({ id: node.id });
  1003      await ClientDetail.drainPopover.toggle();
  1004      await ClientDetail.drainPopover.submit();
  1005  
  1006      assert.ok(ClientDetail.drainError.isPresent);
  1007      assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
  1008  
  1009      await ClientDetail.drainError.dismiss();
  1010      assert.notOk(ClientDetail.drainError.isPresent);
  1011    });
  1012  
  1013    test('when updating a drain fails, an error message is shown', async function (assert) {
  1014      node = server.create('node', {
  1015        drain: true,
  1016        schedulingEligibility: 'ineligible',
  1017      });
  1018  
  1019      server.pretender.post('/v1/node/:id/drain', () => [500, {}, '']);
  1020  
  1021      await ClientDetail.visit({ id: node.id });
  1022      await ClientDetail.drainPopover.toggle();
  1023      await ClientDetail.drainPopover.submit();
  1024  
  1025      assert.ok(ClientDetail.drainError.isPresent);
  1026      assert.ok(ClientDetail.drainError.title.includes('Drain Error'));
  1027  
  1028      await ClientDetail.drainError.dismiss();
  1029      assert.notOk(ClientDetail.drainError.isPresent);
  1030    });
  1031  
  1032    test('when toggling eligibility fails, an error message is shown', async function (assert) {
  1033      node = server.create('node', {
  1034        drain: false,
  1035        schedulingEligibility: 'eligible',
  1036      });
  1037  
  1038      server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
  1039  
  1040      await ClientDetail.visit({ id: node.id });
  1041      await ClientDetail.eligibilityToggle.toggle();
  1042  
  1043      assert.ok(ClientDetail.eligibilityError.isPresent);
  1044      assert.ok(
  1045        ClientDetail.eligibilityError.title.includes('Eligibility Error')
  1046      );
  1047  
  1048      await ClientDetail.eligibilityError.dismiss();
  1049      assert.notOk(ClientDetail.eligibilityError.isPresent);
  1050    });
  1051  
  1052    test('when navigating away from a client that has an error message to another client, the error is not shown', async function (assert) {
  1053      node = server.create('node', {
  1054        drain: false,
  1055        schedulingEligibility: 'eligible',
  1056      });
  1057  
  1058      const node2 = server.create('node');
  1059  
  1060      server.pretender.post('/v1/node/:id/eligibility', () => [500, {}, '']);
  1061  
  1062      await ClientDetail.visit({ id: node.id });
  1063      await ClientDetail.eligibilityToggle.toggle();
  1064  
  1065      assert.ok(ClientDetail.eligibilityError.isPresent);
  1066      assert.ok(
  1067        ClientDetail.eligibilityError.title.includes('Eligibility Error')
  1068      );
  1069  
  1070      await ClientDetail.visit({ id: node2.id });
  1071  
  1072      assert.notOk(ClientDetail.eligibilityError.isPresent);
  1073    });
  1074  
  1075    test('toggling eligibility and node drain are disabled when the active ACL token does not permit node write', async function (assert) {
  1076      window.localStorage.nomadTokenSecret = clientToken.secretId;
  1077  
  1078      await ClientDetail.visit({ id: node.id });
  1079      assert.ok(ClientDetail.eligibilityToggle.isDisabled);
  1080      assert.ok(ClientDetail.drainPopover.isDisabled);
  1081    });
  1082  
  1083    test('the host volumes table lists all host volumes in alphabetical order by name', async function (assert) {
  1084      await ClientDetail.visit({ id: node.id });
  1085  
  1086      const sortedHostVolumes = Object.keys(node.hostVolumes)
  1087        .map((key) => node.hostVolumes[key])
  1088        .sortBy('Name');
  1089  
  1090      assert.ok(ClientDetail.hasHostVolumes);
  1091      assert.equal(
  1092        ClientDetail.hostVolumes.length,
  1093        Object.keys(node.hostVolumes).length
  1094      );
  1095  
  1096      ClientDetail.hostVolumes.forEach((volume, index) => {
  1097        assert.equal(volume.name, sortedHostVolumes[index].Name);
  1098      });
  1099    });
  1100  
  1101    test('each host volume row contains information about the host volume', async function (assert) {
  1102      await ClientDetail.visit({ id: node.id });
  1103  
  1104      const sortedHostVolumes = Object.keys(node.hostVolumes)
  1105        .map((key) => node.hostVolumes[key])
  1106        .sortBy('Name');
  1107  
  1108      ClientDetail.hostVolumes[0].as((volume) => {
  1109        const volumeRow = sortedHostVolumes[0];
  1110        assert.equal(volume.name, volumeRow.Name);
  1111        assert.equal(volume.path, volumeRow.Path);
  1112        assert.equal(
  1113          volume.permissions,
  1114          volumeRow.ReadOnly ? 'Read' : 'Read/Write'
  1115        );
  1116      });
  1117    });
  1118  
  1119    test('the host volumes table is not shown if the client has no host volumes', async function (assert) {
  1120      node = server.create('node', 'noHostVolumes');
  1121  
  1122      await ClientDetail.visit({ id: node.id });
  1123  
  1124      assert.notOk(ClientDetail.hasHostVolumes);
  1125    });
  1126  
  1127    testFacet('Job', {
  1128      facet: ClientDetail.facets.job,
  1129      paramName: 'job',
  1130      expectedOptions(allocs) {
  1131        return Array.from(new Set(allocs.mapBy('jobId'))).sort();
  1132      },
  1133      async beforeEach() {
  1134        server.createList('job', 5);
  1135        await ClientDetail.visit({ id: node.id });
  1136      },
  1137      filter: (alloc, selection) => selection.includes(alloc.jobId),
  1138    });
  1139  
  1140    testFacet('Status', {
  1141      facet: ClientDetail.facets.status,
  1142      paramName: 'status',
  1143      expectedOptions: [
  1144        'Pending',
  1145        'Running',
  1146        'Complete',
  1147        'Failed',
  1148        'Lost',
  1149        'Unknown',
  1150      ],
  1151      async beforeEach() {
  1152        server.createList('job', 5, { createAllocations: false });
  1153        ['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach(
  1154          (s) => {
  1155            server.createList('allocation', 5, { clientStatus: s });
  1156          }
  1157        );
  1158  
  1159        await ClientDetail.visit({ id: node.id });
  1160      },
  1161      filter: (alloc, selection) => selection.includes(alloc.clientStatus),
  1162    });
  1163  
  1164    test('fiter results with no matches display empty message', async function (assert) {
  1165      const job = server.create('job', { createAllocations: false });
  1166      server.create('allocation', { jobId: job.id, clientStatus: 'running' });
  1167  
  1168      await ClientDetail.visit({ id: node.id });
  1169      const statusFacet = ClientDetail.facets.status;
  1170      await statusFacet.toggle();
  1171      await statusFacet.options.objectAt(0).toggle();
  1172  
  1173      assert.true(ClientDetail.emptyAllocations.isVisible);
  1174      assert.equal(ClientDetail.emptyAllocations.headline, 'No Matches');
  1175    });
  1176  });
  1177  
  1178  module('Acceptance | client detail (multi-namespace)', function (hooks) {
  1179    setupApplicationTest(hooks);
  1180    setupMirage(hooks);
  1181  
  1182    hooks.beforeEach(function () {
  1183      server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' });
  1184      node = server.db.nodes[0];
  1185  
  1186      // Related models
  1187      server.create('namespace');
  1188      server.create('namespace', { id: 'other-namespace' });
  1189  
  1190      server.create('agent');
  1191  
  1192      // Make a job for each namespace, but have both scheduled on the same node
  1193      server.create('job', {
  1194        id: 'job-1',
  1195        namespaceId: 'default',
  1196        createAllocations: false,
  1197      });
  1198      server.createList('allocation', 3, {
  1199        nodeId: node.id,
  1200        jobId: 'job-1',
  1201        clientStatus: 'running',
  1202      });
  1203  
  1204      server.create('job', {
  1205        id: 'job-2',
  1206        namespaceId: 'other-namespace',
  1207        createAllocations: false,
  1208      });
  1209      server.createList('allocation', 3, {
  1210        nodeId: node.id,
  1211        jobId: 'job-2',
  1212        clientStatus: 'running',
  1213      });
  1214    });
  1215  
  1216    test('when the node has allocations on different namespaces, the associated jobs are fetched correctly', async function (assert) {
  1217      window.localStorage.nomadActiveNamespace = 'other-namespace';
  1218  
  1219      await ClientDetail.visit({ id: node.id });
  1220  
  1221      assert.equal(
  1222        ClientDetail.allocations.length,
  1223        server.db.allocations.length,
  1224        'All allocations are scheduled on this node'
  1225      );
  1226      assert.ok(
  1227        server.pretender.handledRequests.findBy('url', '/v1/job/job-1'),
  1228        'Job One fetched correctly'
  1229      );
  1230      assert.ok(
  1231        server.pretender.handledRequests.findBy(
  1232          'url',
  1233          '/v1/job/job-2?namespace=other-namespace'
  1234        ),
  1235        'Job Two fetched correctly'
  1236      );
  1237    });
  1238  
  1239    testFacet('Namespace', {
  1240      facet: ClientDetail.facets.namespace,
  1241      paramName: 'namespace',
  1242      expectedOptions(allocs) {
  1243        return Array.from(new Set(allocs.mapBy('namespace'))).sort();
  1244      },
  1245      async beforeEach() {
  1246        await ClientDetail.visit({ id: node.id });
  1247      },
  1248      filter: (alloc, selection) => selection.includes(alloc.namespace),
  1249    });
  1250  
  1251    test('facet Namespace | selecting namespace filters job options', async function (assert) {
  1252      await ClientDetail.visit({ id: node.id });
  1253  
  1254      const nsFacet = ClientDetail.facets.namespace;
  1255      const jobFacet = ClientDetail.facets.job;
  1256  
  1257      // Select both namespaces.
  1258      await nsFacet.toggle();
  1259      await nsFacet.options.objectAt(0).toggle();
  1260      await nsFacet.options.objectAt(1).toggle();
  1261      await jobFacet.toggle();
  1262  
  1263      assert.deepEqual(
  1264        jobFacet.options.map((option) => option.label.trim()),
  1265        ['job-1', 'job-2']
  1266      );
  1267  
  1268      // Select juse one namespace.
  1269      await nsFacet.toggle();
  1270      await nsFacet.options.objectAt(1).toggle(); // deselect second option
  1271      await jobFacet.toggle();
  1272  
  1273      assert.deepEqual(
  1274        jobFacet.options.map((option) => option.label.trim()),
  1275        ['job-1']
  1276      );
  1277    });
  1278  });
  1279  
  1280  function testFacet(
  1281    label,
  1282    { facet, paramName, beforeEach, filter, expectedOptions }
  1283  ) {
  1284    test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) {
  1285      await beforeEach();
  1286      await facet.toggle();
  1287  
  1288      let expectation;
  1289      if (typeof expectedOptions === 'function') {
  1290        expectation = expectedOptions(server.db.allocations);
  1291      } else {
  1292        expectation = expectedOptions;
  1293      }
  1294  
  1295      assert.deepEqual(
  1296        facet.options.map((option) => option.label.trim()),
  1297        expectation,
  1298        'Options for facet are as expected'
  1299      );
  1300    });
  1301  
  1302    test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) {
  1303      let option;
  1304  
  1305      await beforeEach();
  1306  
  1307      await facet.toggle();
  1308      option = facet.options.objectAt(0);
  1309      await option.toggle();
  1310  
  1311      const selection = [option.key];
  1312      const expectedAllocs = server.db.allocations
  1313        .filter((alloc) => filter(alloc, selection))
  1314        .sortBy('modifyIndex')
  1315        .reverse();
  1316  
  1317      ClientDetail.allocations.forEach((alloc, index) => {
  1318        assert.equal(
  1319          alloc.id,
  1320          expectedAllocs[index].id,
  1321          `Allocation at ${index} is ${expectedAllocs[index].id}`
  1322        );
  1323      });
  1324    });
  1325  
  1326    test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
  1327      const selection = [];
  1328  
  1329      await beforeEach();
  1330      await facet.toggle();
  1331  
  1332      const option1 = facet.options.objectAt(0);
  1333      const option2 = facet.options.objectAt(1);
  1334      await option1.toggle();
  1335      selection.push(option1.key);
  1336      await option2.toggle();
  1337      selection.push(option2.key);
  1338  
  1339      const expectedAllocs = server.db.allocations
  1340        .filter((alloc) => filter(alloc, selection))
  1341        .sortBy('modifyIndex')
  1342        .reverse();
  1343  
  1344      ClientDetail.allocations.forEach((alloc, index) => {
  1345        assert.equal(
  1346          alloc.id,
  1347          expectedAllocs[index].id,
  1348          `Allocation at ${index} is ${expectedAllocs[index].id}`
  1349        );
  1350      });
  1351    });
  1352  
  1353    test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
  1354      const selection = [];
  1355  
  1356      await beforeEach();
  1357      await facet.toggle();
  1358  
  1359      const option1 = facet.options.objectAt(0);
  1360      const option2 = facet.options.objectAt(1);
  1361      await option1.toggle();
  1362      selection.push(option1.key);
  1363      await option2.toggle();
  1364      selection.push(option2.key);
  1365  
  1366      assert.equal(
  1367        currentURL(),
  1368        `/clients/${node.id}?${paramName}=${encodeURIComponent(
  1369          JSON.stringify(selection)
  1370        )}`,
  1371        'URL has the correct query param key and value'
  1372      );
  1373    });
  1374  }