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

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