commit/galaxy-central: carlfeberhard: History API: add update method and allow name, genome_build, annotation, deleted, and published as updatable fields; browser tests: test history api; root/history: fix error handling when user is anonymous
1 new commit in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/commits/fb28ceb83c37/ Changeset: fb28ceb83c37 User: carlfeberhard Date: 2013-04-23 23:38:00 Summary: History API: add update method and allow name, genome_build, annotation, deleted, and published as updatable fields; browser tests: test history api; root/history: fix error handling when user is anonymous Affected #: 9 files diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 lib/galaxy/model/__init__.py --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -575,8 +575,10 @@ self.group = group class History( object, UsesAnnotations ): + api_collection_visible_keys = ( 'id', 'name', 'published', 'deleted' ) - api_element_visible_keys = ( 'id', 'name', 'published', 'deleted' ) + api_element_visible_keys = ( 'id', 'name', 'published', 'deleted', 'genome_build', 'purged' ) + def __init__( self, id=None, name=None, user=None ): self.id = id self.name = name or "Unnamed history" @@ -589,6 +591,7 @@ self.user = user self.datasets = [] self.galaxy_sessions = [] + def _next_hid( self ): # TODO: override this with something in the database that ensures # better integrity @@ -600,18 +603,21 @@ if dataset.hid > last_hid: last_hid = dataset.hid return last_hid + 1 + def add_galaxy_session( self, galaxy_session, association=None ): if association is None: self.galaxy_sessions.append( GalaxySessionToHistoryAssociation( galaxy_session, self ) ) else: self.galaxy_sessions.append( association ) + def add_dataset( self, dataset, parent_id=None, genome_build=None, set_hid=True, quota=True ): if isinstance( dataset, Dataset ): dataset = HistoryDatasetAssociation(dataset=dataset) object_session( self ).add( dataset ) object_session( self ).flush() elif not isinstance( dataset, HistoryDatasetAssociation ): - raise TypeError, "You can only add Dataset and HistoryDatasetAssociation instances to a history ( you tried to add %s )." % str( dataset ) + raise TypeError, ( "You can only add Dataset and HistoryDatasetAssociation instances to a history" + + " ( you tried to add %s )." % str( dataset ) ) if parent_id: for data in self.datasets: if data.id == parent_id: @@ -630,6 +636,7 @@ self.genome_build = genome_build self.datasets.append( dataset ) return dataset + def copy( self, name=None, target_user=None, activatable=False ): # Create new history. if not name: @@ -647,7 +654,7 @@ # Copy annotation. self.copy_item_annotation( db_session, self.user, self, target_user, new_history ) - #Copy Tags + # Copy Tags new_history.copy_tags_from(target_user=target_user, source_history=self) # Copy HDAs. @@ -667,12 +674,17 @@ db_session.add( new_history ) db_session.flush() return new_history + @property def activatable_datasets( self ): # This needs to be a list return [ hda for hda in self.datasets if not hda.dataset.deleted ] + def get_display_name( self ): - """ History name can be either a string or a unicode object. If string, convert to unicode object assuming 'utf-8' format. """ + """ + History name can be either a string or a unicode object. + If string, convert to unicode object assuming 'utf-8' format. + """ history_name = self.name if isinstance(history_name, str): history_name = unicode(history_name, 'utf-8') @@ -682,6 +694,7 @@ if value_mapper is None: value_mapper = {} rval = {} + try: visible_keys = self.__getattribute__( 'api_' + view + '_visible_keys' ) except AttributeError: @@ -693,6 +706,7 @@ rval[key] = value_mapper.get( key )( rval[key] ) except AttributeError: rval[key] = None + tags_str_list = [] for tag in self.tags: tag_str = tag.user_tname @@ -702,25 +716,51 @@ rval['tags'] = tags_str_list rval['model_class'] = self.__class__.__name__ return rval + + def set_from_dict( self, new_data ): + #AKA: set_api_value + """ + Set object attributes to the values in dictionary new_data limiting + to only those keys in api_element_visible_keys. + + Returns a dictionary of the keys, values that have been changed. + """ + # precondition: keys are proper, values are parsed and validated + changed = {} + for key in [ k for k in new_data.keys() if k in self.api_element_visible_keys ]: + new_val = new_data[ key ] + old_val = self.__getattribute__( key ) + if new_val == old_val: + continue + + self.__setattr__( key, new_val ) + changed[ key ] = new_val + + return changed + @property def get_disk_size_bytes( self ): return self.get_disk_size( nice_size=False ) + def unhide_datasets( self ): for dataset in self.datasets: dataset.mark_unhidden() + def resume_paused_jobs( self ): for dataset in self.datasets: job = dataset.creating_job if job is not None and job.state == Job.states.PAUSED: job.set_state(Job.states.NEW) + def get_disk_size( self, nice_size=False ): # unique datasets only db_session = object_session( self ) - rval = db_session.query( func.sum( db_session.query( HistoryDatasetAssociation.dataset_id, Dataset.total_size ).join( Dataset ) - .filter( HistoryDatasetAssociation.table.c.history_id == self.id ) - .filter( HistoryDatasetAssociation.purged != True ) - .filter( Dataset.purged != True ) - .distinct().subquery().c.total_size ) ).first()[0] + rval = db_session.query( + func.sum( db_session.query( HistoryDatasetAssociation.dataset_id, Dataset.total_size ).join( Dataset ) + .filter( HistoryDatasetAssociation.table.c.history_id == self.id ) + .filter( HistoryDatasetAssociation.purged != True ) + .filter( Dataset.purged != True ) + .distinct().subquery().c.total_size ) ).first()[0] if rval is None: rval = 0 if nice_size: @@ -733,6 +773,7 @@ new_shta.user = target_user self.tags.append(new_shta) + class HistoryUserShareAssociation( object ): def __init__( self ): self.history = None diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 lib/galaxy/web/base/controller.py --- a/lib/galaxy/web/base/controller.py +++ b/lib/galaxy/web/base/controller.py @@ -245,6 +245,187 @@ return item +class UsesHistoryMixin( SharableItemSecurityMixin ): + """ Mixin for controllers that use History objects. """ + + def get_history( self, trans, id, check_ownership=True, check_accessible=False, deleted=None ): + """Get a History from the database by id, verifying ownership.""" + history = self.get_object( trans, id, 'History', check_ownership=check_ownership, check_accessible=check_accessible, deleted=deleted ) + return self.security_check( trans, history, check_ownership, check_accessible ) + + def get_history_datasets( self, trans, history, show_deleted=False, show_hidden=False, show_purged=False ): + """ Returns history's datasets. """ + query = trans.sa_session.query( trans.model.HistoryDatasetAssociation ) \ + .filter( trans.model.HistoryDatasetAssociation.history == history ) \ + .options( eagerload( "children" ) ) \ + .join( "dataset" ) \ + .options( eagerload_all( "dataset.actions" ) ) \ + .order_by( trans.model.HistoryDatasetAssociation.hid ) + if not show_deleted: + query = query.filter( trans.model.HistoryDatasetAssociation.deleted == False ) + if not show_purged: + query = query.filter( trans.model.Dataset.purged == False ) + return query.all() + + def get_hda_state_counts( self, trans, history, include_deleted=False, include_hidden=False ): + """ + Returns a dictionary with state counts for history's HDAs. Key is a + dataset state, value is the number of states in that count. + """ + # Build query to get (state, count) pairs. + cols_to_select = [ trans.app.model.Dataset.table.c.state, func.count( '*' ) ] + from_obj = trans.app.model.HistoryDatasetAssociation.table.join( trans.app.model.Dataset.table ) + + conditions = [ trans.app.model.HistoryDatasetAssociation.table.c.history_id == history.id ] + if not include_deleted: + # Only count datasets that have not been deleted. + conditions.append( trans.app.model.HistoryDatasetAssociation.table.c.deleted == False ) + if not include_hidden: + # Only count datasets that are visible. + conditions.append( trans.app.model.HistoryDatasetAssociation.table.c.visible == True ) + + group_by = trans.app.model.Dataset.table.c.state + query = select( columns=cols_to_select, + from_obj=from_obj, + whereclause=and_( *conditions ), + group_by=group_by ) + + # Initialize count dict with all states. + state_count_dict = {} + for k, state in trans.app.model.Dataset.states.items(): + state_count_dict[ state ] = 0 + + # Process query results, adding to count dict. + for row in trans.sa_session.execute( query ): + state, count = row + state_count_dict[ state ] = count + + return state_count_dict + + def get_hda_summary_dicts( self, trans, history ): + """Returns a list of dictionaries containing summary information + for each HDA in the given history. + """ + hda_model = trans.model.HistoryDatasetAssociation + + # get state, name, etc. + columns = ( hda_model.name, hda_model.hid, hda_model.id, hda_model.deleted, + trans.model.Dataset.state ) + column_keys = [ "name", "hid", "id", "deleted", "state" ] + + query = ( trans.sa_session.query( *columns ) + .enable_eagerloads( False ) + .filter( hda_model.history == history ) + .join( trans.model.Dataset ) + .order_by( hda_model.hid ) ) + + # build dictionaries, adding history id and encoding all ids + hda_dicts = [] + for hda_tuple in query.all(): + hda_dict = dict( zip( column_keys, hda_tuple ) ) + hda_dict[ 'history_id' ] = history.id + trans.security.encode_dict_ids( hda_dict ) + hda_dicts.append( hda_dict ) + return hda_dicts + + def _get_hda_state_summaries( self, trans, hda_dict_list ): + """Returns two dictionaries (in a tuple): state_counts and state_ids. + Each is keyed according to the possible hda states: + _counts contains a sum of the datasets in each state + _ids contains a list of the encoded ids for each hda in that state + + hda_dict_list should be a list of hda data in dictionary form. + """ + #TODO: doc to rst + # init counts, ids for each state + state_counts = {} + state_ids = {} + for key, state in trans.app.model.Dataset.states.items(): + state_counts[ state ] = 0 + state_ids[ state ] = [] + + for hda_dict in hda_dict_list: + item_state = hda_dict['state'] + if not hda_dict['deleted']: + state_counts[ item_state ] = state_counts[ item_state ] + 1 + # needs to return all ids (no deleted check) + state_ids[ item_state ].append( hda_dict['id'] ) + + return ( state_counts, state_ids ) + + def _get_history_state_from_hdas( self, trans, history, hda_state_counts ): + """Returns the history state based on the states of the HDAs it contains. + """ + states = trans.app.model.Dataset.states + + num_hdas = sum( hda_state_counts.values() ) + # (default to ERROR) + state = states.ERROR + if num_hdas == 0: + state = states.NEW + + else: + if( ( hda_state_counts[ states.RUNNING ] > 0 ) + or ( hda_state_counts[ states.SETTING_METADATA ] > 0 ) + or ( hda_state_counts[ states.UPLOAD ] > 0 ) ): + state = states.RUNNING + + elif hda_state_counts[ states.QUEUED ] > 0: + state = states.QUEUED + + elif( ( hda_state_counts[ states.ERROR ] > 0 ) + or ( hda_state_counts[ states.FAILED_METADATA ] > 0 ) ): + state = states.ERROR + + elif hda_state_counts[ states.OK ] == num_hdas: + state = states.OK + + return state + + def get_history_dict( self, trans, history, hda_dictionaries=None ): + """Returns history data in the form of a dictionary. + """ + history_dict = history.get_api_value( view='element', value_mapper={ 'id':trans.security.encode_id }) + + history_dict[ 'nice_size' ] = history.get_disk_size( nice_size=True ) + history_dict[ 'annotation' ] = history.get_item_annotation_str( trans.sa_session, trans.user, history ) + if not history_dict[ 'annotation' ]: + history_dict[ 'annotation' ] = '' + #TODO: item_slug url + + hda_summaries = hda_dictionaries if hda_dictionaries else self.get_hda_summary_dicts( trans, history ) + #TODO remove the following in v2 + ( state_counts, state_ids ) = self._get_hda_state_summaries( trans, hda_summaries ) + history_dict[ 'state_details' ] = state_counts + history_dict[ 'state_ids' ] = state_ids + history_dict[ 'state' ] = self._get_history_state_from_hdas( trans, history, state_counts ) + + return history_dict + + def set_history_from_dict( self, trans, history, new_data ): + """ + Changes history data using the given dictionary new_data. + """ + # precondition: access of the history has already been checked + + # send what we can down into the model + changed = history.set_from_dict( new_data ) + # the rest (often involving the trans) - do here + if 'annotation' in new_data.keys() and trans.get_user(): + history.add_item_annotation( trans.sa_session, trans.get_user(), history, new_data[ 'annotation' ] ) + changed[ 'annotation' ] = new_data[ 'annotation' ] + # tags + # importable (ctrl.history.set_accessible_async) + # sharing/permissions? + # slugs? + # purged - duh duh duhhhhhhnnnnnnnnnn + + if changed.keys(): + trans.sa_session.flush() + + return changed + + class UsesHistoryDatasetAssociationMixin: """ Mixin for controllers that use HistoryDatasetAssociation objects. """ @@ -817,165 +998,6 @@ step.input_connections_by_name = dict( ( conn.input_name, conn ) for conn in step.input_connections ) -class UsesHistoryMixin( SharableItemSecurityMixin ): - """ Mixin for controllers that use History objects. """ - - def get_history( self, trans, id, check_ownership=True, check_accessible=False, deleted=None ): - """Get a History from the database by id, verifying ownership.""" - history = self.get_object( trans, id, 'History', check_ownership=check_ownership, check_accessible=check_accessible, deleted=deleted ) - return self.security_check( trans, history, check_ownership, check_accessible ) - - def get_history_datasets( self, trans, history, show_deleted=False, show_hidden=False, show_purged=False ): - """ Returns history's datasets. """ - query = trans.sa_session.query( trans.model.HistoryDatasetAssociation ) \ - .filter( trans.model.HistoryDatasetAssociation.history == history ) \ - .options( eagerload( "children" ) ) \ - .join( "dataset" ) \ - .options( eagerload_all( "dataset.actions" ) ) \ - .order_by( trans.model.HistoryDatasetAssociation.hid ) - if not show_deleted: - query = query.filter( trans.model.HistoryDatasetAssociation.deleted == False ) - if not show_purged: - query = query.filter( trans.model.Dataset.purged == False ) - return query.all() - - def get_hda_state_counts( self, trans, history, include_deleted=False, include_hidden=False ): - """ - Returns a dictionary with state counts for history's HDAs. Key is a - dataset state, value is the number of states in that count. - """ - # Build query to get (state, count) pairs. - cols_to_select = [ trans.app.model.Dataset.table.c.state, func.count( '*' ) ] - from_obj = trans.app.model.HistoryDatasetAssociation.table.join( trans.app.model.Dataset.table ) - - conditions = [ trans.app.model.HistoryDatasetAssociation.table.c.history_id == history.id ] - if not include_deleted: - # Only count datasets that have not been deleted. - conditions.append( trans.app.model.HistoryDatasetAssociation.table.c.deleted == False ) - if not include_hidden: - # Only count datasets that are visible. - conditions.append( trans.app.model.HistoryDatasetAssociation.table.c.visible == True ) - - group_by = trans.app.model.Dataset.table.c.state - query = select( columns=cols_to_select, - from_obj=from_obj, - whereclause=and_( *conditions ), - group_by=group_by ) - - # Initialize count dict with all states. - state_count_dict = {} - for k, state in trans.app.model.Dataset.states.items(): - state_count_dict[ state ] = 0 - - # Process query results, adding to count dict. - for row in trans.sa_session.execute( query ): - state, count = row - state_count_dict[ state ] = count - - return state_count_dict - - def get_hda_summary_dicts( self, trans, history ): - """Returns a list of dictionaries containing summary information - for each HDA in the given history. - """ - hda_model = trans.model.HistoryDatasetAssociation - - # get state, name, etc. - columns = ( hda_model.name, hda_model.hid, hda_model.id, hda_model.deleted, - trans.model.Dataset.state ) - column_keys = [ "name", "hid", "id", "deleted", "state" ] - - query = ( trans.sa_session.query( *columns ) - .enable_eagerloads( False ) - .filter( hda_model.history == history ) - .join( trans.model.Dataset ) - .order_by( hda_model.hid ) ) - - # build dictionaries, adding history id and encoding all ids - hda_dicts = [] - for hda_tuple in query.all(): - hda_dict = dict( zip( column_keys, hda_tuple ) ) - hda_dict[ 'history_id' ] = history.id - trans.security.encode_dict_ids( hda_dict ) - hda_dicts.append( hda_dict ) - return hda_dicts - - def _get_hda_state_summaries( self, trans, hda_dict_list ): - """Returns two dictionaries (in a tuple): state_counts and state_ids. - Each is keyed according to the possible hda states: - _counts contains a sum of the datasets in each state - _ids contains a list of the encoded ids for each hda in that state - - hda_dict_list should be a list of hda data in dictionary form. - """ - #TODO: doc to rst - # init counts, ids for each state - state_counts = {} - state_ids = {} - for key, state in trans.app.model.Dataset.states.items(): - state_counts[ state ] = 0 - state_ids[ state ] = [] - - for hda_dict in hda_dict_list: - item_state = hda_dict['state'] - if not hda_dict['deleted']: - state_counts[ item_state ] = state_counts[ item_state ] + 1 - # needs to return all ids (no deleted check) - state_ids[ item_state ].append( hda_dict['id'] ) - - return ( state_counts, state_ids ) - - def _get_history_state_from_hdas( self, trans, history, hda_state_counts ): - """Returns the history state based on the states of the HDAs it contains. - """ - states = trans.app.model.Dataset.states - - num_hdas = sum( hda_state_counts.values() ) - # (default to ERROR) - state = states.ERROR - if num_hdas == 0: - state = states.NEW - - else: - if( ( hda_state_counts[ states.RUNNING ] > 0 ) - or ( hda_state_counts[ states.SETTING_METADATA ] > 0 ) - or ( hda_state_counts[ states.UPLOAD ] > 0 ) ): - state = states.RUNNING - - elif hda_state_counts[ states.QUEUED ] > 0: - state = states.QUEUED - - elif( ( hda_state_counts[ states.ERROR ] > 0 ) - or ( hda_state_counts[ states.FAILED_METADATA ] > 0 ) ): - state = states.ERROR - - elif hda_state_counts[ states.OK ] == num_hdas: - state = states.OK - - return state - - def get_history_dict( self, trans, history, hda_dictionaries=None ): - """Returns history data in the form of a dictionary. - """ - history_dict = history.get_api_value( view='element', value_mapper={ 'id':trans.security.encode_id }) - - history_dict[ 'nice_size' ] = history.get_disk_size( nice_size=True ) - - #TODO: separate, move to annotation api, fill on the client - history_dict[ 'annotation' ] = history.get_item_annotation_str( trans.sa_session, trans.user, history ) - if not history_dict[ 'annotation' ]: - history_dict[ 'annotation' ] = '' - - hda_summaries = hda_dictionaries if hda_dictionaries else self.get_hda_summary_dicts( trans, history ) - #TODO remove the following in v2 - ( state_counts, state_ids ) = self._get_hda_state_summaries( trans, hda_summaries ) - history_dict[ 'state_details' ] = state_counts - history_dict[ 'state_ids' ] = state_ids - history_dict[ 'state' ] = self._get_history_state_from_hdas( trans, history, state_counts ) - - return history_dict - - class UsesFormDefinitionsMixin: """Mixin for controllers that use Galaxy form objects.""" diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 lib/galaxy/webapps/galaxy/api/histories.py --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -1,12 +1,18 @@ """ API operations on a history. """ -import logging + +import pkg_resources +pkg_resources.require("Paste") +from paste.httpexceptions import HTTPBadRequest + from galaxy import web, util from galaxy.web.base.controller import BaseAPIController, UsesHistoryMixin from galaxy.web import url_for from galaxy.model.orm import desc +from galaxy.util.bunch import Bunch +import logging log = logging.getLogger( __name__ ) class HistoriesController( BaseAPIController, UsesHistoryMixin ): @@ -18,6 +24,7 @@ GET /api/histories/deleted Displays a collection (list) of histories. """ + #TODO: query (by name, date, etc.) rval = [] deleted = util.string_as_bool( deleted ) try: @@ -50,6 +57,8 @@ GET /api/histories/most_recently_used Displays information about a history. """ + #TODO: GET /api/histories/{encoded_history_id}?as_archive=True + #TODO: GET /api/histories/s/{username}/{slug} history_id = id deleted = util.string_as_bool( deleted ) @@ -92,6 +101,10 @@ trans.sa_session.flush() item = new_history.get_api_value(view='element', value_mapper={'id':trans.security.encode_id}) item['url'] = url_for( 'history', id=item['id'] ) + + #TODO: copy own history + #TODO: import an importable history + #TODO: import from archive return item @web.expose_api @@ -146,3 +159,66 @@ trans.sa_session.add( history ) trans.sa_session.flush() return 'OK' + + @web.expose_api + def update( self, trans, id, payload, **kwd ): + """ + PUT /api/histories/{encoded_history_id} + Changes an existing history. + """ + #TODO: PUT /api/histories/{encoded_history_id} payload = { rating: rating } (w/ no security checks) + try: + history = self.get_history( trans, id, check_ownership=True, check_accessible=True, deleted=True ) + # validation handled here and some parsing, processing, and conversion + payload = self._validate_and_parse_update_payload( payload ) + # additional checks here (security, etc.) + changed = self.set_history_from_dict( trans, history, payload ) + + except Exception, exception: + log.error( 'Update of history (%s) failed: %s', id, str( exception ), exc_info=True ) + # convert to appropo HTTP code + if( isinstance( exception, ValueError ) + or isinstance( exception, AttributeError ) ): + # bad syntax from the validater/parser + trans.response.status = 400 + else: + trans.response.status = 500 + return { 'error': str( exception ) } + + return changed + + def _validate_and_parse_update_payload( self, payload ): + """ + Validate and parse incomming data payload for a history. + """ + # This layer handles (most of the stricter idiot proofing): + # - unknown/unallowed keys + # - changing data keys from api key to attribute name + # - protection against bad data form/type + # - protection against malicious data content + # all other conversions and processing (such as permissions, etc.) should happen down the line + for key, val in payload.items(): + # TODO: lots of boilerplate here, but overhead on abstraction is equally onerous + if key == 'name': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( 'name must be a string or unicode: %s' %( str( type( val ) ) ) ) + payload[ 'name' ] = util.sanitize_html.sanitize_html( val, 'utf-8' ) + #TODO:?? if sanitized != val: log.warn( 'script kiddie' ) + elif key == 'deleted': + if not isinstance( val, bool ): + raise ValueError( 'deleted must be a boolean: %s' %( str( type( val ) ) ) ) + elif key == 'published': + if not isinstance( payload[ 'published' ], bool ): + raise ValueError( 'published must be a boolean: %s' %( str( type( val ) ) ) ) + elif key == 'genome_build': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( 'genome_build must be a string: %s' %( str( type( val ) ) ) ) + payload[ 'genome_build' ] = util.sanitize_html.sanitize_html( val, 'utf-8' ) + elif key == 'annotation': + if not ( isinstance( val, str ) or isinstance( val, unicode ) ): + raise ValueError( 'annotation must be a string or unicode: %s' %( str( type( val ) ) ) ) + payload[ 'annotation' ] = util.sanitize_html.sanitize_html( val, 'utf-8' ) + else: + raise AttributeError( 'unknown key: %s' %( str( key ) ) ) + return payload + diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 lib/galaxy/webapps/galaxy/controllers/history.py --- a/lib/galaxy/webapps/galaxy/controllers/history.py +++ b/lib/galaxy/webapps/galaxy/controllers/history.py @@ -724,10 +724,10 @@ 'include_hidden' : include_hidden, 'include_deleted' : include_deleted } history_exp_tool.execute( trans, incoming = params, set_output_hid = True ) + url = url_for( controller='history', action="export_archive", id=id, qualified=True ) return trans.show_message( "Exporting History '%(n)s'. Use this link to download \ the archive or import it to another Galaxy server: \ - <a href='%(u)s'>%(u)s</a>" \ - % ( { 'n' : history.name, 'u' : url_for(controller='history', action="export_archive", id=id, qualified=True ) } ) ) + <a href='%(u)s'>%(u)s</a>" % ( { 'n' : history.name, 'u' : url } ) ) @web.expose @web.json @@ -739,7 +739,8 @@ trans.sa_session.flush() return_dict = { "name" : history.name, - "link" : url_for(controller='history', action="display_by_username_and_slug", username=history.user.username, slug=history.slug ) } + "link" : url_for(controller='history', action="display_by_username_and_slug", + username=history.user.username, slug=history.slug ) } return return_dict @web.expose diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 lib/galaxy/webapps/galaxy/controllers/root.py --- a/lib/galaxy/webapps/galaxy/controllers/root.py +++ b/lib/galaxy/webapps/galaxy/controllers/root.py @@ -167,7 +167,8 @@ history_dictionary = self.get_history_dict( trans, history, hda_dictionaries=hda_dictionaries ) except Exception, exc: - log.error( 'Error bootstrapping history for user %d: %s', trans.user.id, str( exc ), exc_info=True ) + user_id = str( trans.user.id ) if trans.user else '(anonymous)' + log.error( 'Error bootstrapping history for user %s: %s', user_id, str( exc ), exc_info=True ) message, status = err_msg() history_dictionary[ 'error' ] = message diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 test/casperjs/api-history-tests.js --- /dev/null +++ b/test/casperjs/api-history-tests.js @@ -0,0 +1,372 @@ +/* Utility to load a specific page and output html, page text, or a screenshot + * Optionally wait for some time, text, or dom selector + */ +try { + //...if there's a better way - please let me know, universe + var scriptDir = require( 'system' ).args[3] + // remove the script filename + .replace( /[\w|\.|\-|_]*$/, '' ) + // if given rel. path, prepend the curr dir + .replace( /^(?!\/)/, './' ), + spaceghost = require( scriptDir + 'spaceghost' ).create({ + // script options here (can be overridden by CLI) + //verbose: true, + //logLevel: debug, + scriptDir: scriptDir + }); + +} catch( error ){ + console.debug( error ); + phantom.exit( 1 ); +} +spaceghost.start(); + + +// =================================================================== SET UP +var utils = require( 'utils' ); + +var email = spaceghost.user.getRandomEmail(), + password = '123456'; +if( spaceghost.fixtureData.testUser ){ + email = spaceghost.fixtureData.testUser.email; + password = spaceghost.fixtureData.testUser.password; +} +spaceghost.user.loginOrRegisterUser( email, password ); + +function hasKeys( object, keysArray ){ + if( !utils.isObject( object ) ){ return false; } + for( var i=0; i<keysArray.length; i += 1 ){ + if( !object.hasOwnProperty( keysArray[i] ) ){ return false; } + } + return true; +} + +function countKeys( object ){ + if( !utils.isObject( object ) ){ return 0; } + var count = 0; + for( var key in object ){ + if( object.hasOwnProperty( key ) ){ count += 1; } + } + return count; +} + +// =================================================================== TESTS +spaceghost.thenOpen( spaceghost.baseUrl ).then( function(){ + + // ------------------------------------------------------------------------------------------- INDEX + this.test.comment( 'index should get a list of histories' ); + var historyIndex = this.api.histories.index(); + //this.debug( this.jsonStr( historyIndex ) ); + this.test.assert( utils.isArray( historyIndex ), "index returned an array: length " + historyIndex.length ); + this.test.assert( historyIndex.length >= 1, 'Has at least one history' ); + + var firstHistory = historyIndex[0]; + this.test.assert( hasKeys( firstHistory, [ 'id', 'name', 'url' ] ), 'Has the proper keys' ); + this.test.assert( this.api.isEncodedId( firstHistory.id ), 'Id appears well-formed' ); + + + // ------------------------------------------------------------------------------------------- SHOW + this.test.comment( 'show should get a history details object' ); + var historyShow = this.api.histories.show( firstHistory.id ); + //this.debug( this.jsonStr( historyShow ) ); + this.test.assert( hasKeys( historyShow, [ + 'id', 'name', 'annotation', 'nice_size', 'contents_url', + 'state', 'state_details', 'state_ids' ]), + 'Has the proper keys' ); + + this.test.comment( 'a history details object should contain two objects named state_details and state_ids' ); + var states = [ + 'discarded', 'empty', 'error', 'failed_metadata', 'new', + 'ok', 'paused', 'queued', 'running', 'setting_metadata', 'upload' ], + state_details = historyShow.state_details, + state_ids = historyShow.state_ids; + this.test.assert( hasKeys( state_details, states ), 'state_details has the proper keys' ); + this.test.assert( hasKeys( state_ids, states ), 'state_ids has the proper keys' ); + var state_detailsAreNumbers = true; + state_idsAreArrays = true; + states.forEach( function( state ){ + if( !utils.isArray( state_ids[ state ] ) ){ state_idsAreArrays = false; } + if( !utils.isNumber( state_details[ state ] ) ){ state_detailsAreNumbers = false; } + }); + this.test.assert( state_idsAreArrays, 'state_ids values are arrays' ); + this.test.assert( state_detailsAreNumbers, 'state_details values are numbers' ); + + this.test.comment( 'calling show with "most_recently_used" should return the first history' ); + historyShow = this.api.histories.show( 'most_recently_used' ); + //this.debug( this.jsonStr( historyShow ) ); + this.test.assert( historyShow.id === firstHistory.id, 'Is the first history' ); + + this.test.comment( 'Should be able to combine calls' ); + this.test.assert( this.api.histories.show( this.api.histories.index()[0].id ).id === firstHistory.id, + 'combining function calls works' ); + + // test server bad id protection + this.test.comment( 'A bad id to show should throw an error' ); + this.assertRaises( function(){ + this.api.histories.show( '1234123412341234' ); + }, 'Error in history API at showing history detail: 400 Bad Request', 'Raises an exception' ); + + + // ------------------------------------------------------------------------------------------- CREATE + this.test.comment( 'Calling create should create a new history and allow setting the name' ); + var newHistoryName = 'Created History', + createdHistory = this.api.histories.create({ name: newHistoryName }); + //this.debug( 'returned from create:\n' + this.jsonStr( createdHistory ) ); + this.test.assert( createdHistory.name === newHistoryName, + "Name of created history (from create) is correct: " + createdHistory.name ); + + // check the index + var newFirstHistory = this.api.histories.index()[0]; + //this.debug( 'newFirstHistory:\n' + this.jsonStr( newFirstHistory ) ); + this.test.assert( newFirstHistory.name === newHistoryName, + "Name of last history (from index) is correct: " + newFirstHistory.name ); + this.test.assert( newFirstHistory.id === createdHistory.id, + "Id of last history (from index) is correct: " + newFirstHistory.id ); + + + // ------------------------------------------------------------------------------------------- DELETE + this.test.comment( 'calling delete should delete the given history and remove it from the standard index' ); + var deletedHistory = this.api.histories.delete_( createdHistory.id ); + //this.debug( 'returned from delete:\n' + this.jsonStr( deletedHistory ) ); + this.test.assert( deletedHistory === 'OK', + "Deletion returned 'OK' - even though that's not a great, informative response: " + deletedHistory ); + + newFirstHistory = this.api.histories.index()[0]; + //this.debug( 'newFirstHistory:\n' + this.jsonStr( newFirstHistory ) ); + this.test.assert( newFirstHistory.id !== createdHistory.id, + "Id of last history (from index) DOES NOT appear: " + newFirstHistory.id ); + + this.test.comment( 'calling index with delete=true should include the deleted history' ); + newFirstHistory = this.api.histories.index( true )[0]; + //this.debug( 'newFirstHistory:\n' + this.jsonStr( newFirstHistory ) ); + this.test.assert( newFirstHistory.id === createdHistory.id, + "Id of last history (from index) DOES appear using index( deleted=true ): " + newFirstHistory.id ); + + + // ------------------------------------------------------------------------------------------- UNDELETE + this.test.comment( 'calling undelete should undelete the given history and re-include it in index' ); + var undeletedHistory = this.api.histories.undelete( createdHistory.id ); + //this.debug( 'returned from undelete:\n' + this.jsonStr( undeletedHistory ) ); + this.test.assert( undeletedHistory === 'OK', + "Undeletion returned 'OK' - even though that's not a great, informative response: " + undeletedHistory ); + + newFirstHistory = this.api.histories.index()[0]; + this.debug( 'newFirstHistory:\n' + this.jsonStr( newFirstHistory ) ); + this.test.assert( newFirstHistory.id === createdHistory.id, + "Id of last history (from index) DOES appear after undeletion: " + newFirstHistory.id ); + + + //TODO: show, deleted flag + //TODO: delete, purge flag + // ------------------------------------------------------------------------------------------- UPDATE + // ........................................................................................... idiot proofing + this.test.comment( 'updating to the current value should return no value (no change)' ); + historyShow = this.api.histories.show( newFirstHistory.id ); + var returned = this.api.histories.update( newFirstHistory.id, { + name : historyShow.name + }); + this.test.assert( countKeys( returned ) === 0, "No changed returned: " + this.jsonStr( returned ) ); + + this.test.comment( 'updating using a nonsense key should fail with an error' ); + var err = {}; + try { + returned = this.api.histories.update( newFirstHistory.id, { + konamiCode : 'uuddlrlrba' + }); + } catch( error ){ + err = error; + //this.debug( this.jsonStr( err ) ); + } + this.test.assert( !!err.message, "Error occurred: " + err.message ); + this.test.assert( err.status === 400, "Error status is 400: " + err.status ); + + this.test.comment( 'updating by attempting to change type should cause an error' ); + err = {}; + try { + returned = this.api.histories.update( newFirstHistory.id, { + //name : false + deleted : 'sure why not' + }); + } catch( error ){ + err = error; + //this.debug( this.jsonStr( err ) ); + } + this.test.assert( !!err.message, "Error occurred: " + err.message ); + this.test.assert( err.status === 400, "Error status is 400: " + err.status ); + //TODO??: other type checks? + + + // ........................................................................................... name + this.test.comment( 'update should allow changing the name' ); + returned = this.api.histories.update( newFirstHistory.id, { + name : 'New name' + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.name === 'New name', "Name successfully set via update: " + historyShow.name ); + + this.test.comment( 'update should sanitize any new name' ); + returned = this.api.histories.update( newFirstHistory.id, { + name : 'New name<script type="text/javascript" src="bler">alert("blah");</script>' + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.name === 'New name', "Update sanitized name: " + historyShow.name ); + + //NOTE!: this fails on sqlite3 (with default setup) + try { + this.test.comment( 'update should allow unicode in names' ); + var unicodeName = '桜ゲノム'; + returned = this.api.histories.update( newFirstHistory.id, { + name : unicodeName + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.name === unicodeName, "Update accepted unicode name: " + historyShow.name ); + } catch( err ){ + //this.debug( this.jsonStr( err ) ); + if( ( err instanceof this.api.APIError ) + && ( err.status === 500 ) + && ( err.message.indexOf( '(ProgrammingError) You must not use 8-bit bytestrings' ) !== -1 ) ){ + this.skipTest( 'Unicode update failed. Are you using sqlite3 as the db?' ); + } + } + + this.test.comment( 'update should allow escaped quotations in names' ); + var quotedName = '"Bler"'; + returned = this.api.histories.update( newFirstHistory.id, { + name : quotedName + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.name === quotedName, + "Update accepted escaped quotations in name: " + historyShow.name ); + + + // ........................................................................................... deleted + this.test.comment( 'update should allow changing the deleted flag' ); + returned = this.api.histories.update( newFirstHistory.id, { + deleted: true + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.deleted === true, "Update set the deleted flag: " + historyShow.deleted ); + + this.test.comment( 'update should allow changing the deleted flag back' ); + returned = this.api.histories.update( newFirstHistory.id, { + deleted: false + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.deleted === false, "Update set the deleted flag: " + historyShow.deleted ); + + + // ........................................................................................... published + this.test.comment( 'update should allow changing the published flag' ); + returned = this.api.histories.update( newFirstHistory.id, { + published: true + }); + this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.published === true, "Update set the published flag: " + historyShow.published ); + + + // ........................................................................................... genome_build + this.test.comment( 'update should allow changing the genome_build' ); + returned = this.api.histories.update( newFirstHistory.id, { + genome_build : 'hg18' + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.genome_build === 'hg18', + "genome_build successfully set via update: " + historyShow.genome_build ); + + this.test.comment( 'update should sanitize any genome_build' ); + returned = this.api.histories.update( newFirstHistory.id, { + genome_build : 'hg18<script type="text/javascript" src="bler">alert("blah");</script>' + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.genome_build === 'hg18', + "Update sanitized genome_build: " + historyShow.genome_build ); + + this.test.comment( 'update should allow unicode in genome builds' ); + var unicodeBuild = '桜12'; + //NOTE!: this fails on sqlite3 (with default setup) + try { + returned = this.api.histories.update( newFirstHistory.id, { + name : unicodeBuild + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.genome_build === unicodeBuild, + "Update accepted unicode genome_build: " + historyShow.name ); + } catch( err ){ + //this.debug( this.jsonStr( err ) ); + if( ( err instanceof this.api.APIError ) + && ( err.status === 500 ) + && ( err.message.indexOf( '(ProgrammingError) You must not use 8-bit bytestrings' ) !== -1 ) ){ + this.skipTest( 'Unicode update failed. Are you using sqlite3 as the db?' ); + } + } + + + // ........................................................................................... annotation + this.test.comment( 'update should allow changing the annotation' ); + var newAnnotation = 'Here are some notes that I stole from the person next to me'; + returned = this.api.histories.update( newFirstHistory.id, { + annotation : newAnnotation + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.annotation === newAnnotation, + "Annotation successfully set via update: " + historyShow.annotation ); + + this.test.comment( 'update should sanitize any new annotation' ); + returned = this.api.histories.update( newFirstHistory.id, { + annotation : 'New annotation<script type="text/javascript" src="bler">alert("blah");</script>' + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.annotation === 'New annotation', + "Update sanitized annotation: " + historyShow.annotation ); + + //NOTE!: this fails on sqlite3 (with default setup) + try { + this.test.comment( 'update should allow unicode in annotations' ); + var unicodeAnnotation = 'お願いは、それが落下させない'; + returned = this.api.histories.update( newFirstHistory.id, { + annotation : unicodeAnnotation + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.annotation === unicodeAnnotation, + "Update accepted unicode annotation: " + historyShow.annotation ); + } catch( err ){ + //this.debug( this.jsonStr( err ) ); + if( ( err instanceof this.api.APIError ) + && ( err.status === 500 ) + && ( err.message.indexOf( '(ProgrammingError) You must not use 8-bit bytestrings' ) !== -1 ) ){ + this.skipTest( 'Unicode update failed. Are you using sqlite3 as the db?' ); + } + } + + this.test.comment( 'update should allow escaped quotations in annotations' ); + var quotedAnnotation = '"Bler"'; + returned = this.api.histories.update( newFirstHistory.id, { + annotation : quotedAnnotation + }); + //this.debug( 'returned:\n' + this.jsonStr( returned ) ); + historyShow = this.api.histories.show( newFirstHistory.id ); + this.test.assert( historyShow.annotation === quotedAnnotation, + "Update accepted escaped quotations in annotation: " + historyShow.annotation ); + + +/* +*/ + //this.debug( this.jsonStr( historyShow ) ); +}); + +// =================================================================== +spaceghost.run( function(){ +}); diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 test/casperjs/casperjs_runner.py --- a/test/casperjs/casperjs_runner.py +++ b/test/casperjs/casperjs_runner.py @@ -361,6 +361,14 @@ self.run_js_script( 'hda-state-tests.js' ) +class Test_05_API( CasperJSTestCase ): + """Tests for API functionality and security. + """ + def test_00_history_api( self ): + """Test history API. + """ + self.run_js_script( 'api-history-tests.js' ) + # ==================================================================== MAIN if __name__ == '__main__': diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 test/casperjs/modules/api.js --- a/test/casperjs/modules/api.js +++ b/test/casperjs/modules/api.js @@ -32,11 +32,13 @@ APIError.prototype = new Error(); APIError.prototype.constructor = Error; /** @class Thrown when Galaxy the API returns an error from a request */ -function APIError( msg ){ +function APIError( msg, status ){ Error.apply( this, arguments ); this.name = "APIError"; this.message = msg; + this.status = status; } +API.prototype.APIError = APIError; exports.APIError = APIError; /* ------------------------------------------------------------------- TODO: @@ -65,7 +67,8 @@ if( resp.status !== 200 ){ // grrr... this doesn't lose the \n\r\t - throw new APIError( resp.responseText.replace( /[\s\n\r\t]+/gm, ' ' ).replace( /"/, '' ) ); + //throw new APIError( resp.responseText.replace( /[\s\n\r\t]+/gm, ' ' ).replace( /"/, '' ) ); + throw new APIError( resp.responseText, resp.status ); } return JSON.parse( resp.responseText ); }; @@ -130,7 +133,8 @@ show : 'api/histories/%s', create : 'api/histories', delete_ : 'api/histories/%s', - undelete: 'api/histories/deleted/%s/undelete' + undelete: 'api/histories/deleted/%s/undelete', + update : 'api/histories/%s' }; HistoriesAPI.prototype.index = function index( deleted ){ @@ -183,6 +187,20 @@ }); }; +HistoriesAPI.prototype.update = function create( id, payload ){ + this.api.spaceghost.info( 'history.update: ' + id + ',' + this.api.spaceghost.jsonStr( payload ) ); + + // py.payload <-> ajax.data + id = this.api.ensureId( id ); + payload = this.api.ensureObject( payload ); + url = utils.format( this.urlTpls.update, id ); + + return this.api._ajax( url, { + type : 'PUT', + data : payload + }); +}; + // =================================================================== HDAS var HDAAPI = function HDAAPI( api ){ @@ -201,7 +219,7 @@ }; HDAAPI.prototype.index = function index( historyId, ids ){ - this.api.spaceghost.info( 'history.index: ' + [ historyId, ids ] ); + this.api.spaceghost.info( 'hda.index: ' + [ historyId, ids ] ); var data = {}; if( ids ){ ids = ( utils.isArray( ids ) )?( ids.join( ',' ) ):( ids ); @@ -214,7 +232,7 @@ }; HDAAPI.prototype.show = function show( historyId, id, deleted ){ - this.api.spaceghost.info( 'history.show: ' + [ id, (( deleted )?( 'w deleted' ):( '' )) ] ); + this.api.spaceghost.info( 'hda.show: ' + [ id, (( deleted )?( 'w deleted' ):( '' )) ] ); id = ( id === 'most_recently_used' )?( id ):( this.api.ensureId( id ) ); deleted = deleted || false; @@ -224,7 +242,7 @@ }; HDAAPI.prototype.create = function create( historyId, payload ){ - this.api.spaceghost.info( 'history.create: ' + this.api.spaceghost.jsonStr( payload ) ); + this.api.spaceghost.info( 'hda.create: ' + this.api.spaceghost.jsonStr( payload ) ); // py.payload <-> ajax.data payload = this.api.ensureObject( payload ); @@ -235,7 +253,8 @@ }; HDAAPI.prototype.update = function create( historyId, id, payload ){ - this.api.spaceghost.info( 'history.update: ' + this.api.spaceghost.jsonStr( payload ) ); + this.api.spaceghost.info( 'hda.update: ' + historyId + ',' + id + ',' + + this.api.spaceghost.jsonStr( payload ) ); // py.payload <-> ajax.data historyId = this.api.ensureId( historyId ); diff -r 7c59121055516595937b83d51eaf98b60723b622 -r fb28ceb83c379e1d792f622f9b2cbc8c3e050f37 test/casperjs/spaceghost.js --- a/test/casperjs/spaceghost.js +++ b/test/casperjs/spaceghost.js @@ -545,9 +545,10 @@ * NOTE: uses string indexOf - doesn't play well with urls like [ 'history', 'history/bler' ] * @param {String} urlToWaitFor the url to wait for (rel. to spaceghost.baseUrl) * @param {Function} then the function to call after the nav request + * @param {Function} timeoutFn the function to call on timeout (optional) */ -SpaceGhost.prototype.waitForNavigation = function waitForNavigation( urlToWaitFor, then ){ - return this.waitForMultipleNavigation( [ urlToWaitFor ], then ); +SpaceGhost.prototype.waitForNavigation = function waitForNavigation( urlToWaitFor, then, timeoutFn ){ + return this.waitForMultipleNavigation( [ urlToWaitFor ], then, timeoutFn ); }; /** Wait for a multiple navigation requests then call a function. @@ -555,8 +556,9 @@ * NOTE: uses string indexOf - doesn't play well with urls like [ 'history', 'history/bler' ] * @param {String[]} urlsToWaitFor the relative urls to wait for * @param {Function} then the function to call after the nav request + * @param {Function} timeoutFn the function to call on timeout (optional) */ -SpaceGhost.prototype.waitForMultipleNavigation = function waitForMultipleNavigation( urlsToWaitFor, then ){ +SpaceGhost.prototype.waitForMultipleNavigation = function waitForMultipleNavigation( urlsToWaitFor, then, timeoutFn ){ this.info( 'waiting for navigation: ' + this.jsonStr( urlsToWaitFor ) ); function urlMatches( urlToMatch, url ){ return ( url.indexOf( spaceghost.baseUrl + '/' + urlToMatch ) !== -1 ); @@ -586,6 +588,9 @@ function callThen(){ if( utils.isFunction( then ) ){ then.call( this ); } }, + function timeout(){ + if( utils.isFunction( timeoutFn ) ){ timeoutFn.call( this ); } + }, this.options.waitTimeout * urlsToWaitFor.length ); return this; @@ -731,8 +736,9 @@ /** Casper has an (undocumented?) skip test feature. This is a conv. wrapper for that. */ -SpaceGhost.prototype.skipTest = function skipTest(){ - throw this.test.SKIP_MESSAGE; +SpaceGhost.prototype.skipTest = function skipTest( msg ){ + this.warn( 'Skipping test. ' + msg ); + //throw this.test.SKIP_MESSAGE; }; /** Test helper - within frame, assert selector, and assert text in selector Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.
participants (1)
-
commits-noreply@bitbucket.org