open-nomad/ui/tests/acceptance/optimize-test.js
Buck Doyle cc0336bf0f Add facets for recommendation summaries
This is mostly copied from the jobs list. One uncertainty
is what to do when changing a facet causes the currently-
active card to be excluded from the filtered list 🤔
2020-11-06 15:53:58 -06:00

608 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { currentURL, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import Response from 'ember-cli-mirage/response';
import moment from 'moment';
import Optimize from 'nomad-ui/tests/pages/optimize';
import PageLayout from 'nomad-ui/tests/pages/layout';
import JobsList from 'nomad-ui/tests/pages/jobs/list';
let managementToken, clientToken;
function getLatestRecommendationSubmitTimeForJob(job) {
const tasks = job.taskGroups.models
.mapBy('tasks.models')
.reduce((tasks, taskModels) => tasks.concat(taskModels), []);
const recommendations = tasks.reduce(
(recommendations, task) => recommendations.concat(task.recommendations.models),
[]
);
return Math.max(...recommendations.mapBy('submitTime'));
}
module('Acceptance | optimize', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
server.create('node');
server.createList('namespace', 2);
const jobs = server.createList('job', 2, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
jobs.sort((jobA, jobB) => {
return (
getLatestRecommendationSubmitTimeForJob(jobB) -
getLatestRecommendationSubmitTimeForJob(jobA)
);
});
[this.job1, this.job2] = jobs;
managementToken = server.create('token');
clientToken = server.create('token');
window.localStorage.clear();
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
test('it passes an accessibility audit', async function(assert) {
await Optimize.visit();
await a11yAudit(assert);
});
test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function(assert) {
await Optimize.visit();
const currentTaskGroup = this.job1.taskGroups.models[0];
const nextTaskGroup = this.job2.taskGroups.models[0];
assert.equal(Optimize.breadcrumbFor('optimize').text, 'Recommendations');
assert.equal(
Optimize.recommendationSummaries[0].slug,
`${this.job1.name} / ${currentTaskGroup.name}`
);
assert.equal(
Optimize.breadcrumbFor('optimize.summary').text,
`${this.job1.name} / ${currentTaskGroup.name}`
);
assert.equal(Optimize.recommendationSummaries[0].namespace, this.job1.namespace);
assert.equal(
Optimize.recommendationSummaries[1].slug,
`${this.job2.name} / ${nextTaskGroup.name}`
);
const currentRecommendations = currentTaskGroup.tasks.models.reduce(
(recommendations, task) => recommendations.concat(task.recommendations.models),
[]
);
const latestSubmitTime = Math.max(...currentRecommendations.mapBy('submitTime'));
Optimize.recommendationSummaries[0].as(summary => {
assert.equal(
summary.date,
moment(new Date(latestSubmitTime / 1000000)).format('MMM DD HH:mm:ss ZZ')
);
const currentTaskGroupAllocations = server.schema.allocations.where({
jobId: currentTaskGroup.job.name,
taskGroup: currentTaskGroup.name,
});
assert.equal(summary.allocationCount, currentTaskGroupAllocations.length);
const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce(
(currentResources, task) => {
currentResources.currCpu += task.resources.CPU;
currentResources.currMem += task.resources.MemoryMB;
return currentResources;
},
{ currCpu: 0, currMem: 0 }
);
const { recCpu, recMem } = currentRecommendations.reduce(
(recommendedResources, recommendation) => {
if (recommendation.resource === 'CPU') {
recommendedResources.recCpu += recommendation.value;
} else {
recommendedResources.recMem += recommendation.value;
}
return recommendedResources;
},
{ recCpu: 0, recMem: 0 }
);
const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0;
const memDiff = recMem > 0 ? recMem - currMem : 0;
const cpuSign = cpuDiff > 0 ? '+' : '';
const memSign = memDiff > 0 ? '+' : '';
const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu);
const memDiffPercent = Math.round((100 * memDiff) / currMem);
assert.equal(
summary.cpu,
cpuDiff ? `${cpuSign}${cpuDiff} MHz ${cpuSign}${cpuDiffPercent}%` : ''
);
assert.equal(
summary.memory,
memDiff ? `${memSign}${formattedMemDiff(memDiff)} ${memSign}${memDiffPercent}%` : ''
);
assert.equal(
summary.aggregateCpu,
cpuDiff ? `${cpuSign}${cpuDiff * currentTaskGroupAllocations.length} MHz` : ''
);
assert.equal(
summary.aggregateMemory,
memDiff ? `${memSign}${formattedMemDiff(memDiff * currentTaskGroupAllocations.length)}` : ''
);
});
assert.ok(Optimize.recommendationSummaries[0].isActive);
assert.notOk(Optimize.recommendationSummaries[1].isActive);
assert.equal(Optimize.card.slug.jobName, this.job1.name);
assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name);
const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory;
let toggledAnything = true;
// Toggle off all memory
if (Optimize.card.togglesTable.toggleAllMemory.isPresent) {
await Optimize.card.togglesTable.toggleAllMemory.toggle();
assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive);
assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive);
} else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) {
await Optimize.card.togglesTable.tasks[0].memory.toggle();
} else {
toggledAnything = false;
}
assert.equal(
Optimize.recommendationSummaries[0].memory,
summaryMemoryBefore,
'toggling recommendations doesnt affect the summary table diffs'
);
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
const cpuRecommendationIds = server.schema.recommendations
.where({ resource: 'CPU' })
.models.filter(taskIdFilter)
.mapBy('id');
const memoryRecommendationIds = server.schema.recommendations
.where({ resource: 'MemoryMB' })
.models.filter(taskIdFilter)
.mapBy('id');
const appliedIds = toggledAnything ? cpuRecommendationIds : memoryRecommendationIds;
const dismissedIds = toggledAnything ? memoryRecommendationIds : [];
await Optimize.card.acceptButton.click();
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
const { Apply, Dismiss } = JSON.parse(request.requestBody);
assert.equal(request.url, '/v1/recommendations/apply');
assert.deepEqual(Apply, appliedIds);
assert.deepEqual(Dismiss, dismissedIds);
assert.equal(Optimize.card.slug.jobName, this.job2.name);
assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name);
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can navigate between summaries via the table', async function(assert) {
server.createList('job', 10, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
await Optimize.visit();
await Optimize.recommendationSummaries[1].click();
assert.equal(
`${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
Optimize.recommendationSummaries[1].slug
);
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can visit a summary directly via URL', async function(assert) {
server.createList('job', 10, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
await Optimize.visit();
const lastSummary =
Optimize.recommendationSummaries[Optimize.recommendationSummaries.length - 1];
const collapsedSlug = lastSummary.slug.replace(' / ', '/');
// preferable to use page objects visitable but it encodes the slash
await visit(`/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
assert.equal(
`${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
lastSummary.slug
);
assert.ok(lastSummary.isActive);
assert.equal(currentURL(), `/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
});
test('when a summary is not found, an error message is shown, but the URL persists', async function(assert) {
await visit('/optimize/nonexistent/summary?namespace=anamespace');
assert.equal(currentURL(), '/optimize/nonexistent/summary?namespace=anamespace');
assert.ok(Optimize.applicationError.isPresent);
assert.equal(Optimize.applicationError.title, 'Not Found');
});
test('cannot return to already-processed summaries', async function(assert) {
await Optimize.visit();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.recommendationSummaries[0].isDisabled);
await Optimize.recommendationSummaries[0].click();
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can dismiss a set of recommendations', async function(assert) {
await Optimize.visit();
const currentTaskGroup = this.job1.taskGroups.models[0];
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
const idsBeforeDismissal = server.schema.recommendations
.all()
.models.filter(taskIdFilter)
.mapBy('id');
await Optimize.card.dismissButton.click();
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
const { Apply, Dismiss } = JSON.parse(request.requestBody);
assert.equal(request.url, '/v1/recommendations/apply');
assert.deepEqual(Apply, []);
assert.deepEqual(Dismiss, idsBeforeDismissal);
});
test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismissed', async function(assert) {
server.post('/recommendations/apply', function() {
return new Response(500, {}, null);
});
await Optimize.visit();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.error.isPresent);
assert.equal(Optimize.error.headline, 'Recommendation error');
assert.equal(
Optimize.error.errors,
'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)'
);
await Optimize.error.dismiss();
assert.equal(Optimize.card.slug.jobName, this.job2.name);
});
test('it displays an empty message when there are no recommendations', async function(assert) {
server.db.recommendations.remove();
await Optimize.visit();
assert.ok(Optimize.empty.isPresent);
assert.equal(Optimize.empty.headline, 'No Recommendations');
});
test('it displays an empty message after all recommendations have been processed', async function(assert) {
await Optimize.visit();
await Optimize.card.acceptButton.click();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.empty.isPresent);
});
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function(assert) {
window.localStorage.nomadTokenSecret = clientToken.secretId;
await Optimize.visit();
assert.equal(currentURL(), '/jobs');
assert.ok(PageLayout.gutter.optimize.isHidden);
});
test('it reloads partially-loaded jobs', async function(assert) {
await JobsList.visit();
await Optimize.visit();
assert.equal(Optimize.recommendationSummaries.length, 2);
});
});
module('Acceptance | optimize facets', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
server.create('node');
server.createList('namespace', 2);
managementToken = server.create('token');
window.localStorage.clear();
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
test('the optimize page has appropriate faceted search options', async function(assert) {
server.createList('job', 4, {
status: 'running',
createRecommendations: true,
childrenCount: 0,
});
await Optimize.visit();
assert.ok(Optimize.facets.type.isPresent, 'Type facet found');
assert.ok(Optimize.facets.status.isPresent, 'Status facet found');
assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found');
assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found');
});
testFacet('Type', {
facet: Optimize.facets.type,
paramName: 'type',
expectedOptions: ['Service', 'System'],
async beforeEach() {
server.createList('job', 2, {
type: 'service',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
});
server.createList('job', 2, {
type: 'system',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
});
await Optimize.visit();
},
filter(taskGroup, selection) {
let displayType = taskGroup.job.type;
return selection.includes(displayType);
},
});
testFacet('Status', {
facet: Optimize.facets.status,
paramName: 'status',
expectedOptions: ['Pending', 'Running', 'Dead'],
async beforeEach() {
server.createList('job', 2, {
status: 'pending',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.createList('job', 2, {
status: 'running',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.createList('job', 2, { status: 'dead', createRecommendations: true, childrenCount: 0 });
await Optimize.visit();
},
filter: (taskGroup, selection) => selection.includes(taskGroup.job.status),
});
testFacet('Datacenter', {
facet: Optimize.facets.datacenter,
paramName: 'dc',
expectedOptions(jobs) {
const allDatacenters = new Set(
jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), [])
);
return Array.from(allDatacenters).sort();
},
async beforeEach() {
server.create('job', {
datacenters: ['pdx', 'lax'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['pdx', 'ord'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['lax', 'jfk'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['jfk', 'dfw'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', { datacenters: ['pdx'], createRecommendations: true, childrenCount: 0 });
await Optimize.visit();
},
filter: (taskGroup, selection) => taskGroup.job.datacenters.find(dc => selection.includes(dc)),
});
testFacet('Prefix', {
facet: Optimize.facets.prefix,
paramName: 'prefix',
expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'],
async beforeEach() {
[
'pre-one',
'hashi_one',
'nmd.one',
'one-alone',
'pre_two',
'hashi.two',
'hashi-three',
'nmd_two',
'noprefix',
].forEach(name => {
server.create('job', {
name,
createRecommendations: true,
createAllocations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
});
await Optimize.visit();
},
filter: (taskGroup, selection) => selection.find(prefix => taskGroup.job.name.startsWith(prefix)),
});
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
test(`the ${label} facet has the correct options`, async function(assert) {
await beforeEach();
await facet.toggle();
let expectation;
if (typeof expectedOptions === 'function') {
expectation = expectedOptions(server.db.jobs);
} else {
expectation = expectedOptions;
}
assert.deepEqual(
facet.options.map(option => option.label.trim()),
expectation,
'Options for facet are as expected'
);
});
test(`the ${label} facet filters the recommendation summaries by ${label}`, async function(assert) {
let option;
await beforeEach();
await facet.toggle();
option = facet.options.objectAt(0);
await option.toggle();
const selection = [option.key];
const sortedRecommendations = server.db.recommendations
.sortBy('submitTime').reverse();
const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection));
Optimize.recommendationSummaries.forEach((summary, index) => {
const group = recommendationTaskGroups[index];
assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
});
});
test(`selecting multiple options in the ${label} facet results in a broader search`, async function(assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
const sortedRecommendations = server.db.recommendations
.sortBy('submitTime').reverse();
const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection));
Optimize.recommendationSummaries.forEach((summary, index) => {
const group = recommendationTaskGroups[index];
assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
});
});
test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function(assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
assert.ok(currentURL().includes(encodeURIComponent(JSON.stringify(selection))));
});
}
});
function formattedMemDiff(memDiff) {
const absMemDiff = Math.abs(memDiff);
const negativeSign = memDiff < 0 ? '-' : '';
if (absMemDiff >= 1024) {
const gibDiff = absMemDiff / 1024;
if (Number.isInteger(gibDiff)) {
return `${negativeSign}${gibDiff} GiB`;
} else {
return `${negativeSign}${gibDiff.toFixed(2)} GiB`;
}
} else {
return `${negativeSign}${absMemDiff} MiB`;
}
}