From 6463efe2ceef0d3a5c217d5592fe01e0cd07a7e5 Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Wed, 22 Aug 2018 17:36:04 -0700 Subject: [PATCH] Test coverage for the job-editor component Most of this was ported over from the existing job run acceptance tests --- ui/app/components/job-editor.js | 2 + ui/app/templates/components/job-editor.hbs | 4 +- ui/mirage/config.js | 8 + ui/tests/integration/job-editor-test.js | 492 +++++++++++++++++++++ ui/tests/pages/components/job-editor.js | 49 ++ ui/tests/pages/jobs/run.js | 44 +- 6 files changed, 556 insertions(+), 43 deletions(-) create mode 100644 ui/tests/integration/job-editor-test.js create mode 100644 ui/tests/pages/components/job-editor.js diff --git a/ui/app/components/job-editor.js b/ui/app/components/job-editor.js index 7cb6ff201..d72c97a8a 100644 --- a/ui/app/components/job-editor.js +++ b/ui/app/components/job-editor.js @@ -10,6 +10,8 @@ export default Component.extend({ store: service(), config: service(), + 'data-test-job-editor': true, + job: null, onSubmit() {}, context: computed({ diff --git a/ui/app/templates/components/job-editor.hbs b/ui/app/templates/components/job-editor.hbs index c0c2df862..8f85ebc58 100644 --- a/ui/app/templates/components/job-editor.hbs +++ b/ui/app/templates/components/job-editor.hbs @@ -18,7 +18,7 @@ {{/if}} {{#if (eq stage "editor")}} - {{#if showEditorMessage}} + {{#if (and showEditorMessage (eq context "new"))}}
@@ -35,7 +35,7 @@
Job Definition {{#if cancelable}} - + {{/if}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js index d3b2a9778..d573ed6de 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -116,6 +116,14 @@ export default function() { }) ); + this.post('/job/:id', function(schema, req) { + const body = JSON.parse(req.requestBody); + + if (!body.Job) return new Response(400, {}, 'Job is a required field on the request payload'); + + return okEmpty(); + }); + this.get( '/job/:id/summary', withBlockingSupport(function({ jobSummaries }, { params }) { diff --git a/ui/tests/integration/job-editor-test.js b/ui/tests/integration/job-editor-test.js new file mode 100644 index 000000000..9513a650e --- /dev/null +++ b/ui/tests/integration/job-editor-test.js @@ -0,0 +1,492 @@ +import { getOwner } from '@ember/application'; +import { assign } from '@ember/polyfills'; +import { run } from '@ember/runloop'; +import { test, moduleForComponent } from 'ember-qunit'; +import wait from 'ember-test-helpers/wait'; +import hbs from 'htmlbars-inline-precompile'; +import { create } from 'ember-cli-page-object'; +import sinon from 'sinon'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { getCodeMirrorInstance } from 'nomad-ui/tests/helpers/codemirror'; +import jobEditor from 'nomad-ui/tests/pages/components/job-editor'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +const Editor = create(jobEditor()); + +moduleForComponent('job-editor', 'Integration | Component | job-editor', { + integration: true, + beforeEach() { + window.localStorage.clear(); + + fragmentSerializerInitializer(getOwner(this)); + + // Normally getCodeMirrorInstance is a registered test helper, + // but those registered test helpers only work in acceptance tests. + window._getCodeMirrorInstance = window.getCodeMirrorInstance; + window.getCodeMirrorInstance = getCodeMirrorInstance(getOwner(this)); + + this.store = getOwner(this).lookup('service:store'); + this.server = startMirage(); + + // Required for placing allocations (a result of creating jobs) + this.server.create('node'); + + Editor.setContext(this); + }, + afterEach() { + this.server.shutdown(); + Editor.removeContext(); + window.getCodeMirrorInstance = window._getCodeMirrorInstance; + delete window._getCodeMirrorInstance; + }, +}); + +const newJobName = 'new-job'; +const newJobTaskGroupName = 'redis'; +const jsonJob = overrides => { + return JSON.stringify( + assign( + {}, + { + Name: newJobName, + Namespace: 'default', + Datacenters: ['dc1'], + Priority: 50, + TaskGroups: [ + { + Name: newJobTaskGroupName, + Tasks: [ + { + Name: 'redis', + Driver: 'docker', + }, + ], + }, + ], + }, + overrides + ), + null, + 2 + ); +}; + +const hclJob = () => ` +job "${newJobName}" { + namespace = "default" + datacenters = ["dc1"] + + task "${newJobTaskGroupName}" { + driver = "docker" + } +} +`; + +const commonTemplate = hbs` + {{job-editor + job=job + context=context + onSubmit=onSubmit}} +`; + +const cancelableTemplate = hbs` + {{job-editor + job=job + context=context + cancelable=true + onSubmit=onSubmit + onCancel=onCancel}} +`; + +const renderNewJob = (component, job) => () => { + component.setProperties({ job, onSubmit: sinon.spy(), context: 'new' }); + component.render(commonTemplate); + return wait(); +}; + +const renderEditJob = (component, job) => () => { + component.setProperties({ job, onSubmit: sinon.spy(), onCancel: sinon.spy(), context: 'edit' }); + component.render(cancelableTemplate); +}; + +const planJob = spec => () => { + Editor.editor.fillIn(spec); + return wait().then(() => { + Editor.plan(); + return wait(); + }); +}; + +test('the default state is an editor with an explanation popup', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.ok(Editor.editorHelp.isPresent, 'Editor explanation popup is present'); + assert.ok(Editor.editor.isPresent, 'Editor is present'); + }); +}); + +test('the explanation popup can be dismissed', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + Editor.editorHelp.dismiss(); + return wait(); + }) + .then(() => { + assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone'); + assert.equal( + window.localStorage.nomadMessageJobEditor, + 'false', + 'Dismissal is persisted in localStorage' + ); + }); +}); + +test('the explanation popup is not shown once the dismissal state is set in localStorage', function(assert) { + window.localStorage.nomadMessageJobEditor = 'false'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.notOk(Editor.editorHelp.isPresent, 'Editor explanation popup is gone'); + }); +}); + +test('submitting a json job skips the parse endpoint', function(assert) { + const spec = jsonJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + const requests = this.server.pretender.handledRequests.mapBy('url'); + assert.notOk(requests.includes('/v1/jobs/parse'), 'JSON job spec is not parsed'); + assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'JSON job spec is still planned'); + }); +}); + +test('submitting an hcl job requires the parse endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + const requests = this.server.pretender.handledRequests.mapBy('url'); + assert.ok(requests.includes('/v1/jobs/parse'), 'HCL job spec is parsed first'); + assert.ok(requests.includes(`/v1/job/${newJobName}/plan`), 'HCL job spec is planned'); + assert.ok( + requests.indexOf('/v1/jobs/parse') < requests.indexOf(`/v1/job/${newJobName}/plan`), + 'Parse comes before Plan' + ); + }); +}); + +test('when a job is successfully parsed and planned, the plan is shown to the user', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok(Editor.planOutput, 'The plan is outputted'); + assert.notOk(Editor.editor.isPresent, 'The editor is replaced with the plan output'); + assert.ok(Editor.planHelp.isPresent, 'The plan explanation popup is shown'); + }); +}); + +test('from the plan screen, the cancel button goes back to the editor with the job still in tact', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.cancel(); + return wait(); + }) + .then(() => { + assert.ok(Editor.editor.isPresent, 'The editor is shown again'); + assert.equal( + Editor.editor.contents, + spec, + 'The spec that was planned is still in the editor' + ); + }); +}); + +test('when parse fails, the parse error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Parse Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post('/v1/jobs/parse', () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.notOk(Editor.planError.isPresent, 'Plan error is not shown'); + assert.notOk(Editor.runError.isPresent, 'Run error is not shown'); + + assert.ok(Editor.parseError.isPresent, 'Parse error is shown'); + assert.equal( + Editor.parseError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when plan fails, the plan error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Plan Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post(`/v1/job/${newJobName}/plan`, () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown'); + assert.notOk(Editor.runError.isPresent, 'Run error is not shown'); + + assert.ok(Editor.planError.isPresent, 'Plan error is shown'); + assert.equal( + Editor.planError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when run fails, the run error message is shown', function(assert) { + const spec = hclJob(); + const errorMessage = 'Run Failed!! :o'; + + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + this.server.pretender.post('/v1/jobs', () => [400, {}, errorMessage]); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + return wait(); + }) + .then(() => { + assert.notOk(Editor.planError.isPresent, 'Plan error is not shown'); + assert.notOk(Editor.parseError.isPresent, 'Parse error is not shown'); + + assert.ok(Editor.runError.isPresent, 'Run error is shown'); + assert.equal( + Editor.runError.message, + errorMessage, + 'The error message from the server is shown in the error in the UI' + ); + }); +}); + +test('when the scheduler dry-run has warnings, the warnings are shown to the user', function(assert) { + const spec = jsonJob({ Unschedulable: true }); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok( + Editor.dryRunMessage.errored, + 'The scheduler dry-run message is in the warning state' + ); + assert.notOk( + Editor.dryRunMessage.succeeded, + 'The success message is not shown in addition to the warning message' + ); + assert.ok( + Editor.dryRunMessage.body.includes(newJobTaskGroupName), + 'The scheduler dry-run message includes the warning from send back by the API' + ); + }); +}); + +test('when the scheduler dry-run has no warnings, a success message is shown to the user', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + assert.ok( + Editor.dryRunMessage.succeeded, + 'The scheduler dry-run message is in the success state' + ); + assert.notOk( + Editor.dryRunMessage.errored, + 'The warning message is not shown in addition to the success message' + ); + }); +}); + +test('when a job is submitted in the edit context, a POST request is made to the update job endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + }) + .then(() => { + const requests = this.server.pretender.handledRequests + .filterBy('method', 'POST') + .mapBy('url'); + assert.ok(requests.includes(`/v1/job/${newJobName}`), 'A request was made to job update'); + assert.notOk(requests.includes('/v1/jobs'), 'A request was not made to job create'); + }); +}); + +test('when a job is submitted in the new context, a POST request is made to the create job endpoint', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + }) + .then(() => { + const requests = this.server.pretender.handledRequests + .filterBy('method', 'POST') + .mapBy('url'); + assert.ok(requests.includes('/v1/jobs'), 'A request was made to job create'); + assert.notOk( + requests.includes(`/v1/job/${newJobName}`), + 'A request was not made to job update' + ); + }); +}); + +test('when a job is successfully submitted, the onSubmit hook is called', function(assert) { + const spec = hclJob(); + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(planJob(spec)) + .then(() => { + Editor.run(); + return wait(); + }) + .then(() => { + assert.ok( + this.get('onSubmit').calledWith(newJobName, 'default'), + 'The onSubmit hook was called with the correct arguments' + ); + }); +}); + +test('when the job-editor cancelable flag is false, there is no cancel button in the header', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderNewJob(this, job)) + .then(() => { + assert.notOk(Editor.cancelEditingIsAvailable, 'No way to cancel editing'); + }); +}); + +test('when the job-editor cancelable flag is true, there is a cancel button in the header', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(() => { + assert.ok(Editor.cancelEditingIsAvailable, 'Cancel editing button exists'); + }); +}); + +test('when the job-editor cancel button is clicked, the onCancel hook is called', function(assert) { + let job; + run(() => { + job = this.store.createRecord('job'); + }); + + return wait() + .then(renderEditJob(this, job)) + .then(() => { + Editor.cancelEditing(); + }) + .then(() => { + assert.ok(this.get('onCancel').calledOnce, 'The onCancel hook was called'); + }); +}); diff --git a/ui/tests/pages/components/job-editor.js b/ui/tests/pages/components/job-editor.js new file mode 100644 index 000000000..c9de367c5 --- /dev/null +++ b/ui/tests/pages/components/job-editor.js @@ -0,0 +1,49 @@ +import { clickable, hasClass, isPresent, text } from 'ember-cli-page-object'; +import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror'; + +import error from 'nomad-ui/tests/pages/components/error'; + +export default () => ({ + scope: '[data-test-job-editor]', + + planError: error('data-test-plan-error'), + parseError: error('data-test-parse-error'), + runError: error('data-test-run-error'), + + plan: clickable('[data-test-plan]'), + cancel: clickable('[data-test-cancel]'), + run: clickable('[data-test-run]'), + + cancelEditing: clickable('[data-test-cancel-editing]'), + cancelEditingIsAvailable: isPresent('[data-test-cancel-editing]'), + + planOutput: text('[data-test-plan-output]'), + + planHelp: { + isPresent: isPresent('[data-test-plan-help-title]'), + title: text('[data-test-plan-help-title]'), + message: text('[data-test-plan-help-message]'), + dismiss: clickable('[data-test-plan-help-dismiss]'), + }, + + editorHelp: { + isPresent: isPresent('[data-test-editor-help-title]'), + title: text('[data-test-editor-help-title]'), + message: text('[data-test-editor-help-message]'), + dismiss: clickable('[data-test-editor-help-dismiss]'), + }, + + editor: { + isPresent: isPresent('[data-test-editor]'), + contents: code('[data-test-editor]'), + fillIn: codeFillable('[data-test-editor]'), + }, + + dryRunMessage: { + scope: '[data-test-dry-run-message]', + title: text('[data-test-dry-run-title]'), + body: text('[data-test-dry-run-body]'), + errored: hasClass('is-warning'), + succeeded: hasClass('is-primary'), + }, +}); diff --git a/ui/tests/pages/jobs/run.js b/ui/tests/pages/jobs/run.js index 08c1bcb7c..03c24ef14 100644 --- a/ui/tests/pages/jobs/run.js +++ b/ui/tests/pages/jobs/run.js @@ -1,46 +1,8 @@ -import { clickable, create, hasClass, isPresent, text, visitable } from 'ember-cli-page-object'; -import { codeFillable, code } from 'nomad-ui/tests/pages/helpers/codemirror'; +import { create, visitable } from 'ember-cli-page-object'; -import error from 'nomad-ui/tests/pages/components/error'; +import jobEditor from 'nomad-ui/tests/pages/components/job-editor'; export default create({ visit: visitable('/jobs/run'), - - planError: error('data-test-plan-error'), - parseError: error('data-test-parse-error'), - runError: error('data-test-run-error'), - - plan: clickable('[data-test-plan]'), - cancel: clickable('[data-test-cancel]'), - run: clickable('[data-test-run]'), - - planOutput: text('[data-test-plan-output]'), - - planHelp: { - isPresent: isPresent('[data-test-plan-help-title]'), - title: text('[data-test-plan-help-title]'), - message: text('[data-test-plan-help-message]'), - dismiss: clickable('[data-test-plan-help-dismiss]'), - }, - - editorHelp: { - isPresent: isPresent('[data-test-editor-help-title]'), - title: text('[data-test-editor-help-title]'), - message: text('[data-test-editor-help-message]'), - dismiss: clickable('[data-test-editor-help-dismiss]'), - }, - - editor: { - isPresent: isPresent('[data-test-editor]'), - contents: code('[data-test-editor]'), - fillIn: codeFillable('[data-test-editor]'), - }, - - dryRunMessage: { - scope: '[data-test-dry-run-message]', - title: text('[data-test-dry-run-title]'), - body: text('[data-test-dry-run-body]'), - errored: hasClass('is-warning'), - succeeded: hasClass('is-primary'), - }, + editor: jobEditor(), });