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