github.com/hernad/nomad@v1.6.112/ui/tests/acceptance/exec-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  import { module, skip, test } from 'qunit';
     8  import { currentURL, settled } from '@ember/test-helpers';
     9  import { setupApplicationTest } from 'ember-qunit';
    10  import { setupMirage } from 'ember-cli-mirage/test-support';
    11  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
    12  import Service from '@ember/service';
    13  import Exec from 'nomad-ui/tests/pages/exec';
    14  import KEYS from 'nomad-ui/utils/keys';
    15  import percySnapshot from '@percy/ember';
    16  import faker from 'nomad-ui/mirage/faker';
    17  
    18  module('Acceptance | exec', function (hooks) {
    19    setupApplicationTest(hooks);
    20    setupMirage(hooks);
    21  
    22    hooks.beforeEach(async function () {
    23      window.localStorage.clear();
    24      window.sessionStorage.clear();
    25  
    26      faker.seed(1);
    27  
    28      server.create('agent');
    29      server.create('node-pool');
    30      server.create('node');
    31  
    32      this.job = server.create('job', {
    33        groupsCount: 2,
    34        groupTaskCount: 5,
    35        createAllocations: false,
    36        status: 'running',
    37      });
    38  
    39      this.job.taskGroups.models.forEach((taskGroup) => {
    40        const alloc = server.create('allocation', {
    41          jobId: this.job.id,
    42          taskGroup: taskGroup.name,
    43          forceRunningClientStatus: true,
    44        });
    45        server.db.taskStates.update(
    46          { allocationId: alloc.id },
    47          { state: 'running' }
    48        );
    49      });
    50    });
    51  
    52    test('it passes an accessibility audit', async function (assert) {
    53      await Exec.visitJob({ job: this.job.id });
    54      await a11yAudit(assert);
    55    });
    56  
    57    test('/exec/:job should show the region, namespace, and job name', async function (assert) {
    58      server.create('namespace');
    59      let namespace = server.create('namespace');
    60  
    61      server.create('region', { id: 'global' });
    62      server.create('region', { id: 'region-2' });
    63  
    64      this.job = server.create('job', {
    65        createAllocations: false,
    66        namespaceId: namespace.id,
    67        status: 'running',
    68      });
    69  
    70      await Exec.visitJob({
    71        job: this.job.id,
    72        namespace: namespace.id,
    73        region: 'region-2',
    74      });
    75  
    76      assert.ok(document.title.includes('Exec - region-2'));
    77  
    78      assert.equal(Exec.header.region.text, this.job.region);
    79      assert.equal(Exec.header.namespace.text, this.job.namespace);
    80      assert.equal(Exec.header.job, this.job.name);
    81  
    82      assert.notOk(Exec.jobDead.isPresent);
    83    });
    84  
    85    test('/exec/:job should not show region and namespace when there are none', async function (assert) {
    86      await Exec.visitJob({ job: this.job.id });
    87  
    88      assert.ok(Exec.header.region.isHidden);
    89      assert.ok(Exec.header.namespace.isHidden);
    90    });
    91  
    92    test('/exec/:job should show the task groups collapsed by default and allow the tasks to be shown', async function (assert) {
    93      const firstTaskGroup = this.job.taskGroups.models.sortBy('name')[0];
    94      await Exec.visitJob({ job: this.job.id });
    95  
    96      assert.equal(Exec.taskGroups.length, this.job.taskGroups.length);
    97  
    98      assert.equal(Exec.taskGroups[0].name, firstTaskGroup.name);
    99      assert.equal(Exec.taskGroups[0].tasks.length, 0);
   100      assert.ok(Exec.taskGroups[0].chevron.isRight);
   101      assert.notOk(Exec.taskGroups[0].isLoading);
   102  
   103      await Exec.taskGroups[0].click();
   104      assert.equal(Exec.taskGroups[0].tasks.length, firstTaskGroup.tasks.length);
   105      assert.notOk(Exec.taskGroups[0].tasks[0].isActive);
   106      assert.ok(Exec.taskGroups[0].chevron.isDown);
   107  
   108      await percySnapshot(assert);
   109  
   110      await Exec.taskGroups[0].click();
   111      assert.equal(Exec.taskGroups[0].tasks.length, 0);
   112    });
   113  
   114    test('/exec/:job should require selecting a task', async function (assert) {
   115      await Exec.visitJob({ job: this.job.id });
   116  
   117      assert.equal(
   118        window.execTerminal.buffer.active.getLine(0).translateToString().trim(),
   119        'Select a task to start your session.'
   120      );
   121    });
   122  
   123    test('a task group with a pending allocation shows a loading spinner', async function (assert) {
   124      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   125      this.server.db.allocations.update(
   126        { taskGroup: taskGroup.name },
   127        { clientStatus: 'pending' }
   128      );
   129  
   130      await Exec.visitJob({ job: this.job.id });
   131      assert.ok(Exec.taskGroups[0].isLoading);
   132    });
   133  
   134    test('a task group with no running task states or pending allocations should not be shown', async function (assert) {
   135      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   136      this.server.db.allocations.update(
   137        { taskGroup: taskGroup.name },
   138        { clientStatus: 'failed' }
   139      );
   140  
   141      await Exec.visitJob({ job: this.job.id });
   142      assert.notEqual(Exec.taskGroups[0].name, taskGroup.name);
   143    });
   144  
   145    test('an inactive task should not be shown', async function (assert) {
   146      let notRunningTaskGroup = this.job.taskGroups.models.sortBy('name')[0];
   147      this.server.db.allocations.update(
   148        { taskGroup: notRunningTaskGroup.name },
   149        { clientStatus: 'failed' }
   150      );
   151  
   152      let runningTaskGroup = this.job.taskGroups.models.sortBy('name')[1];
   153      runningTaskGroup.tasks.models.forEach((task, index) => {
   154        let state = 'running';
   155        if (index > 0) {
   156          state = 'dead';
   157        }
   158        this.server.db.taskStates.update({ name: task.name }, { state });
   159      });
   160  
   161      await Exec.visitJob({ job: this.job.id });
   162      await Exec.taskGroups[0].click();
   163  
   164      assert.equal(Exec.taskGroups[0].tasks.length, 1);
   165    });
   166  
   167    test('a task that becomes active should appear', async function (assert) {
   168      let notRunningTaskGroup = this.job.taskGroups.models.sortBy('name')[0];
   169      this.server.db.allocations.update(
   170        { taskGroup: notRunningTaskGroup.name },
   171        { clientStatus: 'failed' }
   172      );
   173  
   174      let runningTaskGroup = this.job.taskGroups.models.sortBy('name')[1];
   175      let changingTaskStateName;
   176      runningTaskGroup.tasks.models.sortBy('name').forEach((task, index) => {
   177        let state = 'running';
   178        if (index > 0) {
   179          state = 'dead';
   180        }
   181        this.server.db.taskStates.update({ name: task.name }, { state });
   182  
   183        if (index === 1) {
   184          changingTaskStateName = task.name;
   185        }
   186      });
   187  
   188      await Exec.visitJob({ job: this.job.id });
   189      await Exec.taskGroups[0].click();
   190  
   191      assert.equal(Exec.taskGroups[0].tasks.length, 1);
   192  
   193      // Approximate new task arrival via polling by changing a finished task state to be not finished
   194      this.owner
   195        .lookup('service:store')
   196        .peekAll('allocation')
   197        .forEach((allocation) => {
   198          const changingTaskState = allocation.states.findBy(
   199            'name',
   200            changingTaskStateName
   201          );
   202  
   203          if (changingTaskState) {
   204            changingTaskState.set('state', 'running');
   205          }
   206        });
   207  
   208      await settled();
   209  
   210      assert.equal(Exec.taskGroups[0].tasks.length, 2);
   211      assert.equal(Exec.taskGroups[0].tasks[1].name, changingTaskStateName);
   212    });
   213  
   214    test('a dead job has an inert window', async function (assert) {
   215      this.job.status = 'dead';
   216      this.job.save();
   217  
   218      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   219      let task = taskGroup.tasks.models.sortBy('name')[0];
   220  
   221      this.server.db.taskStates.update({ finishedAt: new Date() });
   222  
   223      await Exec.visitTask({
   224        job: this.job.id,
   225        task_group: taskGroup.name,
   226        task_name: task.name,
   227      });
   228  
   229      assert.ok(Exec.jobDead.isPresent);
   230      assert.equal(
   231        Exec.jobDead.message,
   232        `Job ${this.job.name} is dead and cannot host an exec session.`
   233      );
   234    });
   235  
   236    test('when a job dies the exec window becomes inert', async function (assert) {
   237      await Exec.visitJob({ job: this.job.id });
   238  
   239      // Approximate live-polling job death
   240      this.owner
   241        .lookup('service:store')
   242        .peekAll('job')
   243        .forEach((job) => job.set('status', 'dead'));
   244  
   245      await settled();
   246  
   247      assert.ok(Exec.jobDead.isPresent);
   248    });
   249  
   250    test('visiting a path with a task group should open the group by default', async function (assert) {
   251      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   252      await Exec.visitTaskGroup({ job: this.job.id, task_group: taskGroup.name });
   253  
   254      assert.equal(Exec.taskGroups[0].tasks.length, taskGroup.tasks.length);
   255      assert.ok(Exec.taskGroups[0].chevron.isDown);
   256  
   257      let task = taskGroup.tasks.models.sortBy('name')[0];
   258      await Exec.visitTask({
   259        job: this.job.id,
   260        task_group: taskGroup.name,
   261        task_name: task.name,
   262      });
   263  
   264      assert.equal(Exec.taskGroups[0].tasks.length, taskGroup.tasks.length);
   265      assert.ok(Exec.taskGroups[0].chevron.isDown);
   266    });
   267  
   268    test('navigating to a task adds its name to the route, chooses an allocation, and assigns a default command', async function (assert) {
   269      await Exec.visitJob({ job: this.job.id });
   270      await Exec.taskGroups[0].click();
   271      await Exec.taskGroups[0].tasks[0].click();
   272  
   273      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   274      let task = taskGroup.tasks.models.sortBy('name')[0];
   275  
   276      let taskStates = this.server.db.taskStates.where({
   277        name: task.name,
   278      });
   279      let allocationId = taskStates.find((ts) => ts.allocationId).allocationId;
   280  
   281      await settled();
   282  
   283      assert.equal(
   284        currentURL(),
   285        `/exec/${this.job.id}/${taskGroup.name}/${task.name}`
   286      );
   287      assert.ok(Exec.taskGroups[0].tasks[0].isActive);
   288  
   289      assert.equal(
   290        window.execTerminal.buffer.active.getLine(2).translateToString().trim(),
   291        'Multiple instances of this task are running. The allocation below was selected by random draw.'
   292      );
   293  
   294      assert.equal(
   295        window.execTerminal.buffer.active.getLine(4).translateToString().trim(),
   296        'Customize your command, then hit ‘return’ to run.'
   297      );
   298  
   299      assert.equal(
   300        window.execTerminal.buffer.active.getLine(6).translateToString().trim(),
   301        `$ nomad alloc exec -i -t -task ${task.name} ${
   302          allocationId.split('-')[0]
   303        } /bin/bash`
   304      );
   305  
   306      const terminalTextRendered = assert.async();
   307      setTimeout(async () => {
   308        await percySnapshot(assert);
   309        terminalTextRendered();
   310      }, 1000);
   311    });
   312  
   313    test('an allocation can be specified', async function (assert) {
   314      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   315      let task = taskGroup.tasks.models.sortBy('name')[0];
   316      let allocations = this.server.db.allocations.where({
   317        jobId: this.job.id,
   318        taskGroup: taskGroup.name,
   319      });
   320      let allocation = allocations[allocations.length - 1];
   321  
   322      this.server.db.taskStates.update(
   323        { name: task.name },
   324        { name: 'spaced name!' }
   325      );
   326  
   327      task.name = 'spaced name!';
   328      task.save();
   329  
   330      await Exec.visitTask({
   331        job: this.job.id,
   332        task_group: taskGroup.name,
   333        task_name: task.name,
   334        allocation: allocation.id.split('-')[0],
   335      });
   336  
   337      await settled();
   338  
   339      assert.equal(
   340        window.execTerminal.buffer.active.getLine(4).translateToString().trim(),
   341        `$ nomad alloc exec -i -t -task spaced\\ name\\! ${
   342          allocation.id.split('-')[0]
   343        } /bin/bash`
   344      );
   345    });
   346  
   347    test('running the command opens the socket for reading/writing and detects it closing', async function (assert) {
   348      let mockSocket = new MockSocket();
   349      let mockSockets = Service.extend({
   350        getTaskStateSocket(taskState, command) {
   351          assert.equal(taskState.name, task.name);
   352          assert.equal(taskState.allocation.id, allocation.id);
   353  
   354          assert.equal(command, '/bin/bash');
   355  
   356          assert.step('Socket built');
   357  
   358          return mockSocket;
   359        },
   360      });
   361  
   362      this.owner.register('service:sockets', mockSockets);
   363  
   364      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   365      let task = taskGroup.tasks.models.sortBy('name')[0];
   366      let allocations = this.server.db.allocations.where({
   367        jobId: this.job.id,
   368        taskGroup: taskGroup.name,
   369      });
   370      let allocation = allocations[allocations.length - 1];
   371  
   372      await Exec.visitTask({
   373        job: this.job.id,
   374        task_group: taskGroup.name,
   375        task_name: task.name,
   376        allocation: allocation.id.split('-')[0],
   377      });
   378  
   379      await settled();
   380  
   381      await Exec.terminal.pressEnter();
   382      await settled();
   383      mockSocket.onopen();
   384  
   385      assert.verifySteps(['Socket built']);
   386  
   387      mockSocket.onmessage({
   388        data: '{"stdout":{"data":"c2gtMy4yIPCfpbMk"}}',
   389      });
   390  
   391      await settled();
   392  
   393      assert.equal(
   394        window.execTerminal.buffer.active.getLine(5).translateToString().trim(),
   395        'sh-3.2 🥳$'
   396      );
   397  
   398      await Exec.terminal.pressEnter();
   399      await settled();
   400  
   401      assert.deepEqual(mockSocket.sent, [
   402        '{"version":1,"auth_token":""}',
   403        `{"tty_size":{"width":${window.execTerminal.cols},"height":${window.execTerminal.rows}}}`,
   404        '{"stdin":{"data":"DQ=="}}',
   405      ]);
   406  
   407      await mockSocket.onclose();
   408      await settled();
   409  
   410      assert.equal(
   411        window.execTerminal.buffer.active.getLine(6).translateToString().trim(),
   412        'The connection has closed.'
   413      );
   414    });
   415  
   416    test('the opening message includes the token if it exists', async function (assert) {
   417      const { secretId } = server.create('token');
   418      window.localStorage.nomadTokenSecret = secretId;
   419  
   420      let mockSocket = new MockSocket();
   421      let mockSockets = Service.extend({
   422        getTaskStateSocket() {
   423          return mockSocket;
   424        },
   425      });
   426  
   427      this.owner.register('service:sockets', mockSockets);
   428  
   429      let taskGroup = this.job.taskGroups.models[0];
   430      let task = taskGroup.tasks.models[0];
   431      let allocations = this.server.db.allocations.where({
   432        jobId: this.job.id,
   433        taskGroup: taskGroup.name,
   434      });
   435      let allocation = allocations[allocations.length - 1];
   436  
   437      await Exec.visitTask({
   438        job: this.job.id,
   439        task_group: taskGroup.name,
   440        task_name: task.name,
   441        allocation: allocation.id.split('-')[0],
   442      });
   443  
   444      await Exec.terminal.pressEnter();
   445      await settled();
   446      mockSocket.onopen();
   447  
   448      await Exec.terminal.pressEnter();
   449      await settled();
   450  
   451      assert.equal(
   452        mockSocket.sent[0],
   453        `{"version":1,"auth_token":"${secretId}"}`
   454      );
   455    });
   456  
   457    test('only one socket is opened after switching between tasks', async function (assert) {
   458      let mockSockets = Service.extend({
   459        getTaskStateSocket() {
   460          assert.step('Socket built');
   461          return new MockSocket();
   462        },
   463      });
   464  
   465      this.owner.register('service:sockets', mockSockets);
   466  
   467      await Exec.visitJob({
   468        job: this.job.id,
   469      });
   470  
   471      await settled();
   472  
   473      await Exec.taskGroups[0].click();
   474      await Exec.taskGroups[0].tasks[0].click();
   475  
   476      await Exec.taskGroups[1].click();
   477      await Exec.taskGroups[1].tasks[0].click();
   478  
   479      await Exec.terminal.pressEnter();
   480  
   481      assert.verifySteps(['Socket built']);
   482    });
   483  
   484    test('the command can be customised', async function (assert) {
   485      let mockSockets = Service.extend({
   486        getTaskStateSocket(taskState, command) {
   487          assert.equal(command, '/sh');
   488          window.localStorage.getItem('nomadExecCommand', JSON.stringify('/sh'));
   489  
   490          assert.step('Socket built');
   491  
   492          return new MockSocket();
   493        },
   494      });
   495  
   496      this.owner.register('service:sockets', mockSockets);
   497  
   498      await Exec.visitJob({ job: this.job.id });
   499      await Exec.taskGroups[0].click();
   500      await Exec.taskGroups[0].tasks[0].click();
   501  
   502      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   503      let task = taskGroup.tasks.models.sortBy('name')[0];
   504      let allocation = this.server.db.allocations.findBy({
   505        jobId: this.job.id,
   506        taskGroup: taskGroup.name,
   507      });
   508  
   509      await settled();
   510  
   511      // Delete /bash
   512      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   513      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   514      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   515      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   516      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   517  
   518      // Delete /bin and try to go beyond
   519      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   520      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   521      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   522      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   523      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   524      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   525      await window.execTerminal.simulateCommandDataEvent(KEYS.DELETE);
   526  
   527      await settled();
   528  
   529      assert.equal(
   530        window.execTerminal.buffer.active.getLine(6).translateToString().trim(),
   531        `$ nomad alloc exec -i -t -task ${task.name} ${
   532          allocation.id.split('-')[0]
   533        }`
   534      );
   535  
   536      await window.execTerminal.simulateCommandDataEvent('/sh');
   537  
   538      await Exec.terminal.pressEnter();
   539      await settled();
   540  
   541      assert.verifySteps(['Socket built']);
   542    });
   543  
   544    test('a persisted customised command is recalled', async function (assert) {
   545      window.localStorage.setItem('nomadExecCommand', JSON.stringify('/bin/sh'));
   546  
   547      let taskGroup = this.job.taskGroups.models[0];
   548      let task = taskGroup.tasks.models[0];
   549      let allocations = this.server.db.allocations.where({
   550        jobId: this.job.id,
   551        taskGroup: taskGroup.name,
   552      });
   553      let allocation = allocations[allocations.length - 1];
   554  
   555      await Exec.visitTask({
   556        job: this.job.id,
   557        task_group: taskGroup.name,
   558        task_name: task.name,
   559        allocation: allocation.id.split('-')[0],
   560      });
   561  
   562      await settled();
   563  
   564      assert.equal(
   565        window.execTerminal.buffer.active.getLine(4).translateToString().trim(),
   566        `$ nomad alloc exec -i -t -task ${task.name} ${
   567          allocation.id.split('-')[0]
   568        } /bin/sh`
   569      );
   570    });
   571  
   572    skip('when a task state finishes submitting a command displays an error', async function (assert) {
   573      let taskGroup = this.job.taskGroups.models.sortBy('name')[0];
   574      let task = taskGroup.tasks.models.sortBy('name')[0];
   575  
   576      await Exec.visitTask({
   577        job: this.job.id,
   578        task_group: taskGroup.name,
   579        task_name: task.name,
   580      });
   581  
   582      // Approximate allocation failure via polling
   583      this.owner
   584        .lookup('service:store')
   585        .peekAll('allocation')
   586        .forEach((allocation) => allocation.set('clientStatus', 'failed'));
   587  
   588      await Exec.terminal.pressEnter();
   589      await settled();
   590  
   591      assert.equal(
   592        window.execTerminal.buffer.active.getLine(7).translateToString().trim(),
   593        `Failed to open a socket because task ${task.name} is not active.`
   594      );
   595    });
   596  });
   597  
   598  class MockSocket {
   599    constructor() {
   600      this.sent = [];
   601    }
   602  
   603    send(message) {
   604      this.sent.push(message);
   605    }
   606  }