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 }