.and('not.contain', 'anotherKey: anotherValue');
// Check that the file listing show both read & write operations
cy.get('[data-cy=collection-files-panel]').within(() => {
- cy.wait(1000);
- cy.root().should('contain', fileName);
+ cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
+ .should('contain', fileName);
if (isWritable) {
cy.get('[data-cy=upload-button]')
.should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
'bar' // make sure we can go back to the original name as a last step
];
eachPair(names, (from, to) => {
- cy.get('[data-cy=collection-files-panel]')
- .contains(`${from}`).rightclick({force: true});
+ cy.waitForDom().get('[data-cy=collection-files-panel]')
+ .contains(`${from}`).rightclick();
cy.get('[data-cy=context-menu]')
.contains('Rename')
.click();
cy.get('[data-cy=form-submit-btn]').click();
- cy.get('.layout-pane-primary', { wait: 12000 }).contains('Projects').click();
+ cy.get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
cy.get('main').contains(`Files extracted from: ${this.collection.name}`).should('exist');
});
cy.get('[data-cy=form-submit-btn]').should('not.exist');
cy.get('[data-cy=collection-files-right-panel]')
.contains('5mb_b.bin').should('exist');
-
});
});
});
.then(function(containerRequest) {
cy.loginAs(activeUser);
cy.goToPath(`/processes/${containerRequest.uuid}`);
- cy.get('[data-cy=process-info]').should('contain', crName);
+ cy.get('[data-cy=process-details]').should('contain', crName);
cy.get('[data-cy=process-logs]')
.should('contain', 'No logs yet')
.and('not.contain', 'hello world');
});
});
});
-});
+
+ it('should show runtime status indicators', function() {
+ // Setup running container with runtime_status error & warning messages
+ createContainerRequest(
+ activeUser,
+ 'test_container_request',
+ 'arvados/jobs',
+ ['echo', 'hello world'],
+ false, 'Committed')
+ .as('containerRequest')
+ .then(function(containerRequest) {
+ expect(containerRequest.state).to.equal('Committed');
+ expect(containerRequest.container_uuid).not.to.be.equal('');
+
+ cy.getContainer(activeUser.token, containerRequest.container_uuid)
+ .then(function(queuedContainer) {
+ expect(queuedContainer.state).to.be.equal('Queued');
+ });
+ cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
+ state: 'Locked'
+ }).then(function(lockedContainer) {
+ expect(lockedContainer.state).to.be.equal('Locked');
+
+ cy.updateContainer(adminUser.token, lockedContainer.uuid, {
+ state: 'Running',
+ runtime_status: {
+ error: 'Something went wrong',
+ errorDetail: 'Process exited with status 1',
+ warning: 'Free disk space is low',
+ }
+ })
+ .as('runningContainer')
+ .then(function(runningContainer) {
+ expect(runningContainer.state).to.be.equal('Running');
+ expect(runningContainer.runtime_status).to.be.deep.equal({
+ 'error': 'Something went wrong',
+ 'errorDetail': 'Process exited with status 1',
+ 'warning': 'Free disk space is low',
+ });
+ });
+ })
+ });
+ // Test that the UI shows the error and warning messages
+ cy.getAll('@containerRequest', '@runningContainer').then(function([containerRequest]) {
+ cy.loginAs(activeUser);
+ cy.goToPath(`/processes/${containerRequest.uuid}`);
+ cy.get('[data-cy=process-runtime-status-error]')
+ .should('contain', 'Something went wrong')
+ .and('contain', 'Process exited with status 1');
+ cy.get('[data-cy=process-runtime-status-warning]')
+ .should('contain', 'Free disk space is low')
+ .and('contain', 'No additional warning details available');
+ });
+ });
+});
Cypress.Commands.add(
"getCollection", (token, uuid) => {
- return cy.doRequest('GET', `/arvados/v1/collections/${uuid}`, null, {}, token)
- .its('body')
- .then(function (theCollection) {
- return theCollection;
- })
+ return cy.getResource(token, 'collections', uuid)
}
)
}
)
+ Cypress.Commands.add(
+ "getContainer", (token, uuid) => {
+ return cy.getResource(token, 'containers', uuid)
+ }
+ )
+
+ Cypress.Commands.add(
+ "updateContainer", (token, uuid, data) => {
+ return cy.updateResource(token, 'containers', uuid, {
+ container: JSON.stringify(data)
+ })
+ }
+ )
+
Cypress.Commands.add(
'createContainerRequest', (token, data) => {
return cy.createResource(token, 'container_requests', {
}
)
+ Cypress.Commands.add(
+ "getResource", (token, suffix, uuid) => {
+ return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
+ .its('body')
+ .then(function (resource) {
+ return resource;
+ })
+ }
+ )
+
Cypress.Commands.add(
"createResource", (token, suffix, data) => {
return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true)
- .its('body').as('resource')
- .then(function () {
- createdResources.push({suffix, uuid: this.resource.uuid});
- return this.resource;
+ .its('body')
+ .then(function (resource) {
+ createdResources.push({suffix, uuid: resource.uuid});
+ return resource;
})
}
)
Cypress.Commands.add(
"deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode)
- .its('body').as('resource')
- .then(function () {
- return this.resource;
+ .its('body')
+ .then(function (resource) {
+ return resource;
})
}
)
Cypress.Commands.add(
"updateResource", (token, suffix, uuid, data) => {
- return cy.doRequest('PUT', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
- .its('body').as('resource')
- .then(function () {
- return this.resource;
+ return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
+ .its('body')
+ .then(function (resource) {
+ return resource;
})
}
)
const blob = new Blob(byteArrays, { type: contentType });
return blob
}
+
+// From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
+// This command requires the async package (https://www.npmjs.com/package/async)
+Cypress.Commands.add('waitForDom', () => {
+ cy.window().then(win => {
+ let timeElapsed = 0;
+
+ cy.log("Waiting for DOM mutations to complete");
+
+ return new Cypress.Promise((resolve) => {
+ // set the required variables
+ let async = require("async");
+ let observerConfig = { attributes: true, childList: true, subtree: true };
+ let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
+ win.mutationCount = 0;
+ win.previousMutationCount = null;
+
+ // create an observer instance
+ let observer = new win.MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ // Only record "attributes" type mutations that are not a "class" mutation.
+ // If the mutation is not an "attributes" type, then we always record it.
+ if (mutation.type === 'attributes' && mutation.attributeName !== 'class') {
+ win.mutationCount += 1;
+ } else if (mutation.type !== 'attributes') {
+ win.mutationCount += 1;
+ }
+ });
+
+ // initialize the previousMutationCount
+ if (win.previousMutationCount == null) win.previousMutationCount = 0;
+ });
+
+ // watch the document body for the specified mutations
+ observer.observe(win.document.body, observerConfig);
+
+ // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
+ async.eachSeries(items, function iteratee(item, callback) {
+ // keep track of the elapsed time so we can log it at the end of the command
+ timeElapsed = timeElapsed + 100;
+
+ // make each iteration of the loop 100ms apart
+ setTimeout(() => {
+ if (win.mutationCount === win.previousMutationCount) {
+ // pass an argument to the async callback to exit the loop
+ return callback('Resolved - DOM changes complete.');
+ } else if (win.previousMutationCount != null) {
+ // only set the previous count if the observer has checked the DOM at least once
+ win.previousMutationCount = win.mutationCount;
+ return callback();
+ } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
+ // this is an early exit in case nothing is changing in the DOM. That way we only
+ // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
+ return callback('Resolved - Exiting early since no DOM changes were detected.');
+ } else {
+ // proceed to the next iteration
+ return callback();
+ }
+ }, 100);
+ }, function done() {
+ // Log the total wait time so users can see it
+ cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
+
+ // disconnect the observer and resolve the promise
+ observer.disconnect();
+ resolve();
+ });
+ });
+ });
+ });
IconButton,
CardContent,
Tooltip,
- Chip,
+ Typography,
} from '@material-ui/core';
import { ArvadosTheme } from 'common/custom-theme';
-import { CloseIcon } from 'components/icon/icon';
+import { CloseIcon, MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
- import { Process, getProcessStatus, getProcessStatusColor } from 'store/processes/process';
+ import { Process } from 'store/processes/process';
import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
import { ProcessDetailsAttributes } from './process-details-attributes';
++import { ProcessStatus } from 'views-components/data-explorer/renderers';
+import { ContainerState } from 'models/container';
- type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'chip' | 'avatar' | 'iconHeader';
-type CssRules = 'card' | 'content' | 'title' | 'header';
++type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
card: {
paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit,
},
+ iconHeader: {
+ fontSize: '1.875rem',
+ color: theme.customs.colors.green700,
+ },
+ avatar: {
+ alignSelf: 'flex-start',
+ paddingTop: theme.spacing.unit * 0.5
+ },
content: {
'&:last-child': {
paddingBottom: theme.spacing.unit * 2,
overflow: 'hidden',
paddingTop: theme.spacing.unit * 0.5
},
- chip: {
- height: theme.spacing.unit * 3,
- width: theme.spacing.unit * 12,
- color: theme.palette.common.white,
- fontSize: '0.875rem',
- borderRadius: theme.spacing.unit * 0.625,
- },
+ cancelButton: {
+ paddingRight: theme.spacing.unit * 2,
+ fontSize: '14px',
+ color: theme.customs.colors.red900,
+ "&:hover": {
+ cursor: 'pointer'
+ }
+ },
});
export interface ProcessDetailsCardDataProps {
process: Process;
+ cancelProcess: (uuid: string) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
}
- type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules, true> & MPVPanelProps;
+ type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules> & MPVPanelProps;
- export const ProcessDetailsCard = withStyles(styles, {withTheme: true})(
- ({ theme, cancelProcess, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+ export const ProcessDetailsCard = withStyles(styles)(
- ({ classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
++ ({ cancelProcess, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
return <Card className={classes.card}>
<CardHeader
className={classes.header}
classes={{
content: classes.title,
+ avatar: classes.avatar,
}}
- title='Details'
- action={ doHidePanel &&
+ avatar={<ProcessIcon className={classes.iconHeader} />}
+ title={
+ <Tooltip title={process.containerRequest.name} placement="bottom-start">
+ <Typography noWrap variant='h6' color='inherit'>
+ {process.containerRequest.name}
+ </Typography>
+ </Tooltip>
+ }
+ subheader={
+ <Tooltip title={getDescription(process)} placement="bottom-start">
+ <Typography noWrap variant='body1' color='inherit'>
+ {getDescription(process)}
+ </Typography>
+ </Tooltip>}
+ action={
+ <div>
+ {process.container && process.container.state === ContainerState.RUNNING &&
+ <span className={classes.cancelButton} onClick={() => cancelProcess(process.containerRequest.uuid)}>Cancel</span>}
- <Chip label={getProcessStatus(process)}
- className={classes.chip}
- style={{ backgroundColor: getProcessStatusColor(getProcessStatus(process), theme as ArvadosTheme) }} />
++ <ProcessStatus uuid={process.containerRequest.uuid} />
+ <Tooltip title="More options" disableFocusListener>
+ <IconButton
+ aria-label="More options"
+ onClick={event => onContextMenu(event)}>
+ <MoreOptionsIcon />
+ </IconButton>
+ </Tooltip>
+ { doHidePanel &&
<Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
<IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
- </Tooltip> } />
+ </Tooltip> }
+ </div>
+ } />
<CardContent className={classes.content}>
- <ProcessDetailsAttributes item={process.containerRequest} twoCol />
+ <ProcessDetailsAttributes request={process.containerRequest} twoCol />
</CardContent>
</Card>;
}
);
+const getDescription = (process: Process) =>
+ process.containerRequest.description || '(no-description)';