diff --git a/cypress.config.ts b/cypress.config.ts index 76d866af7..ed70c2c53 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -42,12 +42,13 @@ export default defineConfig({ runMode: 1, openMode: 1 }, - defaultCommandTimeout: 8000, + defaultCommandTimeout: 10000, execTimeout: 120000, pageLoadTimeout: 120000, responseTimeout: 60000, viewportWidth: 1400, - viewportHeight: 800 + viewportHeight: 800, + experimentalMemoryManagement: true }, - numTestsKeptInMemory: 10 + numTestsKeptInMemory: 5 }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index f660cc059..4400ca7fd 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -139,6 +139,14 @@ Cypress.Commands.add('deleteFile', (name: string): void => { }); }); +Cypress.Commands.add('deleteFiles', (patterns: string[]): void => { + if (patterns.length === 0) return; + const findArgs = patterns.map((p) => `-name "${p}"`).join(' -o '); + cy.exec(`find build/cypress/ \\( ${findArgs} \\) -delete`, { + failOnNonZeroExit: false + }); +}); + Cypress.Commands.add( 'createPipeline', ({ name, type, emptyPipeline } = {}): void => { @@ -165,12 +173,17 @@ Cypress.Commands.add( cy.openFile(name); } - cy.get('.common-canvas-drop-div'); - // wait an additional 300ms for the list of items to settle - cy.wait(300); + cy.get('.common-canvas-drop-div').should('be.visible'); } ); +Cypress.Commands.add('focusPipelineEditor', (): void => { + cy.get( + '.jp-LauncherCard[data-category="Elyra"][title="Generic Pipeline Editor"]' + ).click(); + cy.get('.common-canvas-drop-div').should('be.visible'); +}); + Cypress.Commands.add('openDirectory', (name: string): void => { cy.findByRole('listitem', { name: (n, _el) => n.includes(name) @@ -180,8 +193,12 @@ Cypress.Commands.add('openDirectory', (name: string): void => { Cypress.Commands.add('addFileToPipeline', (name: string): void => { cy.findByRole('listitem', { name: (n, _el) => n.includes(name) - }).rightclick(); - cy.findByRole('menuitem', { name: /add file to pipeline/i }).click(); + }) + .should('be.visible') + .rightclick(); + cy.findByRole('menuitem', { name: /add file to pipeline/i }) + .should('be.visible') + .click(); }); Cypress.Commands.add('dragAndDropFileToPipeline', (name: string) => { @@ -195,9 +212,24 @@ Cypress.Commands.add('dragAndDropFileToPipeline', (name: string) => { }); Cypress.Commands.add('savePipeline', (): void => { - cy.findByRole('button', { name: /save pipeline/i }).click(); - // can take a moment to register as saved in ci - cy.wait(1000); + cy.intercept('PUT', '**/api/contents/**').as('savePipelineFile'); + + // Check if document has unsaved changes before clicking save + cy.document().then((doc) => { + const isDirty = doc.querySelector('.jp-Document.jp-mod-dirty') !== null; + + cy.findByRole('button', { name: /save pipeline/i }).click(); + + if (isDirty) { + // Wait for the server to finish writing the file + cy.wait('@savePipelineFile'); + } + + // Confirm document is no longer dirty + cy.get('.jp-Document:not(.jp-mod-dirty)', { timeout: 10000 }).should( + 'exist' + ); + }); }); Cypress.Commands.add('openFile', (name: string): void => { @@ -219,6 +251,8 @@ Cypress.Commands.add('resetJupyterLab', (): void => { cy.findByRole('tab', { name: /file browser/i, timeout: 25000 }).should( 'exist' ); + // Wait for the launcher to be fully rendered + cy.get('.jp-Launcher', { timeout: 10000 }).should('be.visible'); }); Cypress.Commands.add('checkTabMenuOptions', (fileType: string): void => { @@ -235,6 +269,8 @@ Cypress.Commands.add('closeTab', (index: number): void => { }); Cypress.Commands.add('createNewScriptEditor', (language: string): void => { + // Ensure launcher is visible (may take a moment after closing a previous tab) + cy.get('.jp-Launcher', { timeout: 10000 }).should('be.visible'); cy.get( `.jp-LauncherCard[data-category="Elyra"][title="Create a new ${language} Editor"]:visible` ).click(); @@ -308,16 +344,7 @@ Cypress.Commands.add( (fileExtension: string): void => { cy.openHelloWorld(fileExtension); // Ensure that the file contents are as expected - cy.get('.cm-line').then((lines) => { - const content = [...lines] - .map((line) => line.innerText) - .join('\n') - .trim(); - expect(content).to.equal('print("Hello Elyra")'); - }); - - // Close the file editor - cy.closeTab(-1); + cy.get('.cm-line').should('contain.text', 'print("Hello Elyra")'); } ); @@ -345,7 +372,26 @@ Cypress.Commands.add('dismissAssistant', (fileType: string): void => { }); }); +// Allowlist of known benign JupyterLab errors that should not fail tests. +// Unknown errors are allowed to propagate so real bugs surface. +const BENIGN_ERROR_PATTERNS: RegExp[] = [ + /ResizeObserver loop/, + /cancelled/, + /Disposed/, + /restore\(\) must be called/, + /Non-Error promise rejection/, + // JupyterLab internal null-pointer errors from extensions + /Cannot read properties of null/, + // JupyterLab checkpoint errors during cleanup/navigation + /Unhandled error/ +]; + Cypress.on('uncaught:exception', (err, _runnable) => { - console.log('Uncaught exception:', err); - return false; // Prevent Cypress from failing the test + const message = err.message ?? String(err); + if (BENIGN_ERROR_PATTERNS.some((pattern) => pattern.test(message))) { + return false; // Suppress known benign errors + } + // Let unknown errors fail the test + console.error('Uncaught exception (not suppressed):', err); + return undefined; }); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index e25c92a3b..be1499ae7 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -27,6 +27,7 @@ declare namespace Cypress { type: 'kfp' | 'airflow'; }): Chainable; deleteFile(fileName: string): Chainable; + deleteFiles(patterns: string[]): Chainable; openDirectory(fileName: string): Chainable; addFileToPipeline(fileName: string): Chainable; dragAndDropFileToPipeline(fileName: string): Chainable; @@ -46,5 +47,6 @@ declare namespace Cypress { openFileAndCheckContent(fileExtension: string): Chainable; openHelloWorld(fileExtension: string): Chainable; dismissAssistant(fileType: string): Chainable; + focusPipelineEditor(): Chainable; } } diff --git a/cypress/tests/codesnippet.cy.ts b/cypress/tests/codesnippet.cy.ts index d09cac335..9b908e761 100644 --- a/cypress/tests/codesnippet.cy.ts +++ b/cypress/tests/codesnippet.cy.ts @@ -110,8 +110,6 @@ describe('Code Snippet tests', () => { it('should delete existing Code Snippet', () => { createValidCodeSnippet(snippetName); - cy.wait(500); - getSnippetByName(snippetName); deleteSnippet(snippetName); @@ -120,19 +118,16 @@ describe('Code Snippet tests', () => { // Duplicate snippet it('should duplicate existing Code Snippet', () => { createValidCodeSnippet(snippetName); - cy.wait(500); let snippetRef = getSnippetByName(snippetName); expect(snippetRef).to.not.be.null; // create a duplicate of this snippet duplicateSnippet(snippetName); - cy.wait(100); snippetRef = getSnippetByName(`${snippetName}-Copy1`); expect(snippetRef).to.not.be.null; // create another duplicate of this snippet duplicateSnippet(snippetName); - cy.wait(100); snippetRef = getSnippetByName(`${snippetName}-Copy2`); expect(snippetRef).to.not.be.null; @@ -207,8 +202,6 @@ describe('Code Snippet tests', () => { .type(newSnippetName); saveAndCloseMetadataEditor(); - cy.wait(500); - // Check new snippet name is displayed const updatedSnippetItem = getSnippetByName(newSnippetName); @@ -222,9 +215,6 @@ describe('Code Snippet tests', () => { }); it('should fail to insert a code snippet into unsupported widget', () => { - // Give time for the Launcher tab to load - cy.wait(2000); - createValidCodeSnippet(snippetName); // Insert snippet into launcher widget @@ -233,19 +223,16 @@ describe('Code Snippet tests', () => { // Check if insertion failed and dismiss dialog cy.get('.jp-Dialog-header').contains('Error'); cy.get('button.jp-mod-accept').click(); - cy.wait(100); }); it('should insert a python code snippet into python editor', () => { - // Give time for the Launcher tab to load - cy.wait(2000); - createValidCodeSnippet(snippetName); // Open blank python file cy.createNewScriptEditor('Python'); - cy.wait(1500); + // Wait for script editor to be ready + cy.get('.elyra-ScriptEditor', { timeout: 10000 }).should('be.visible'); // Insert snippet into python editor insert(snippetName); @@ -256,15 +243,13 @@ describe('Code Snippet tests', () => { }); it('should fail to insert a java code snippet into python editor', () => { - // Give time for the Launcher tab to load - cy.wait(2000); - createValidCodeSnippet(snippetName, 'Java'); // Open blank python file cy.createNewScriptEditor('Python'); - cy.wait(500); + // Wait for script editor to be ready + cy.get('.elyra-ScriptEditor', { timeout: 10000 }).should('be.visible'); // Insert snippet into python editor insert(snippetName); @@ -287,12 +272,12 @@ describe('Code Snippet tests', () => { saveAndCloseMetadataEditor(); - cy.wait(500); - // Close Code Snippets sidebar and open File Browser cy.get('.jp-SideBar [title*="Code Snippets"]').click(); cy.get('.jp-SideBar [title*="File Browser"]').click(); - cy.wait(500); + cy.get('.jp-SideBar .lm-mod-current[title*="File Browser"]').should( + 'exist' + ); // Create new notebook via File menu cy.get('.lm-MenuBar-itemLabel:contains("File")').first().click(); @@ -303,14 +288,15 @@ describe('Code Snippet tests', () => { cy.get('.jp-Dialog', { timeout: 10000 }).should('be.visible'); cy.get('.jp-Dialog .jp-mod-accept').click(); cy.get('.jp-Dialog').should('not.exist'); - cy.wait(500); - // Check widget is loaded - Update selector for modern JupyterLab + // Check widget is loaded cy.get('.cm-editor:visible'); // Re-open Code Snippets sidebar as it may have switched when opening notebook cy.get('.jp-SideBar [title="Code Snippets"]').click(); - cy.wait(500); + cy.get('.jp-SideBar .lm-mod-current[title*="Code Snippets"]').should( + 'exist' + ); insert(snippetName); @@ -328,12 +314,12 @@ describe('Code Snippet tests', () => { saveAndCloseMetadataEditor(); - cy.wait(500); - // Close Code Snippets sidebar and open File Browser cy.get('.jp-SideBar [title*="Code Snippets"]').click(); cy.get('.jp-SideBar [title*="File Browser"]').click(); - cy.wait(500); + cy.get('.jp-SideBar .lm-mod-current[title*="File Browser"]').should( + 'exist' + ); // Create new markdown via File menu cy.get('.lm-MenuBar-itemLabel:contains("File")').first().click(); @@ -341,14 +327,15 @@ describe('Code Snippet tests', () => { cy.get( '[data-command="fileeditor:create-new-markdown-file"] > .lm-Menu-itemLabel' ).click(); - cy.wait(500); - // Check widget is loaded - Update selector for modern JupyterLab + // Check widget is loaded cy.get('.cm-editor:visible'); // Re-open Code Snippets sidebar as it may have switched when opening markdown cy.get('.jp-SideBar [title="Code Snippets"]').click(); - cy.wait(500); + cy.get('.jp-SideBar .lm-mod-current[title*="Code Snippets"]').should( + 'exist' + ); insert(snippetName); @@ -370,7 +357,7 @@ describe('Code Snippet tests', () => { const openCodeSnippetExtension = (): void => { // In JupyterLab 4, click the Code Snippets tab button cy.get('.jp-SideBar [title*="Code Snippets"]').click(); - cy.get('.jp-SideBar .lm-mod-current[title*="Code Snippets"]'); + cy.get('.jp-SideBar .lm-mod-current[title*="Code Snippets"]').should('exist'); }; const getSnippetByName = ( @@ -414,7 +401,8 @@ const createValidCodeSnippet = ( saveAndCloseMetadataEditor(); - cy.wait(1000); + // Wait for snippet to appear in the list + cy.get(`[data-item-id="${snippetName}"]`).should('exist'); }; const clickCreateNewSnippetButton = (): void => { @@ -430,7 +418,6 @@ const checkValidationWarnings = (count: number): void => { const saveAndCloseMetadataEditor = (): void => { cy.get('.elyra-metadataEditor-saveButton > button:visible').click(); - cy.wait(500); }; const typeCodeSnippetName = (name: string): void => { @@ -463,7 +450,6 @@ const deleteSnippet = (snippetName: string): void => { cy.get('.jp-Dialog .lm-TabBar-tabCloseIcon') .first() .click({ force: true }); - cy.wait(500); } }); @@ -500,7 +486,6 @@ const insert = (snippetName: string): void => { getActionButtonsElement(snippetName).within(() => { cy.get('button[title="Insert"]').click(); }); - cy.wait(500); }; const editSnippetLanguage = (snippetName: string, lang: string): void => { diff --git a/cypress/tests/codesnippetfromselectedcells.cy.ts b/cypress/tests/codesnippetfromselectedcells.cy.ts index 536c8c04c..1c3578ad7 100644 --- a/cypress/tests/codesnippetfromselectedcells.cy.ts +++ b/cypress/tests/codesnippetfromselectedcells.cy.ts @@ -23,14 +23,16 @@ describe('Code snippet from cells tests', () => { '.jp-LauncherCard[data-category="Notebook"][title="Python 3 (ipykernel)"]' ).click(); - cy.wait(2000); + // Wait for notebook and kernel to be ready + cy.get('.jp-Notebook', { timeout: 10000 }).should('exist'); + waitForKernelIdle(); }); it('test empty cell', () => { - cy.get('.jp-Notebook', { timeout: 10000 }).should('have.length', 1); - cy.get('.jp-Cell').first().rightclick(); + cy.get('.jp-Notebook').should('have.length', 1); - cy.wait(2000); + // Extension commands register asynchronously — use retry helper + openCellContextMenuWithSnippetItem(); cy.get( 'li.lm-Menu-item[data-command="codesnippet:save-as-snippet"]' @@ -40,16 +42,16 @@ describe('Code snippet from cells tests', () => { it('test 1 cell', () => { populateCells(); - cy.get('.jp-Notebook', { timeout: 10000 }).should('have.length', 1); - cy.get('.jp-Cell').first().rightclick(); + cy.get('.jp-Notebook').should('have.length', 1); - cy.wait(2000); + openCellContextMenuWithSnippetItem(); cy.get( 'li.lm-Menu-item[data-command="codesnippet:save-as-snippet"]' ).click(); - cy.wait(2000); + // Wait for snippet editor to open + cy.get('.elyra-metadataEditor', { timeout: 10000 }).should('be.visible'); // Verify snippet editor contents cy.get('.elyra-metadataEditor .cm-editor .cm-content .cm-line').then( @@ -69,7 +71,7 @@ describe('Code snippet from cells tests', () => { '.jp-NotebookPanel-toolbar > div:nth-child(2) > jp-button:nth-child(1)' ).click(); - cy.wait(2000); + waitForKernelIdle(); populateCells(); @@ -88,13 +90,14 @@ describe('Code snippet from cells tests', () => { force: true }); - cy.wait(2000); + openCellContextMenuWithSnippetItem(); cy.get( 'li.lm-Menu-item[data-command="codesnippet:save-as-snippet"]' ).click(); - cy.wait(2000); + // Wait for snippet editor to open + cy.get('.elyra-metadataEditor', { timeout: 10000 }).should('be.visible'); // Verify snippet editor contents cy.get('.elyra-metadataEditor .cm-editor .cm-content .cm-line').then( @@ -114,14 +117,48 @@ describe('Code snippet from cells tests', () => { // ----- Utility Functions // ------------------------------ -// Populate cells +// Wait for kernel to reach idle status +const waitForKernelIdle = (): void => { + cy.get('[data-status="idle"]', { timeout: 30000 }).should('exist'); +}; + +// Populate cells by re-querying each by index to avoid stale DOM references const populateCells = (): void => { - cy.get('.jp-Cell').each(($cell) => { - cy.wrap($cell).click(); - cy.wrap($cell).should('have.class', 'jp-mod-selected'); - cy.wrap($cell) - .find('.jp-InputArea') - .click() - .type('print("test")', { delay: 100 }); + cy.get('.jp-Cell').then(($cells) => { + for (let i = 0; i < $cells.length; i++) { + cy.get('.jp-Cell') + .eq(i) + .click() + .should('have.class', 'jp-mod-selected') + .find('.jp-InputArea') + .click() + .type('print("test")', { delay: 100 }); + } }); }; + +// Retry opening context menu until the snippet command is registered. +// Extension commands register asynchronously; the menu must be +// re-opened to pick up newly available items. +const openCellContextMenuWithSnippetItem = (maxRetries: number = 5): void => { + const attemptOpen = (remaining: number): void => { + cy.get('.jp-Cell').first().rightclick(); + cy.get('ul.lm-Menu-content').should('exist'); + + cy.get('body').then(($body) => { + const hasItem = + $body.find( + 'li.lm-Menu-item[data-command="codesnippet:save-as-snippet"]' + ).length > 0; + + if (!hasItem && remaining > 0) { + // Dismiss menu and retry + cy.get('body').click(0, 0, { force: true }); + cy.get('ul.lm-Menu-content').should('not.exist'); + attemptOpen(remaining - 1); + } + }); + }; + + attemptOpen(maxRetries); +}; diff --git a/cypress/tests/pipeline.cy.ts b/cypress/tests/pipeline.cy.ts index 5446ad63b..0c3c8ffc2 100644 --- a/cypress/tests/pipeline.cy.ts +++ b/cypress/tests/pipeline.cy.ts @@ -38,10 +38,12 @@ const emptyPipeline = `{ describe('Pipeline Editor tests', () => { beforeEach(() => { - cy.deleteFile('generic-test.yaml'); // previously exported pipeline - cy.deleteFile('generic-test-custom.yaml'); // previously exported pipeline - cy.deleteFile('generic-test.py'); // previously exported pipeline - cy.deleteFile('*.pipeline'); // delete pipeline files used for testing + cy.deleteFiles([ + 'generic-test.yaml', + 'generic-test-custom.yaml', + 'generic-test.py', + '*.pipeline' + ]); cy.bootstrapFile('invalid.pipeline'); cy.bootstrapFile('generic-test.pipeline'); @@ -55,67 +57,59 @@ describe('Pipeline Editor tests', () => { }); afterEach(() => { - cy.deleteFile('helloworld.ipynb'); // delete notebook file used for testing - cy.deleteFile('helloworld.py'); // delete python file used for testing - cy.deleteFile('output.txt'); // delete output files generated by tests - cy.deleteFile('*.pipeline'); // delete pipeline files used for testing - cy.deleteFile('generic-test.yaml'); // exported pipeline - cy.deleteFile('generic-test-custom.yaml'); // exported pipeline - cy.deleteFile('generic-test.py'); // exported pipeline - cy.deleteFile('invalid.txt'); - - // delete complex test directories - cy.deleteFile('pipelines'); - cy.deleteFile('scripts'); - - // delete runtime configurations used for testing - cy.exec('elyra-metadata remove runtimes --name=kfp_test_runtime', { - failOnNonZeroExit: false - }); - cy.exec('elyra-metadata remove runtimes --name=airflow_test_runtime', { - failOnNonZeroExit: false - }); - - // delete example catalogs used for testing + cy.deleteFiles([ + 'helloworld.ipynb', + 'helloworld.py', + 'output.txt', + '*.pipeline', + 'generic-test.yaml', + 'generic-test-custom.yaml', + 'generic-test.py', + 'invalid.txt', + 'pipelines', + 'scripts' + ]); + + // delete runtime configurations and example catalogs cy.exec( - 'elyra-metadata remove component-catalogs --name=example_components', - { - failOnNonZeroExit: false - } + 'elyra-metadata remove runtimes --name=kfp_test_runtime 2>/dev/null; ' + + 'elyra-metadata remove runtimes --name=airflow_test_runtime 2>/dev/null; ' + + 'elyra-metadata remove component-catalogs --name=example_components 2>/dev/null', + { failOnNonZeroExit: false } ); }); - // TODO: Fix Test is actually failing - // it('empty editor should have disabled buttons', () => { - // cy.focusPipelineEditor(); - - // const disabledButtons = [ - // '.run-action', - // '.export-action', - // '.clear-action', - // '.undo-action', - // '.redo-action', - // '.cut-action', - // '.copy-action', - // '.paste-action', - // '.deleteSelectedObjects-action', - // '.arrangeHorizontally-action', - // '.arrangeVertically-action' - // ]; - // checkDisabledToolbarButtons(disabledButtons); - - // const enabledButtons = [ - // '.save-action', - // '.openRuntimes-action', - // '.createAutoComment-action' - // ]; - // checkEnabledToolbarButtons(enabledButtons); - - // closePipelineEditor(); - // }); + it('empty editor should have disabled buttons', () => { + cy.focusPipelineEditor(); + + // Run and Clear require nodes; undo/redo/cut/copy/paste/delete + // are auto-managed by @elyra/canvas based on selection state. + const disabledButtons = [ + /run pipeline/i, + /clear/i, + /undo/i, + /redo/i, + /cut/i, + /copy/i, + /paste/i, + /delete/i + ]; + checkDisabledToolbarButtons(disabledButtons); - // Flaky test: Missing expected items in the context menu - it.skip('populated editor should have enabled buttons', () => { + // Arrange and Add Comment are auto-enabled by @elyra/canvas when + // editing is allowed, regardless of node count. + const enabledButtons = [ + /save pipeline/i, + /export pipeline/i, + /open runtimes/i, + /add comment/i, + /arrange horizontally/i, + /arrange vertically/i + ]; + checkEnabledToolbarButtons(enabledButtons); + }); + + it('populated editor should have enabled buttons', () => { cy.createPipeline({ emptyPipeline }); cy.checkTabMenuOptions('Pipeline'); @@ -163,9 +157,7 @@ describe('Pipeline Editor tests', () => { cy.openDirectory('pipelines'); cy.writeFile('build/cypress/pipelines/complex.pipeline', emptyPipeline); cy.openFile('complex.pipeline'); - cy.get('.common-canvas-drop-div'); - // wait an additional 300ms for the list of items to settle - cy.wait(300); + cy.get('.common-canvas-drop-div').should('be.visible'); cy.addFileToPipeline('producer.ipynb'); cy.addFileToPipeline('consumer.ipynb'); @@ -207,25 +199,19 @@ describe('Pipeline Editor tests', () => { ); }); cy.get('#root_component_parameters_runtime_image').within(() => { - cy.get('select[id="root_component_parameters_runtime_image"]').select( - 'continuumio/anaconda3:2024.02-1' - ); + selectRuntimeImage(); }); // consumer props cy.findByText('consumer.ipynb').click(); cy.get('#root_component_parameters_runtime_image').within(() => { - cy.get('select[id="root_component_parameters_runtime_image"]').select( - 'continuumio/anaconda3:2024.02-1' - ); + selectRuntimeImage(); }); // setup props cy.findByText('setup.py').click(); cy.get('#root_component_parameters_runtime_image').within(() => { - cy.get('select[id="root_component_parameters_runtime_image"]').select( - 'continuumio/anaconda3:2024.02-1' - ); + selectRuntimeImage(); }); cy.get('#root_component_parameters_dependencies').within(() => { cy.findByRole('button', { name: /add/i }).click(); @@ -243,9 +229,7 @@ describe('Pipeline Editor tests', () => { // create-source-files props cy.findByText('create-source-files.py').click(); cy.get('#root_component_parameters_runtime_image').within(() => { - cy.get('select[id="root_component_parameters_runtime_image"]').select( - 'continuumio/anaconda3:2024.02-1' - ); + selectRuntimeImage(); }); cy.get('#root_component_parameters_outputs').within(() => { cy.findByRole('button', { name: /add/i }).click(); @@ -262,9 +246,7 @@ describe('Pipeline Editor tests', () => { // producer-script props cy.findByText('producer-script.py').click(); cy.get('#root_component_parameters_runtime_image').within(() => { - cy.get('select[id="root_component_parameters_runtime_image"]').select( - 'continuumio/anaconda3:2024.02-1' - ); + selectRuntimeImage(); }); cy.get('#root_component_parameters_outputs').within(() => { cy.findByRole('button', { name: /add/i }).click(); @@ -388,7 +370,12 @@ describe('Pipeline Editor tests', () => { .scrollIntoView() .find('select') .should('be.visible') - .select('continuumio/anaconda3:2024.02-1', { force: true }); + .then(($select) => { + cy.wrap($select) + .find(`option[value="${RUNTIME_IMAGE}"]`) + .should('exist'); + cy.wrap($select).select(RUNTIME_IMAGE, { force: true }); + }); // Generic Node Defaults > Kubernetes Secrets cy.get('#root_pipeline_defaults_kubernetes_secrets') .scrollIntoView() @@ -416,10 +403,10 @@ describe('Pipeline Editor tests', () => { }); }); - // Flaky test: Sometimes cannot create/open files - it.skip('should open notebook on double-clicking the node', () => { + it('should open notebook on double-clicking the node', () => { // Open a pipeline in root directory cy.openFile('generic-test.pipeline'); + cy.get('.common-canvas-drop-div').should('be.visible'); // Open notebook node with double-click cy.get('.common-canvas-drop-div').within(() => { @@ -431,9 +418,9 @@ describe('Pipeline Editor tests', () => { // close tabs cy.closeTab(-1); // notebook tab - cy.get( - '.jp-Dialog-buttonLabel[aria-label="Discard changes to file"]' - ).click(); + cy.get('.jp-Dialog-buttonLabel[aria-label="Discard changes to file"]', { + timeout: 10000 + }).click(); cy.closeTab(-1); // pipeline tab @@ -442,28 +429,28 @@ describe('Pipeline Editor tests', () => { cy.openDirectory('pipelines'); cy.writeFile('build/cypress/pipelines/complex.pipeline', emptyPipeline); cy.openFile('complex.pipeline'); - cy.get('.common-canvas-drop-div'); - cy.wait(300); + cy.get('.common-canvas-drop-div').should('be.visible'); cy.addFileToPipeline('producer.ipynb'); - cy.wait(300); // Open notebook node with double-click cy.get('#jp-main-dock-panel').within(() => { - cy.findByText('producer.ipynb').dblclick(); + cy.findByText('producer.ipynb').should('be.visible').dblclick(); }); cy.findAllByRole('tab', { name: /producer\.ipynb/g }).should('exist'); }); - // Flaky test: Sometimes cannot create/open files - it.skip('should open notebook from node right-click menu', () => { + it('should open notebook from node right-click menu', () => { // Open a pipeline in root directory cy.openFile('generic-test.pipeline'); + cy.get('.common-canvas-drop-div').should('be.visible'); // Open notebook node with right-click menu cy.get('#jp-main-dock-panel').within(() => { cy.findByText('helloworld.ipynb').rightclick(); - cy.findByRole('menuitem', { name: /open file/i }).click(); + cy.findByRole('menuitem', { name: /open file/i }) + .should('be.visible') + .click(); }); cy.findAllByRole('tab', { name: /helloworld\.ipynb/g }).should('exist'); @@ -471,9 +458,9 @@ describe('Pipeline Editor tests', () => { // close tabs cy.closeTab(-1); // notebook tab - cy.get( - '.jp-Dialog-buttonLabel[aria-label="Discard changes to file"]' - ).click(); + cy.get('.jp-Dialog-buttonLabel[aria-label="Discard changes to file"]', { + timeout: 10000 + }).click(); cy.closeTab(-1); // pipeline tab @@ -482,14 +469,15 @@ describe('Pipeline Editor tests', () => { cy.openDirectory('pipelines'); cy.writeFile('build/cypress/pipelines/complex.pipeline', emptyPipeline); cy.openFile('complex.pipeline'); - cy.get('.common-canvas-drop-div'); - cy.wait(300); + cy.get('.common-canvas-drop-div').should('be.visible'); cy.addFileToPipeline('producer.ipynb'); // Open notebook node with right-click menu cy.get('#jp-main-dock-panel').within(() => { - cy.findByText('producer.ipynb').rightclick(); - cy.findByRole('menuitem', { name: /open file/i }).click(); + cy.findByText('producer.ipynb').should('be.visible').rightclick(); + cy.findByRole('menuitem', { name: /open file/i }) + .should('be.visible') + .click(); }); cy.findAllByRole('tab', { name: /producer\.ipynb/g }).should('exist'); @@ -917,15 +905,27 @@ describe('Pipeline Editor tests', () => { // ----- Utility Functions // ------------------------------ +const RUNTIME_IMAGE = 'continuumio/anaconda3:2024.02-1'; + +// Wait for async-loaded select options before calling .select() +const selectRuntimeImage = (): void => { + cy.get('select[id="root_component_parameters_runtime_image"]') + .find(`option[value="${RUNTIME_IMAGE}"]`) + .should('exist'); + cy.get('select[id="root_component_parameters_runtime_image"]').select( + RUNTIME_IMAGE + ); +}; + const checkEnabledToolbarButtons = (buttons: RegExp[]): void => { for (const button of buttons) { - cy.findByRole('jp-button', { name: button }).should('not.be.disabled'); + cy.findByRole('button', { name: button }).should('not.be.disabled'); } }; const checkDisabledToolbarButtons = (buttons: RegExp[]): void => { for (const button of buttons) { - cy.findByRole('jp-button', { name: button }).should('be.disabled'); + cy.findByRole('button', { name: button }).should('be.disabled'); } }; diff --git a/cypress/tests/pythoneditor.cy.ts b/cypress/tests/pythoneditor.cy.ts index 6f0561466..bce1f5742 100644 --- a/cypress/tests/pythoneditor.cy.ts +++ b/cypress/tests/pythoneditor.cy.ts @@ -42,7 +42,6 @@ describe('Python Editor tests', () => { cy.createNewScriptEditor('Python'); // Wait for editor to fully load before testing right-click cy.get('.elyra-ScriptEditor').should('be.visible'); - cy.wait(500); cy.checkRightClickTabContent('Python'); cy.closeTab(-1); }); @@ -103,12 +102,12 @@ describe('Python Editor tests', () => { cy.createNewScriptEditor('Python'); // Add some text to the editor (wait code editor to load) - cy.wait(1000); - cy.get('.cm-content[contenteditable="true"]') + cy.get('.cm-content[contenteditable="true"]', { timeout: 10000 }) .first() .click({ force: true }) .type('print("test")', { delay: 100 }); + // Brief pause for LSP debounce before dismissing assistant cy.wait(500); cy.dismissAssistant('scripteditor'); @@ -154,8 +153,7 @@ describe('Python Editor tests', () => { it('checks for valid output', () => { cy.openHelloWorld('py'); clickRunButton(); - cy.wait(2000); // Increased wait time for stability - cy.get('.elyra-ScriptEditor-OutputArea-output').should( + cy.get('.elyra-ScriptEditor-OutputArea-output', { timeout: 15000 }).should( 'contain.text', 'Hello Elyra' ); @@ -170,14 +168,14 @@ describe('Python Editor tests', () => { // check for error message running an invalid code it('checks for Error message', () => { cy.createNewScriptEditor('Python'); - cy.wait(1000); // Add some code with syntax error to the editor (wait code editor to load) - cy.get('.cm-editor .cm-content') + cy.get('.cm-editor .cm-content', { timeout: 10000 }) .first() .should('be.visible') .type('print"test"'); + // Brief pause for LSP debounce before dismissing assistant cy.wait(500); cy.dismissAssistant('scripteditor'); clickRunButton(); diff --git a/cypress/tests/submitnotebookbutton.cy.ts b/cypress/tests/submitnotebookbutton.cy.ts index 7f8a43abb..964bb4d2e 100644 --- a/cypress/tests/submitnotebookbutton.cy.ts +++ b/cypress/tests/submitnotebookbutton.cy.ts @@ -22,7 +22,6 @@ describe('Submit Notebook Button tests', () => { beforeEach(() => { cy.resetJupyterLab(); - cy.wait(2000); }); after(() => { @@ -72,5 +71,6 @@ const openNewNotebookFile = (): void => { ) .first() .click(); - cy.wait(500); + // Wait for notebook to be ready + cy.get('.jp-Notebook', { timeout: 10000 }).should('exist'); }; diff --git a/package.json b/package.json index 0b2c29ca3..889edf56c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "graph": "ts-node etc/scripts/generate-make-graph.ts", "cy:open": "npx cypress open", - "cy:run": "npx nyc npx cypress run --headed", + "cy:run": "npx nyc npx cypress run", + "cy:debug": "npx cypress run --headed", "eslint": "eslint . --fix --ignore-path .gitignore --ext .ts,.tsx,.js", "eslint:check": "eslint . --ignore-path .gitignore --ext .ts,.tsx,.js", "prettier": "prettier --ignore-path .gitignore --write \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"", diff --git a/packages/pipeline-editor/package.json b/packages/pipeline-editor/package.json index 3cd6be692..d27a52b8e 100644 --- a/packages/pipeline-editor/package.json +++ b/packages/pipeline-editor/package.json @@ -56,7 +56,7 @@ "dependencies": { "@elyra/metadata-common": "4.0.1-dev", "@elyra/pipeline-editor": "1.13.0", - "@elyra/pipeline-services": "1.12.1", + "@elyra/pipeline-services": "1.13.0", "@elyra/services": "4.0.1-dev", "@elyra/ui-components": "4.0.1-dev", "@jupyterlab/application": "^4.4.2", diff --git a/packages/pipeline-editor/src/PipelineEditorWidget.tsx b/packages/pipeline-editor/src/PipelineEditorWidget.tsx index 662ca724d..585bdd0b6 100644 --- a/packages/pipeline-editor/src/PipelineEditorWidget.tsx +++ b/packages/pipeline-editor/src/PipelineEditorWidget.tsx @@ -1112,12 +1112,14 @@ const PipelineWrapper: React.FC< ] ); + const hasNodes = (pipeline?.pipelines?.[0]?.nodes?.length ?? 0) > 0; + const toolbar = { leftBar: [ { action: 'run', label: 'Run Pipeline', - enable: true + enable: hasNodes }, { action: 'save', @@ -1136,7 +1138,7 @@ const PipelineWrapper: React.FC< { action: 'clear', label: 'Clear Pipeline', - enable: true, + enable: hasNodes, iconEnabled: IconUtil.encode(clearPipelineIcon), iconDisabled: IconUtil.encode(clearPipelineIcon) }, diff --git a/yarn.lock b/yarn.lock index 9bfc65cdf..a165f6502 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2265,7 +2265,7 @@ __metadata: dependencies: "@elyra/metadata-common": 4.0.1-dev "@elyra/pipeline-editor": 1.13.0 - "@elyra/pipeline-services": 1.12.1 + "@elyra/pipeline-services": 1.13.0 "@elyra/services": 4.0.1-dev "@elyra/ui-components": 4.0.1-dev "@jupyterlab/application": ^4.4.2 @@ -2341,17 +2341,7 @@ __metadata: languageName: node linkType: hard -"@elyra/pipeline-services@npm:1.12.1": - version: 1.12.1 - resolution: "@elyra/pipeline-services@npm:1.12.1" - dependencies: - immer: "npm:^9.0.7" - jsonc-parser: "npm:^3.0.0" - checksum: 69e021c775faabd616e28f8035e2b89e38248e679a7e4d4fb675b60ee41c21732ee5c9fd4475a1e22f995d263e437c540400ad7266038c602999894404d2e6dc - languageName: node - linkType: hard - -"@elyra/pipeline-services@npm:^1.13.0": +"@elyra/pipeline-services@npm:1.13.0, @elyra/pipeline-services@npm:^1.13.0": version: 1.13.0 resolution: "@elyra/pipeline-services@npm:1.13.0" dependencies: