2020-10-29 12:46:42 +00:00
import { module , test } from 'qunit' ;
import { setupApplicationTest } from 'ember-qunit' ;
2020-11-04 18:22:24 +00:00
import { currentURL , visit } from '@ember/test-helpers' ;
2020-10-29 12:46:42 +00:00
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 } `
) ;
2020-11-04 18:22:24 +00:00
assert . equal (
Optimize . breadcrumbFor ( 'optimize.summary' ) . text ,
` ${ this . job1 . name } / ${ currentTaskGroup . name } `
) ;
2020-10-29 12:46:42 +00:00
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 doesn’ t 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 ) ;
} ) ;
2020-11-04 18:22:24 +00:00
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 object’ s 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' ) ;
} ) ;
2020-10-29 12:46:42 +00:00
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 ) ;
} ) ;
2020-11-04 18:22:24 +00:00
test ( 'it displays an error encountered trying to save and proceeds to the next summary when the error is dismissed' , async function ( assert ) {
2020-10-29 12:46:42 +00:00
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 ) ;
} ) ;
} ) ;
2020-11-09 15:28:40 +00:00
module ( 'Acceptance | optimize search and facets' , function ( hooks ) {
2020-11-06 21:53:58 +00:00
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 ;
} ) ;
2020-11-09 15:28:40 +00:00
test ( 'search field narrows summary table results' , async function ( assert ) {
server . createList ( 'job' , 1 , {
name : 'oooooo' ,
createRecommendations : true ,
groupsCount : 2 ,
groupTaskCount : 4 ,
} ) ;
server . createList ( 'job' , 1 , {
name : 'pppppp' ,
createRecommendations : true ,
groupsCount : 2 ,
groupTaskCount : 4 ,
} ) ;
await Optimize . visit ( ) ;
2020-11-09 15:32:49 +00:00
assert . equal ( Optimize . search . placeholder , ` Search ${ Optimize . recommendationSummaries . length } recommendations... ` ) ;
2020-11-09 15:28:40 +00:00
await Optimize . search . fillIn ( 'ooo' ) ;
assert . equal ( Optimize . recommendationSummaries . length , 2 ) ;
assert . ok ( Optimize . recommendationSummaries [ 0 ] . slug . startsWith ( 'oooooo' ) ) ;
} ) ;
2020-11-06 21:53:58 +00:00
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 ) ) ) ) ;
} ) ;
}
} ) ;
2020-10-29 12:46:42 +00:00
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 ` ;
}
}