github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/task-group-detail-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 { currentURL, settled } from '@ember/test-helpers';
     9  import { module, test } from 'qunit';
    10  import { setupApplicationTest } from 'ember-qunit';
    11  import { setupMirage } from 'ember-cli-mirage/test-support';
    12  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
    13  import {
    14    formatBytes,
    15    formatHertz,
    16    formatScheduledBytes,
    17    formatScheduledHertz,
    18  } from 'nomad-ui/utils/units';
    19  import TaskGroup from 'nomad-ui/tests/pages/jobs/job/task-group';
    20  import Layout from 'nomad-ui/tests/pages/layout';
    21  import pageSizeSelect from './behaviors/page-size-select';
    22  import moment from 'moment';
    23  
    24  let job;
    25  let taskGroup;
    26  let tasks;
    27  let allocations;
    28  let managementToken;
    29  
    30  const sum = (total, n) => total + n;
    31  
    32  module('Acceptance | task group detail', function (hooks) {
    33    setupApplicationTest(hooks);
    34    setupMirage(hooks);
    35  
    36    hooks.beforeEach(async function () {
    37      server.create('agent');
    38      server.create('node-pool');
    39      server.create('node', 'forceIPv4');
    40  
    41      job = server.create('job', {
    42        groupsCount: 2,
    43        createAllocations: false,
    44      });
    45  
    46      const taskGroups = server.db.taskGroups.where({ jobId: job.id });
    47      taskGroup = taskGroups[0];
    48  
    49      tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
    50  
    51      server.create('node', 'forceIPv4');
    52  
    53      allocations = server.createList('allocation', 2, {
    54        jobId: job.id,
    55        taskGroup: taskGroup.name,
    56        clientStatus: 'running',
    57      });
    58  
    59      // Allocations associated to a different task group on the job to
    60      // assert that they aren't showing up in on this page in error.
    61      server.createList('allocation', 3, {
    62        jobId: job.id,
    63        taskGroup: taskGroups[1].name,
    64        clientStatus: 'running',
    65      });
    66  
    67      // Set a static name to make the search test deterministic
    68      server.db.allocations.forEach((alloc) => {
    69        alloc.name = 'aaaaa';
    70      });
    71  
    72      // Mark the first alloc as rescheduled
    73      allocations[0].update({
    74        nextAllocation: allocations[1].id,
    75      });
    76      allocations[1].update({
    77        previousAllocation: allocations[0].id,
    78      });
    79  
    80      managementToken = server.create('token');
    81  
    82      window.localStorage.clear();
    83    });
    84  
    85    test('it passes an accessibility audit', async function (assert) {
    86      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
    87      await a11yAudit(assert);
    88    });
    89  
    90    test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function (assert) {
    91      const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0);
    92      const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0);
    93      const totalMemoryMax = tasks
    94        .map((t) => t.resources.MemoryMaxMB || t.resources.MemoryMB)
    95        .reduce(sum, 0);
    96      const totalDisk = taskGroup.ephemeralDisk.SizeMB;
    97  
    98      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
    99  
   100      assert.equal(TaskGroup.tasksCount, `# Tasks ${tasks.length}`, '# Tasks');
   101      assert.equal(
   102        TaskGroup.cpu,
   103        `Reserved CPU ${formatScheduledHertz(totalCPU, 'MHz')}`,
   104        'Aggregated CPU reservation for all tasks'
   105      );
   106  
   107      let totalMemoryMaxAddendum = '';
   108  
   109      if (totalMemoryMax > totalMemory) {
   110        totalMemoryMaxAddendum = ` (${formatScheduledBytes(
   111          totalMemoryMax,
   112          'MiB'
   113        )}Max)`;
   114      }
   115  
   116      assert.equal(
   117        TaskGroup.mem,
   118        `Reserved Memory ${formatScheduledBytes(
   119          totalMemory,
   120          'MiB'
   121        )}${totalMemoryMaxAddendum}`,
   122        'Aggregated Memory reservation for all tasks'
   123      );
   124      assert.equal(
   125        TaskGroup.disk,
   126        `Reserved Disk ${formatScheduledBytes(totalDisk, 'MiB')}`,
   127        'Aggregated Disk reservation for all tasks'
   128      );
   129  
   130      assert.ok(
   131        document.title.includes(`Task group ${taskGroup.name} - Job ${job.name}`)
   132      );
   133    });
   134  
   135    test('/jobs/:id/:task-group should have breadcrumbs for job and jobs', async function (assert) {
   136      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   137  
   138      assert.equal(
   139        Layout.breadcrumbFor('jobs.index').text,
   140        'Jobs',
   141        'First breadcrumb says jobs'
   142      );
   143      assert.equal(
   144        Layout.breadcrumbFor('jobs.job.index').text,
   145        `Job ${job.name}`,
   146        'Second breadcrumb says the job name'
   147      );
   148      assert.equal(
   149        Layout.breadcrumbFor('jobs.job.task-group').text,
   150        `Task Group ${taskGroup.name}`,
   151        'Third breadcrumb says the job name'
   152      );
   153    });
   154  
   155    test('/jobs/:id/:task-group first breadcrumb should link to jobs', async function (assert) {
   156      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   157  
   158      await Layout.breadcrumbFor('jobs.index').visit();
   159      assert.equal(currentURL(), '/jobs', 'First breadcrumb links back to jobs');
   160    });
   161  
   162    test('/jobs/:id/:task-group second breadcrumb should link to the job for the task group', async function (assert) {
   163      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   164  
   165      await Layout.breadcrumbFor('jobs.job.index').visit();
   166      assert.equal(
   167        currentURL(),
   168        `/jobs/${job.id}`,
   169        'Second breadcrumb links back to the job for the task group'
   170      );
   171    });
   172  
   173    test('when the user has a client token that has a namespace with a policy to run and scale a job the autoscaler options should be available', async function (assert) {
   174      window.localStorage.clear();
   175  
   176      const SCALE_AND_WRITE_NAMESPACE = 'scale-and-write-namespace';
   177      const READ_ONLY_NAMESPACE = 'read-only-namespace';
   178      const clientToken = server.create('token');
   179  
   180      server.create('namespace', { id: SCALE_AND_WRITE_NAMESPACE });
   181      const secondNamespace = server.create('namespace', {
   182        id: READ_ONLY_NAMESPACE,
   183      });
   184  
   185      job = server.create('job', {
   186        groupCount: 0,
   187        createAllocations: false,
   188        shallow: true,
   189        noActiveDeployment: true,
   190        namespaceId: SCALE_AND_WRITE_NAMESPACE,
   191      });
   192      const scalingGroup = server.create('task-group', {
   193        job,
   194        name: 'scaling',
   195        count: 1,
   196        shallow: true,
   197        withScaling: true,
   198      });
   199      job.update({ taskGroupIds: [scalingGroup.id] });
   200  
   201      const job2 = server.create('job', {
   202        groupCount: 0,
   203        createAllocations: false,
   204        shallow: true,
   205        noActiveDeployment: true,
   206        namespaceId: READ_ONLY_NAMESPACE,
   207      });
   208      const scalingGroup2 = server.create('task-group', {
   209        job: job2,
   210        name: 'scaling',
   211        count: 1,
   212        shallow: true,
   213        withScaling: true,
   214      });
   215      job2.update({ taskGroupIds: [scalingGroup2.id] });
   216  
   217      const policy = server.create('policy', {
   218        id: 'something',
   219        name: 'something',
   220        rulesJSON: {
   221          Namespaces: [
   222            {
   223              Name: SCALE_AND_WRITE_NAMESPACE,
   224              Capabilities: ['scale-job', 'submit-job', 'read-job', 'list-jobs'],
   225            },
   226            {
   227              Name: READ_ONLY_NAMESPACE,
   228              Capabilities: ['list-jobs', 'read-job'],
   229            },
   230          ],
   231        },
   232      });
   233  
   234      clientToken.policyIds = [policy.id];
   235      clientToken.save();
   236  
   237      window.localStorage.nomadTokenSecret = clientToken.secretId;
   238  
   239      await TaskGroup.visit({
   240        id: `${job.id}@${SCALE_AND_WRITE_NAMESPACE}`,
   241        name: scalingGroup.name,
   242      });
   243  
   244      assert.equal(
   245        decodeURIComponent(currentURL()),
   246        `/jobs/${job.id}@${SCALE_AND_WRITE_NAMESPACE}/scaling`
   247      );
   248      assert.notOk(TaskGroup.countStepper.increment.isDisabled);
   249  
   250      await TaskGroup.visit({
   251        id: `${job2.id}@${secondNamespace.name}`,
   252        name: scalingGroup2.name,
   253      });
   254      assert.equal(
   255        decodeURIComponent(currentURL()),
   256        `/jobs/${job2.id}@${READ_ONLY_NAMESPACE}/scaling`
   257      );
   258      assert.ok(TaskGroup.countStepper.increment.isDisabled);
   259    });
   260  
   261    test('/jobs/:id/:task-group should list one page of allocations for the task group', async function (assert) {
   262      server.createList('allocation', TaskGroup.pageSize, {
   263        jobId: job.id,
   264        taskGroup: taskGroup.name,
   265        clientStatus: 'running',
   266      });
   267  
   268      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   269  
   270      assert.ok(
   271        server.db.allocations.where({ jobId: job.id }).length >
   272          TaskGroup.pageSize,
   273        'There are enough allocations to invoke pagination'
   274      );
   275  
   276      assert.equal(
   277        TaskGroup.allocations.length,
   278        TaskGroup.pageSize,
   279        'All allocations for the task group'
   280      );
   281    });
   282  
   283    test('each allocation should show basic information about the allocation', async function (assert) {
   284      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   285  
   286      const allocation = allocations.sortBy('modifyIndex').reverse()[0];
   287      const allocationRow = TaskGroup.allocations.objectAt(0);
   288  
   289      assert.equal(
   290        allocationRow.shortId,
   291        allocation.id.split('-')[0],
   292        'Allocation short id'
   293      );
   294      assert.equal(
   295        allocationRow.createTime,
   296        moment(allocation.createTime / 1000000).format('MMM DD HH:mm:ss ZZ'),
   297        'Allocation create time'
   298      );
   299      assert.equal(
   300        allocationRow.modifyTime,
   301        moment(allocation.modifyTime / 1000000).fromNow(),
   302        'Allocation modify time'
   303      );
   304      assert.equal(
   305        allocationRow.status,
   306        allocation.clientStatus,
   307        'Client status'
   308      );
   309      assert.equal(
   310        allocationRow.jobVersion,
   311        allocation.jobVersion,
   312        'Job Version'
   313      );
   314      assert.equal(
   315        allocationRow.client,
   316        server.db.nodes.find(allocation.nodeId).id.split('-')[0],
   317        'Node ID'
   318      );
   319      assert.equal(
   320        allocationRow.volume,
   321        Object.keys(taskGroup.volumes).length ? 'Yes' : '',
   322        'Volumes'
   323      );
   324  
   325      await allocationRow.visitClient();
   326  
   327      assert.equal(
   328        currentURL(),
   329        `/clients/${allocation.nodeId}`,
   330        'Node links to node page'
   331      );
   332    });
   333  
   334    test('each allocation should show stats about the allocation', async function (assert) {
   335      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   336  
   337      const allocation = allocations.sortBy('name')[0];
   338      const allocationRow = TaskGroup.allocations.objectAt(0);
   339  
   340      const allocStats = server.db.clientAllocationStats.find(allocation.id);
   341      const tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
   342  
   343      const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
   344      const memoryUsed = tasks.reduce(
   345        (sum, task) => sum + task.resources.MemoryMB,
   346        0
   347      );
   348  
   349      assert.equal(
   350        allocationRow.cpu,
   351        Math.floor(allocStats.resourceUsage.CpuStats.TotalTicks) / cpuUsed,
   352        'CPU %'
   353      );
   354  
   355      const roundedTicks = Math.floor(
   356        allocStats.resourceUsage.CpuStats.TotalTicks
   357      );
   358      assert.equal(
   359        allocationRow.cpuTooltip,
   360        `${formatHertz(roundedTicks, 'MHz')} / ${formatHertz(cpuUsed, 'MHz')}`,
   361        'Detailed CPU information is in a tooltip'
   362      );
   363  
   364      assert.equal(
   365        allocationRow.mem,
   366        allocStats.resourceUsage.MemoryStats.RSS / 1024 / 1024 / memoryUsed,
   367        'Memory used'
   368      );
   369  
   370      assert.equal(
   371        allocationRow.memTooltip,
   372        `${formatBytes(allocStats.resourceUsage.MemoryStats.RSS)} / ${formatBytes(
   373          memoryUsed,
   374          'MiB'
   375        )}`,
   376        'Detailed memory information is in a tooltip'
   377      );
   378    });
   379  
   380    test('when the allocation search has no matches, there is an empty message', async function (assert) {
   381      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   382  
   383      await TaskGroup.search('zzzzzz');
   384  
   385      assert.ok(TaskGroup.isEmpty, 'Empty state is shown');
   386      assert.equal(
   387        TaskGroup.emptyState.headline,
   388        'No Matches',
   389        'Empty state has an appropriate message'
   390      );
   391    });
   392  
   393    test('when the allocation has reschedule events, the allocation row is denoted with an icon', async function (assert) {
   394      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   395  
   396      const rescheduleRow = TaskGroup.allocationFor(allocations[0].id);
   397      const normalRow = TaskGroup.allocationFor(allocations[1].id);
   398  
   399      assert.ok(
   400        rescheduleRow.rescheduled,
   401        'Reschedule row has a reschedule icon'
   402      );
   403      assert.notOk(normalRow.rescheduled, 'Normal row has no reschedule icon');
   404    });
   405  
   406    test('/jobs/:id/:task-group should present task lifecycles', async function (assert) {
   407      job = server.create('job', {
   408        groupsCount: 2,
   409        groupTaskCount: 3,
   410      });
   411  
   412      const taskGroups = server.db.taskGroups.where({ jobId: job.id });
   413      taskGroup = taskGroups[0];
   414  
   415      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   416  
   417      assert.ok(TaskGroup.lifecycleChart.isPresent);
   418      assert.equal(
   419        TaskGroup.lifecycleChart.title,
   420        'Task Lifecycle Configuration'
   421      );
   422  
   423      tasks = taskGroup.taskIds.map((id) => server.db.tasks.find(id));
   424      const taskNames = tasks.mapBy('name');
   425  
   426      // This is thoroughly tested in allocation detail tests, so this mostly checks what’s different
   427  
   428      assert.equal(TaskGroup.lifecycleChart.tasks.length, 3);
   429  
   430      TaskGroup.lifecycleChart.tasks.forEach((Task) => {
   431        assert.ok(taskNames.includes(Task.name));
   432        assert.notOk(Task.isActive);
   433        assert.notOk(Task.isFinished);
   434      });
   435    });
   436  
   437    test('when the task group depends on volumes, the volumes table is shown', async function (assert) {
   438      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   439  
   440      assert.ok(TaskGroup.hasVolumes);
   441      assert.equal(
   442        TaskGroup.volumes.length,
   443        Object.keys(taskGroup.volumes).length
   444      );
   445    });
   446  
   447    test('when the task group does not depend on volumes, the volumes table is not shown', async function (assert) {
   448      job = server.create('job', { noHostVolumes: true, shallow: true });
   449      taskGroup = server.db.taskGroups.where({ jobId: job.id })[0];
   450  
   451      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   452  
   453      assert.notOk(TaskGroup.hasVolumes);
   454    });
   455  
   456    test('each row in the volumes table lists information about the volume', async function (assert) {
   457      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   458  
   459      TaskGroup.volumes[0].as((volumeRow) => {
   460        const volume = taskGroup.volumes[volumeRow.name];
   461        assert.equal(volumeRow.name, volume.Name);
   462        assert.equal(volumeRow.type, volume.Type);
   463        assert.equal(volumeRow.source, volume.Source);
   464        assert.equal(
   465          volumeRow.permissions,
   466          volume.ReadOnly ? 'Read' : 'Read/Write'
   467        );
   468      });
   469    });
   470  
   471    test('the count stepper sends the appropriate POST request', async function (assert) {
   472      window.localStorage.nomadTokenSecret = managementToken.secretId;
   473  
   474      job = server.create('job', {
   475        groupCount: 0,
   476        createAllocations: false,
   477        shallow: true,
   478        noActiveDeployment: true,
   479      });
   480      const scalingGroup = server.create('task-group', {
   481        job,
   482        name: 'scaling',
   483        count: 1,
   484        shallow: true,
   485        withScaling: true,
   486      });
   487      job.update({ taskGroupIds: [scalingGroup.id] });
   488  
   489      await TaskGroup.visit({ id: job.id, name: scalingGroup.name });
   490      await TaskGroup.countStepper.increment.click();
   491      await settled();
   492  
   493      const scaleRequest = server.pretender.handledRequests.find(
   494        (req) => req.method === 'POST' && req.url.endsWith('/scale')
   495      );
   496      const requestBody = JSON.parse(scaleRequest.requestBody);
   497      assert.equal(requestBody.Target.Group, scalingGroup.name);
   498      assert.equal(requestBody.Count, scalingGroup.count + 1);
   499    });
   500  
   501    test('the count stepper is disabled when a deployment is running', async function (assert) {
   502      window.localStorage.nomadTokenSecret = managementToken.secretId;
   503  
   504      job = server.create('job', {
   505        groupCount: 0,
   506        createAllocations: false,
   507        shallow: true,
   508        activeDeployment: true,
   509      });
   510      const scalingGroup = server.create('task-group', {
   511        job,
   512        name: 'scaling',
   513        count: 1,
   514        shallow: true,
   515        withScaling: true,
   516      });
   517      job.update({ taskGroupIds: [scalingGroup.id] });
   518  
   519      await TaskGroup.visit({ id: job.id, name: scalingGroup.name });
   520  
   521      assert.ok(TaskGroup.countStepper.input.isDisabled);
   522      assert.ok(TaskGroup.countStepper.increment.isDisabled);
   523      assert.ok(TaskGroup.countStepper.decrement.isDisabled);
   524    });
   525  
   526    test('when the job for the task group is not found, an error message is shown, but the URL persists', async function (assert) {
   527      await TaskGroup.visit({
   528        id: 'not-a-real-job',
   529        name: 'not-a-real-task-group',
   530      });
   531  
   532      assert.equal(
   533        server.pretender.handledRequests
   534          .filter((request) => !request.url.includes('policy'))
   535          .findBy('status', 404).url,
   536        '/v1/job/not-a-real-job',
   537        'A request to the nonexistent job is made'
   538      );
   539      assert.equal(
   540        currentURL(),
   541        '/jobs/not-a-real-job/not-a-real-task-group',
   542        'The URL persists'
   543      );
   544      assert.ok(TaskGroup.error.isPresent, 'Error message is shown');
   545      assert.equal(
   546        TaskGroup.error.title,
   547        'Not Found',
   548        'Error message is for 404'
   549      );
   550    });
   551  
   552    test('when the task group is not found on the job, an error message is shown, but the URL persists', async function (assert) {
   553      await TaskGroup.visit({ id: job.id, name: 'not-a-real-task-group' });
   554  
   555      assert.ok(
   556        server.pretender.handledRequests
   557          .filterBy('status', 200)
   558          .mapBy('url')
   559          .includes(`/v1/job/${job.id}`),
   560        'A request to the job is made and succeeds'
   561      );
   562      assert.equal(
   563        currentURL(),
   564        `/jobs/${job.id}/not-a-real-task-group`,
   565        'The URL persists'
   566      );
   567      assert.ok(TaskGroup.error.isPresent, 'Error message is shown');
   568      assert.equal(
   569        TaskGroup.error.title,
   570        'Not Found',
   571        'Error message is for 404'
   572      );
   573    });
   574  
   575    pageSizeSelect({
   576      resourceName: 'allocation',
   577      pageObject: TaskGroup,
   578      pageObjectList: TaskGroup.allocations,
   579      async setup() {
   580        server.createList('allocation', TaskGroup.pageSize, {
   581          jobId: job.id,
   582          taskGroup: taskGroup.name,
   583          clientStatus: 'running',
   584        });
   585  
   586        await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   587      },
   588    });
   589  
   590    test('when a task group has no scaling events, there is no recent scaling events section', async function (assert) {
   591      const taskGroupScale = job.jobScale.taskGroupScales.models.find(
   592        (m) => m.name === taskGroup.name
   593      );
   594      taskGroupScale.update({ events: [] });
   595  
   596      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   597  
   598      assert.notOk(TaskGroup.hasScaleEvents);
   599    });
   600  
   601    test('the recent scaling events section shows all recent scaling events in reverse chronological order', async function (assert) {
   602      const taskGroupScale = job.jobScale.taskGroupScales.models.find(
   603        (m) => m.name === taskGroup.name
   604      );
   605      taskGroupScale.update({
   606        events: [
   607          server.create('scale-event', { error: true }),
   608          server.create('scale-event', { error: true }),
   609          server.create('scale-event', { error: true }),
   610          server.create('scale-event', { error: true }),
   611          server.create('scale-event', { count: 3, error: false }),
   612          server.create('scale-event', { count: 1, error: false }),
   613        ],
   614      });
   615      const scaleEvents = taskGroupScale.events.models.sortBy('time').reverse();
   616      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   617  
   618      assert.ok(TaskGroup.hasScaleEvents);
   619      assert.notOk(TaskGroup.hasScalingTimeline);
   620  
   621      scaleEvents.forEach((scaleEvent, idx) => {
   622        const ScaleEvent = TaskGroup.scaleEvents[idx];
   623        assert.equal(
   624          ScaleEvent.time,
   625          moment(scaleEvent.time / 1000000).format('MMM DD HH:mm:ss ZZ')
   626        );
   627        assert.equal(ScaleEvent.message, scaleEvent.message);
   628  
   629        if (scaleEvent.count != null) {
   630          assert.equal(ScaleEvent.count, scaleEvent.count);
   631        }
   632  
   633        if (scaleEvent.error) {
   634          assert.ok(ScaleEvent.error);
   635        }
   636  
   637        if (Object.keys(scaleEvent.meta).length) {
   638          assert.ok(ScaleEvent.isToggleable);
   639        } else {
   640          assert.notOk(ScaleEvent.isToggleable);
   641        }
   642      });
   643    });
   644  
   645    test('when a task group has at least two count scaling events and the count scaling events outnumber the non-count scaling events, a timeline is shown in addition to the accordion', async function (assert) {
   646      const taskGroupScale = job.jobScale.taskGroupScales.models.find(
   647        (m) => m.name === taskGroup.name
   648      );
   649      taskGroupScale.update({
   650        events: [
   651          server.create('scale-event', { error: true }),
   652          server.create('scale-event', { error: true }),
   653          server.create('scale-event', { count: 7, error: false }),
   654          server.create('scale-event', { count: 10, error: false }),
   655          server.create('scale-event', { count: 2, error: false }),
   656          server.create('scale-event', { count: 3, error: false }),
   657          server.create('scale-event', { count: 2, error: false }),
   658          server.create('scale-event', { count: 9, error: false }),
   659          server.create('scale-event', { count: 1, error: false }),
   660        ],
   661      });
   662      const scaleEvents = taskGroupScale.events.models.sortBy('time').reverse();
   663      await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   664  
   665      assert.ok(TaskGroup.hasScaleEvents);
   666      assert.ok(TaskGroup.hasScalingTimeline);
   667  
   668      assert.equal(
   669        TaskGroup.scalingAnnotations.length,
   670        scaleEvents.filter((ev) => ev.count == null).length
   671      );
   672    });
   673  
   674    testFacet('Status', {
   675      facet: TaskGroup.facets.status,
   676      paramName: 'status',
   677      expectedOptions: [
   678        'Pending',
   679        'Running',
   680        'Complete',
   681        'Failed',
   682        'Lost',
   683        'Unknown',
   684      ],
   685      async beforeEach() {
   686        ['pending', 'running', 'complete', 'failed', 'lost', 'unknown'].forEach(
   687          (s) => {
   688            server.createList('allocation', 5, { clientStatus: s });
   689          }
   690        );
   691        await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   692      },
   693      filter: (alloc, selection) =>
   694        alloc.jobId == job.id &&
   695        alloc.taskGroup == taskGroup.name &&
   696        selection.includes(alloc.clientStatus),
   697    });
   698  
   699    testFacet('Client', {
   700      facet: TaskGroup.facets.client,
   701      paramName: 'client',
   702      expectedOptions(allocs) {
   703        return Array.from(
   704          new Set(
   705            allocs
   706              .filter(
   707                (alloc) =>
   708                  alloc.jobId == job.id && alloc.taskGroup == taskGroup.name
   709              )
   710              .mapBy('nodeId')
   711              .map((id) => id.split('-')[0])
   712          )
   713        ).sort();
   714      },
   715      async beforeEach() {
   716        const nodes = server.createList('node', 3, 'forceIPv4');
   717        nodes.forEach((node) =>
   718          server.createList('allocation', 5, {
   719            nodeId: node.id,
   720            jobId: job.id,
   721            taskGroup: taskGroup.name,
   722          })
   723        );
   724        await TaskGroup.visit({ id: job.id, name: taskGroup.name });
   725      },
   726      filter: (alloc, selection) =>
   727        alloc.jobId == job.id &&
   728        alloc.taskGroup == taskGroup.name &&
   729        selection.includes(alloc.nodeId.split('-')[0]),
   730    });
   731  });
   732  
   733  function testFacet(
   734    label,
   735    { facet, paramName, beforeEach, filter, expectedOptions }
   736  ) {
   737    test(`facet ${label} | the ${label} facet has the correct options`, async function (assert) {
   738      await beforeEach();
   739      await facet.toggle();
   740  
   741      let expectation;
   742      if (typeof expectedOptions === 'function') {
   743        expectation = expectedOptions(server.db.allocations);
   744      } else {
   745        expectation = expectedOptions;
   746      }
   747  
   748      assert.deepEqual(
   749        facet.options.map((option) => option.label.trim()),
   750        expectation,
   751        'Options for facet are as expected'
   752      );
   753    });
   754  
   755    test(`facet ${label} | the ${label} facet filters the allocations list by ${label}`, async function (assert) {
   756      let option;
   757  
   758      await beforeEach();
   759  
   760      await facet.toggle();
   761      option = facet.options.objectAt(0);
   762      await option.toggle();
   763  
   764      const selection = [option.key];
   765      const expectedAllocs = server.db.allocations
   766        .filter((alloc) => filter(alloc, selection))
   767        .sortBy('modifyIndex')
   768        .reverse();
   769  
   770      TaskGroup.allocations.forEach((alloc, index) => {
   771        assert.equal(
   772          alloc.id,
   773          expectedAllocs[index].id,
   774          `Allocation at ${index} is ${expectedAllocs[index].id}`
   775        );
   776      });
   777    });
   778  
   779    test(`facet ${label} | selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
   780      const selection = [];
   781  
   782      await beforeEach();
   783      await facet.toggle();
   784  
   785      const option1 = facet.options.objectAt(0);
   786      const option2 = facet.options.objectAt(1);
   787      await option1.toggle();
   788      selection.push(option1.key);
   789      await option2.toggle();
   790      selection.push(option2.key);
   791  
   792      const expectedAllocs = server.db.allocations
   793        .filter((alloc) => filter(alloc, selection))
   794        .sortBy('modifyIndex')
   795        .reverse();
   796  
   797      TaskGroup.allocations.forEach((alloc, index) => {
   798        assert.equal(
   799          alloc.id,
   800          expectedAllocs[index].id,
   801          `Allocation at ${index} is ${expectedAllocs[index].id}`
   802        );
   803      });
   804    });
   805  
   806    test(`facet ${label} | selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
   807      const selection = [];
   808  
   809      await beforeEach();
   810      await facet.toggle();
   811  
   812      const option1 = facet.options.objectAt(0);
   813      const option2 = facet.options.objectAt(1);
   814      await option1.toggle();
   815      selection.push(option1.key);
   816      await option2.toggle();
   817      selection.push(option2.key);
   818  
   819      assert.equal(
   820        currentURL(),
   821        `/jobs/${job.id}/${taskGroup.name}?${paramName}=${encodeURIComponent(
   822          JSON.stringify(selection)
   823        )}`,
   824        'URL has the correct query param key and value'
   825      );
   826    });
   827  }