diff --git a/protocol-designer/Makefile b/protocol-designer/Makefile index 14792b22b7b..50b6eca6c4b 100644 --- a/protocol-designer/Makefile +++ b/protocol-designer/Makefile @@ -71,3 +71,33 @@ test: .PHONY: test-cov test-cov: make -C .. test-js-protocol-designer tests=$(tests) test_opts="$(test_opts)" cov_opts="$(cov_opts)" + +CYPRESS_GLOB := "cypress/**/*.{js,ts,md}" + +.PHONY: cy-lint-check +cy-lint-check: cy-lint-eslint-check cy-lint-prettier-check + @echo "Cypress lint check completed." + +.PHONY: cy-lint-fix +cy-lint-fix: cy-lint-eslint-fix cy-lint-prettier-fix + @echo "Cypress lint fix applied." + +.PHONY: cy-lint-eslint-check +cy-lint-eslint-check: + yarn eslint --ignore-path ../.eslintignore $(CYPRESS_GLOB) + @echo "Cypress ESLint check completed." + +.PHONY: cy-lint-eslint-fix +cy-lint-eslint-fix: + yarn eslint --fix --ignore-pattern ../.eslintignore $(CYPRESS_GLOB) + @echo "Cypress ESLint fix applied." + +.PHONY: cy-lint-prettier-check +cy-lint-prettier-check: + yarn prettier --ignore-path ../.eslintignore --check $(CYPRESS_GLOB) + @echo "Cypress Prettier check completed." + +.PHONY: cy-lint-prettier-fix +cy-lint-prettier-fix: + yarn prettier --ignore-path ../.eslintignore --write $(CYPRESS_GLOB) + @echo "Cypress Prettier fix applied." diff --git a/protocol-designer/cypress/e2e/createNew.cy.ts b/protocol-designer/cypress/e2e/createNew.cy.ts new file mode 100644 index 00000000000..be578415bee --- /dev/null +++ b/protocol-designer/cypress/e2e/createNew.cy.ts @@ -0,0 +1,43 @@ +import { + Actions, + Verifications, + runCreateTest, + verifyCreateProtocolPage, +} from '../support/createNew' +import { UniversalActions } from '../support/universalActions' + +describe('The Redesigned Create Protocol Landing Page', () => { + beforeEach(() => { + cy.visit('/') + }) + + it('content and step 1 flow works', () => { + cy.clickCreateNew() + cy.verifyCreateNewHeader() + verifyCreateProtocolPage() + const steps: Array = [ + Verifications.OnStep1, + Verifications.FlexSelected, + UniversalActions.Snapshot, + Actions.SelectOT2, + Verifications.OT2Selected, + UniversalActions.Snapshot, + Actions.SelectFlex, + Verifications.FlexSelected, + UniversalActions.Snapshot, + Actions.Confirm, + Verifications.OnStep2, + Verifications.NinetySixChannel, + UniversalActions.Snapshot, + Actions.GoBack, + Verifications.OnStep1, + Actions.SelectOT2, + Actions.Confirm, + Verifications.OnStep2, + Verifications.NotNinetySixChannel, + UniversalActions.Snapshot, + ] + + runCreateTest(steps) + }) +}) diff --git a/protocol-designer/cypress/e2e/home.cy.js b/protocol-designer/cypress/e2e/home.cy.js deleted file mode 100644 index c2f2bda9f92..00000000000 --- a/protocol-designer/cypress/e2e/home.cy.js +++ /dev/null @@ -1,29 +0,0 @@ -describe('The Home Page', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnnouncementModal() - }) - - it('successfully loads', () => { - cy.title().should('equal', 'Opentrons Protocol Designer') - }) - - it('has the right charset', () => { - cy.document().should('have.property', 'charset').and('eq', 'UTF-8') - }) - - it('displays all the expected text', () => { - cy.contains('Protocol File') - cy.contains('Create New') - cy.contains('Import') - cy.contains('Export') - cy.contains('FILE') - cy.contains('LIQUIDS') - cy.contains('DESIGN') - cy.contains('HELP') - cy.contains('Settings') - cy.contains('Protocol Designer') - }) - - it('displays all the expected images', () => {}) -}) diff --git a/protocol-designer/cypress/e2e/home.cy.ts b/protocol-designer/cypress/e2e/home.cy.ts new file mode 100644 index 00000000000..c5c27b0828b --- /dev/null +++ b/protocol-designer/cypress/e2e/home.cy.ts @@ -0,0 +1,14 @@ +describe('The Home Page', () => { + beforeEach(() => { + cy.visit('/') + }) + + it('successfully loads', () => { + // JTM20241109 + // Normally we want to read our test and understand the validations, + // but I forsee wanting to be able to call these functions from multiple + // places so we will abstract it + cy.verifyFullHeader() + cy.verifyHomePage() + }) +}) diff --git a/protocol-designer/cypress/e2e/import.cy.ts b/protocol-designer/cypress/e2e/import.cy.ts new file mode 100644 index 00000000000..a7fed8c3cd3 --- /dev/null +++ b/protocol-designer/cypress/e2e/import.cy.ts @@ -0,0 +1,14 @@ +import { TestFilePath, getTestFile } from '../support/testFiles' +import { verifyOldProtocolModal } from '../support/import' + +describe('The Import Page', () => { + beforeEach(() => { + cy.visit('/') + }) + + it('successfully loads', () => { + const protocol = getTestFile(TestFilePath.DoItAllV8) + cy.importProtocol(protocol.path) + verifyOldProtocolModal() + }) +}) diff --git a/protocol-designer/cypress/e2e/redesignCreateLanding.cy.js b/protocol-designer/cypress/e2e/redesignCreateLanding.cy.js deleted file mode 100644 index e40336f7f42..00000000000 --- a/protocol-designer/cypress/e2e/redesignCreateLanding.cy.js +++ /dev/null @@ -1,52 +0,0 @@ -describe('The Redesigned Create Protocol Landing Page', () => { - beforeEach(() => { - cy.visit('/') - cy.enableRedesign() - }) - - it('step 1 flow works', () => { - cy.contains('button', 'Create a protocol').click() - cy.contains('p', 'Step 1').should('be.visible').should('be.visible') - cy.contains('p', 'Let’s start with the basics').should('be.visible') - cy.contains('p', 'What kind of robot do you have?').should('be.visible') - // Flex is the default - cy.contains('Opentrons Flex').should( - 'have.css', - 'background-color', - 'rgb(0, 108, 250)' - ) - - cy.contains('Opentrons OT-2').should('be.visible').click() - - cy.contains('Opentrons OT-2').should( - 'have.css', - 'background-color', - 'rgb(0, 108, 250)' - ) - - cy.contains('Opentrons Flex').should('be.visible').click() - - cy.contains('Opentrons Flex').should( - 'have.css', - 'background-color', - 'rgb(0, 108, 250)' - ) - - cy.contains('Confirm').should('be.visible').click() - // a couple validations to validate the click of the confirm button - // takes us to STEP 2 - cy.contains('p', 'Step 2').should('be.visible') - cy.contains('Add a pipette').should('be.visible') - // since Flex was selected, validate the 96 channel pipette is visible - cy.contains('96-Channel').should('be.visible') - cy.contains('Go back').should('be.visible').click() - // validate we are back at the landing page - cy.contains('p', 'Step 1').should('be.visible') - // now select the OT-2 - cy.contains('Opentrons OT-2').click() - cy.contains('Confirm').click() - // validate we are at step 2 - cy.contains('p', 'Step 2').should('be.visible') - cy.contains('96-Channel').should('not.exist') - }) -}) diff --git a/protocol-designer/cypress/e2e/redesignHome.cy.js b/protocol-designer/cypress/e2e/redesignHome.cy.js deleted file mode 100644 index 06c70fba738..00000000000 --- a/protocol-designer/cypress/e2e/redesignHome.cy.js +++ /dev/null @@ -1,14 +0,0 @@ -describe('The Redesigned Home Page', () => { - beforeEach(() => { - cy.visit('/') - cy.enableRedesign() - }) - - it('successfully loads', () => { - cy.title().should('equal', 'Opentrons Protocol Designer') - cy.document().should('have.property', 'charset').and('eq', 'UTF-8') - cy.contains('Welcome to Protocol Designer!') - cy.contains('button', 'Create a protocol').should('be.visible') - cy.contains('label', 'Edit existing protocol').should('be.visible') - }) -}) diff --git a/protocol-designer/cypress/e2e/settings.cy.js b/protocol-designer/cypress/e2e/settings.cy.js deleted file mode 100644 index f2bb737be50..00000000000 --- a/protocol-designer/cypress/e2e/settings.cy.js +++ /dev/null @@ -1,161 +0,0 @@ -describe('The Settings Page', () => { - const exptlSettingText = 'Disable module placement restrictions' - - before(() => { - cy.visit('/') - }) - - it('Verify the settings page', () => { - // displays the announcement modal and clicks "GOT IT!" to close it - cy.closeAnnouncementModal() - - // contains a working settings button - cy.openSettingsPage() - cy.contains('App Settings') - - // contains an information section - cy.get('h3').contains('Information').should('exist') - - // contains version information - cy.contains('Protocol Designer Version').should('exist') - - // contains a hints section - cy.get('h3').contains('Hints').should('exist') - - // contains a privacy section - cy.get('h3').contains('Privacy').should('exist') - - // contains a share settings button in the pivacy section - // It's toggled off by default - cy.contains('Share sessions') - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - // Click it - cy.contains('Share sessions').next().click() - // Now it's toggled on - cy.contains('Share sessions') - .next() - .should('have.attr', 'class') - .and('match', /toggled_on/) - // Click it again - cy.contains('Share sessions').next().click() - // Now it's toggled off again - cy.contains('Share sessions') - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - - // contains an experimental settings section - cy.get('h3').contains('Experimental Settings').should('exist') - - // contains a 'disable module placement restrictions' experimental feature - // It's toggled off by default - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - // Click it - cy.contains(exptlSettingText).next().click() - // We have to confirm this one - cy.contains('Switching on an experimental feature').should('exist') - cy.get('button').contains('Cancel').should('exist') - cy.get('button').contains('Continue').should('exist') - // Abort! - cy.get('button').contains('Cancel').click() - // Still toggled off - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - // Click it again and confirm - cy.contains(exptlSettingText).next().click() - cy.get('button').contains('Continue').click() - // Now it's toggled on - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_on/) - // Click it again - cy.contains(exptlSettingText).next().click() - // We have to confirm to turn it off? - // TODO That doesn't seem right... - cy.get('button').contains('Continue').click() - // Now it's toggled off again - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - - // contains a 'disable module placement restrictions' toggle in the experimental settings card - // It's toggled off by default - cy.contains('Disable module') - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - // Click it - cy.contains('Disable module').next().click() - // We have to confirm this one - cy.contains('Switching on an experimental feature').should('exist') - cy.get('button').contains('Cancel').should('exist') - cy.get('button').contains('Continue').should('exist') - // Abort! - cy.get('button').contains('Cancel').click() - // Still toggled off - cy.contains('Disable module') - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - // Click it again and confirm - cy.contains('Disable module').next().click() - cy.get('button').contains('Continue').click() - // Now it's toggled on - cy.contains('Disable module') - .next() - .should('have.attr', 'class') - .and('match', /toggled_on/) - // Click it again - cy.contains('Disable module').next().click() - // We have to confirm to turn it off - cy.get('button').contains('Continue').click() - // Now it's toggled off again - cy.contains('Disable module') - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - - // PD remembers when we enable things - // Enable a button - // We're not using the privacy button because that - // interacts with analytics libraries, which might - // not be accessible in a headless environment - cy.contains(exptlSettingText).next().click() - cy.get('button').contains('Continue').click() - // Leave the settings page - cy.get("button[id='NavTab_file']").contains('FILE').click() - // Go back to settings - cy.openSettingsPage() - // The toggle is still on - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_on/) - - // PD remembers when we disable things - // Disable a button - // We're not using the privacy button because that - // interacts with analytics libraries, which might - // not be accessible in a headless environment - cy.contains(exptlSettingText).next().click() - cy.get('button').contains('Continue').click() - // Leave the settings page - cy.get("button[id='NavTab_file']").contains('FILE') - // Go back to settings - cy.openSettingsPage() - // The toggle is still off - cy.contains(exptlSettingText) - .next() - .should('have.attr', 'class') - .and('match', /toggled_off/) - }) -}) diff --git a/protocol-designer/cypress/e2e/settings.cy.ts b/protocol-designer/cypress/e2e/settings.cy.ts new file mode 100644 index 00000000000..5ce896aa883 --- /dev/null +++ b/protocol-designer/cypress/e2e/settings.cy.ts @@ -0,0 +1,53 @@ +describe('The Settings Page', () => { + before(() => { + cy.visit('/') + }) + + it('content and toggle state', () => { + // The settings page will not follow the same pattern as create and edit + // The Settings page is simple enough we need not abstract actions and validations into data + + // home page contains a working settings button + cy.openSettingsPage() + cy.verifySettingsPage() + // Timeline editing tips defaults to true + cy.getByAriaLabel('Settings_hotKeys') + .should('exist') + .should('be.visible') + .should('have.attr', 'aria-checked', 'true') + // Share sessions with Opentrons toggle defaults to off + cy.getByTestId('analyticsToggle') + .should('exist') + .should('be.visible') + .find('path[aria-roledescription="ot-toggle-input-off"]') + .should('exist') + // Toggle the share sessions with Opentrons setting + cy.getByTestId('analyticsToggle').click() + cy.getByTestId('analyticsToggle') + .find('path[aria-roledescription="ot-toggle-input-on"]') + .should('exist') + // Navigate away from the settings page + // Then return to see privacy toggle remains toggled on + cy.visit('/') + cy.openSettingsPage() + cy.getByTestId('analyticsToggle').find( + 'path[aria-roledescription="ot-toggle-input-on"]' + ) + // Toggle off editing timeline tips + // Navigate away from the settings page + // Then return to see timeline tips remains toggled on + cy.getByAriaLabel('Settings_hotKeys').click() + cy.getByAriaLabel('Settings_hotKeys').should( + 'have.attr', + 'aria-checked', + 'false' + ) + cy.visit('/') + cy.openSettingsPage() + cy.getByAriaLabel('Settings_hotKeys').should( + 'have.attr', + 'aria-checked', + 'false' + ) + }) +}) diff --git a/protocol-designer/cypress/e2e/sidebar.cy.js b/protocol-designer/cypress/e2e/sidebar.cy.js deleted file mode 100644 index 7b71fc67cc2..00000000000 --- a/protocol-designer/cypress/e2e/sidebar.cy.js +++ /dev/null @@ -1,45 +0,0 @@ -describe('Desktop Navigation', () => { - beforeEach(() => { - cy.visit('/') - cy.closeAnnouncementModal() - }) - - it('contains a working file button', () => { - cy.get("button[id='NavTab_file']") - .contains('FILE') - .parent() - .should('have.prop', 'disabled') - .and('equal', false) - }) - - it('contains a disabled liquids button', () => { - cy.get("button[id='NavTab_liquids']") - .contains('LIQUIDS') - .parent() - .should('have.prop', 'disabled') - }) - - it('contains a disabled design button', () => { - cy.get("button[id='NavTab_design']") - .contains('DESIGN') - .parent() - .should('have.prop', 'disabled') - }) - - it('contains a help button with external link', () => { - cy.get('a') - .contains('HELP') - .parent() - .should('have.prop', 'href') - .and('equal', 'https://support.opentrons.com/s/protocol-designer') - }) - - it('contains a settings button', () => { - cy.get('button').contains('Settings').should('exist') - }) - - it('returns to the file controls when the file button is clicked', () => { - cy.get("button[id='NavTab_file']").contains('FILE').click() - cy.contains('Protocol File') - }) -}) diff --git a/protocol-designer/cypress/e2e/testfiles.cy.ts b/protocol-designer/cypress/e2e/testfiles.cy.ts new file mode 100644 index 00000000000..1d826f4ace4 --- /dev/null +++ b/protocol-designer/cypress/e2e/testfiles.cy.ts @@ -0,0 +1,14 @@ +import { TestFilePath, getTestFile } from '../support/testFiles' + +describe('Validate Test Files', () => { + it('should load and validate all test files', () => { + ;(Object.keys(TestFilePath) as Array).forEach( + key => { + const testFile = getTestFile(TestFilePath[key]) + expect(testFile).to.have.property('path') + expect(testFile).to.have.property('protocolContent') + cy.log(`Loaded and validated: ${testFile.path}`) + } + ) + }) +}) diff --git a/protocol-designer/cypress/e2e/urlNavigation.cy.ts b/protocol-designer/cypress/e2e/urlNavigation.cy.ts new file mode 100644 index 00000000000..ab9eb6c66fe --- /dev/null +++ b/protocol-designer/cypress/e2e/urlNavigation.cy.ts @@ -0,0 +1,18 @@ +describe('URL Navigation', () => { + it('settings', () => { + cy.visit('#/settings') + cy.verifySettingsPage() + }) + it('createNew', () => { + cy.visit('#/createNew') + // directly navigating sends you back to the home page + // TODO: Is this correct? + cy.verifyHomePage() + }) + it('overview', () => { + cy.visit('#/overview') + // directly navigating sends you back to the home page + // TODO: Is this correct? + cy.verifyHomePage() + }) +}) diff --git a/protocol-designer/cypress/fixtures/garbage.txt b/protocol-designer/cypress/fixtures/garbage.txt new file mode 100644 index 00000000000..56993df39af --- /dev/null +++ b/protocol-designer/cypress/fixtures/garbage.txt @@ -0,0 +1 @@ +Not a protocol diff --git a/protocol-designer/cypress/fixtures/invalid_json.json b/protocol-designer/cypress/fixtures/invalid_json.json new file mode 100644 index 00000000000..8bb73b625d0 --- /dev/null +++ b/protocol-designer/cypress/fixtures/invalid_json.json @@ -0,0 +1,7 @@ +{ + "name": "Test Protocol", + "version": 1.0, + "steps": [ + { "step1": "initialize" }, + { "step2": "execute" + ] \ No newline at end of file diff --git a/protocol-designer/cypress/support/commands.js b/protocol-designer/cypress/support/commands.js deleted file mode 100644 index be42909dfe1..00000000000 --- a/protocol-designer/cypress/support/commands.js +++ /dev/null @@ -1,133 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -import 'cypress-file-upload' -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) - -// -// General Custom Commands -// -Cypress.Commands.add('closeAnnouncementModal', () => { - // ComputingSpinner sometimes covers the announcement modal button and prevents the button click - // this will retry until the ComputingSpinner does not exist - cy.get('[data-test="ComputingSpinner"]', { timeout: 30000 }).should( - 'not.exist' - ) - cy.get('button') - .contains('Got It!') - .should('be.visible') - .click({ force: true }) -}) - -Cypress.Commands.add('enableRedesign', () => { - cy.window().then(win => { - win.enablePrereleaseMode() - }) - // You select using the mouse thing on the upper right corner of - // The inspector window - // Use the element name and what's inside it - cy.get('button') - .contains('Got It!') - .should('be.visible') - .click({ force: true }) - cy.openSettingsPage() - // - cy.contains('Enable redesign').next().click() - cy.contains('button', 'Continue').click() -}) -// -// File Page Actions -// -Cypress.Commands.add('openFilePage', () => { - cy.get('button[id="NavTab_file"]').contains('FILE').click() -}) - -// -// Pipette Page Actions -// -Cypress.Commands.add( - 'choosePipettes', - (left_pipette_selector, right_pipette_selector) => { - cy.get('[id="PipetteSelect_left"]').click() - cy.get(left_pipette_selector).click() - cy.get('[id="PipetteSelect_right"]').click() - cy.get(right_pipette_selector).click() - } -) - -Cypress.Commands.add('selectTipRacks', (left, right) => { - if (left) { - cy.get("select[name*='left.tiprack']").select(left) - } - if (right) { - cy.get("select[name*='right.tiprack']").select(right) - } -}) - -// -// Liquid Page Actions -// -Cypress.Commands.add( - 'addLiquid', - (liquidName, liquidDesc, serializeLiquid = false) => { - cy.get('button').contains('New Liquid').click() - cy.get("input[name='name']").type(liquidName) - cy.get("input[name='description']").type(liquidDesc) - if (serializeLiquid) { - // force option used because checkbox is hidden - cy.get("input[name='serialize']").check({ force: true }) - } - cy.get('button').contains('save').click() - } -) - -// -// Design Page Actions -// -Cypress.Commands.add('openDesignPage', () => { - cy.get('button[id="NavTab_design"]').contains('DESIGN').parent().click() -}) -Cypress.Commands.add('addStep', stepName => { - cy.get('button').contains('Add Step').click() - cy.get('button').contains(stepName, { matchCase: false }).click() -}) - -// -// Settings Page Actions -// -Cypress.Commands.add('openSettingsPage', () => { - cy.get('button').contains('Settings').click() -}) - -// Advance Settings for Transfer Steps - -// Pre-wet tip enable/disable -Cypress.Commands.add('togglePreWetTip', () => { - cy.get('input[name="preWetTip"]').click({ force: true }) -}) - -// Mix settings select/deselect -Cypress.Commands.add('mixaspirate', () => { - cy.get('input[name="aspirate_mix_checkbox"]').click({ force: true }) -}) diff --git a/protocol-designer/cypress/support/commands.ts b/protocol-designer/cypress/support/commands.ts new file mode 100644 index 00000000000..f474b343f81 --- /dev/null +++ b/protocol-designer/cypress/support/commands.ts @@ -0,0 +1,275 @@ +import 'cypress-file-upload' +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + getByTestId: (testId: string) => Cypress.Chainable> + getByAriaLabel: (value: string) => Cypress.Chainable> + verifyHeader: () => Cypress.Chainable + verifyFullHeader: () => Cypress.Chainable + verifyCreateNewHeader: () => Cypress.Chainable + clickCreateNew: () => Cypress.Chainable + closeAnnouncementModal: () => Cypress.Chainable + verifyHomePage: () => Cypress.Chainable + importProtocol: (protocolFile: string) => Cypress.Chainable + verifyImportPageOldProtocol: () => Cypress.Chainable + openFilePage: () => Cypress.Chainable + choosePipettes: ( + left_pipette_selector: string, + right_pipette_selector: string + ) => Cypress.Chainable + selectTipRacks: (left: string, right: string) => Cypress.Chainable + addLiquid: ( + liquidName: string, + liquidDesc: string, + serializeLiquid?: boolean + ) => Cypress.Chainable + openDesignPage: () => Cypress.Chainable + addStep: (stepName: string) => Cypress.Chainable + openSettingsPage: () => Cypress.Chainable + verifySettingsPage: () => Cypress.Chainable + verifyCreateNewPage: () => Cypress.Chainable + togglePreWetTip: () => Cypress.Chainable + mixaspirate: () => Cypress.Chainable + } + } +} + +// Only Header, Home, and Settings page actions are here +// Due to their simplicity +// Create and Import page actions are in their respective files + +// +// Content +// + +export enum GeneralContent { + // General Content + SiteTitle = 'Opentrons Protocol Designer', + Opentrons = 'Opentrons', + CharSet = 'UTF-8', + Header = 'Protocol Designer', + CreateNew = 'Create new', + Import = 'Import', +} + +export enum HomeContent { + Welcome = 'Welcome to Protocol Designer!', + CreateProtocol = 'Create a protocol', + EditProtocol = 'Edit existing protocol', +} + +// +// Locators +// + +// Naming Convention: +// Enum Name specifies the page or page section +// Enum Values specify the locator string + +// https://docs.cypress.io/app/core-concepts/best-practices#Selecting-Elements +// best practice is to first use a simple cy.contains() +// this sometimes requires a .first() or .last() to be added +// that may prove brittle, but we will give it a go +// next try aria-* attributes +// finally add a data-testid attribute (then use getByTestId custom command) + +export enum HeaderLocators { + Import = 'Import', + CreateNew = 'Create new', + SettingsDataTestid = 'SettingsIconButton', +} + +export enum HomeLocators { + CreateProtocol = 'Create a protocol', + EditProtocol = 'label', + Settings = 'SettingsIconButton', + PrivacyPolicy = 'a[href="https://opentrons.com/privacy-policy"]', + EULA = 'a[href="https://opentrons.com/eula"]', +} + +// +// General Custom Commands +// + +Cypress.Commands.add( + 'getByTestId', + (testId: string): Cypress.Chainable> => { + return cy.get(`[data-testid="${testId}"]`) + } +) + +Cypress.Commands.add( + 'getByAriaLabel', + (value: string): Cypress.Chainable> => { + return cy.get(`[aria-label="${value}"]`) + } +) + +// +// Header Verifications +// + +const verifyUniversal = (): void => { + cy.title().should('equal', GeneralContent.SiteTitle) + cy.document() + .should('have.property', 'charset') + .and('eq', GeneralContent.CharSet) + cy.contains(GeneralContent.Opentrons).should('be.visible') + cy.contains(GeneralContent.Header).should('be.visible') + cy.contains(HeaderLocators.Import).should('be.visible') + // settings and create new are NOT present on #/createNew +} + +Cypress.Commands.add('verifyFullHeader', () => { + verifyUniversal() + cy.contains(HeaderLocators.CreateNew).should('be.visible') + cy.getByTestId(HeaderLocators.SettingsDataTestid).should('be.visible') +}) + +Cypress.Commands.add('verifyCreateNewHeader', () => { + verifyUniversal() +}) + +// +// Home Page +// + +Cypress.Commands.add('verifyHomePage', () => { + cy.contains('Welcome to Protocol Designer!') + cy.contains('button', 'Create a protocol').should('be.visible') + cy.contains('label', 'Edit existing protocol').should('be.visible') + cy.getByTestId('SettingsIconButton').should('be.visible') + cy.get('a[href="https://opentrons.com/privacy-policy"]') + .should('exist') + .and('be.visible') + cy.get('a[href="https://opentrons.com/eula"]') + .should('exist') + .and('be.visible') +}) + +Cypress.Commands.add('clickCreateNew', () => { + cy.contains(HomeLocators.CreateProtocol).click() +}) + +// +// Header Import +// + +Cypress.Commands.add('importProtocol', (protocolFilePath: string) => { + cy.contains('Import').click() + cy.get('input[type="file"]') + .last() + .selectFile(protocolFilePath, { force: true }) +}) + +// +// Settings Page Actions +// + +Cypress.Commands.add('openSettingsPage', () => { + cy.getByTestId('SettingsIconButton').click() +}) + +Cypress.Commands.add('verifySettingsPage', () => { + cy.verifyFullHeader() + cy.contains('Settings').should('exist').should('be.visible') + cy.contains('App settings').should('exist').should('be.visible') + cy.contains('Privacy').should('exist').should('be.visible') + cy.contains('Share sessions with Opentrons') + .should('exist') + .should('be.visible') + cy.getByAriaLabel('Settings_hotKeys').should('exist').should('be.visible') + cy.getByTestId('analyticsToggle').should('exist').should('be.visible') +}) + +/// ///////////////////////////////////////////////////////////////// +// Legacy Code Section +// This code is deprecated and should be removed +// as soon as possible once it's no longer needed +// as a reference during test migration. +/// ///////////////////////////////////////////////////////////////// + +Cypress.Commands.add('closeAnnouncementModal', () => { + // ComputingSpinner sometimes covers the announcement modal button and prevents the button click + // this will retry until the ComputingSpinner does not exist + cy.get('[data-test="ComputingSpinner"]', { timeout: 30000 }).should( + 'not.exist' + ) + cy.get('button') + .contains('Got It!') + .should('be.visible') + .click({ force: true }) +}) + +// +// File Page Actions +// + +Cypress.Commands.add('openFilePage', () => { + cy.get('button[id="NavTab_file"]').contains('FILE').click() +}) + +// +// Pipette Page Actions +// + +Cypress.Commands.add( + 'choosePipettes', + (leftPipetteSelector, rightPipetteSelector) => { + cy.get('[id="PipetteSelect_left"]').click() + cy.get(leftPipetteSelector).click() + cy.get('[id="PipetteSelect_right"]').click() + cy.get(rightPipetteSelector).click() + } +) + +Cypress.Commands.add('selectTipRacks', (left, right) => { + if (left.length > 0) { + cy.get("select[name*='left.tiprack']").select(left) + } + if (right.length > 0) { + cy.get("select[name*='right.tiprack']").select(right) + } +}) + +// +// Liquid Page Actions +// +Cypress.Commands.add( + 'addLiquid', + (liquidName, liquidDesc, serializeLiquid = false) => { + cy.get('button').contains('New Liquid').click() + cy.get("input[name='name']").type(liquidName) + cy.get("input[name='description']").type(liquidDesc) + if (serializeLiquid) { + // force option used because checkbox is hidden + cy.get("input[name='serialize']").check({ force: true }) + } + cy.get('button').contains('save').click() + } +) + +// +// Design Page Actions +// + +Cypress.Commands.add('openDesignPage', () => { + cy.get('button[id="NavTab_design"]').contains('DESIGN').parent().click() +}) +Cypress.Commands.add('addStep', stepName => { + cy.get('button').contains('Add Step').click() + cy.get('button').contains(stepName, { matchCase: false }).click() +}) + +// Advance Settings for Transfer Steps + +// Pre-wet tip enable/disable +Cypress.Commands.add('togglePreWetTip', () => { + cy.get('input[name="preWetTip"]').click({ force: true }) +}) + +// Mix settings select/deselect +Cypress.Commands.add('mixaspirate', () => { + cy.get('input[name="aspirate_mix_checkbox"]').click({ force: true }) +}) diff --git a/protocol-designer/cypress/support/createNew.ts b/protocol-designer/cypress/support/createNew.ts new file mode 100644 index 00000000000..b5fde778339 --- /dev/null +++ b/protocol-designer/cypress/support/createNew.ts @@ -0,0 +1,131 @@ +import { executeUniversalAction, UniversalActions } from './universalActions' +import { isEnumValue } from './utils' + +export enum Actions { + SelectFlex = 'Select Opentrons Flex', + SelectOT2 = 'Select Opentrons OT-2', + Confirm = 'Confirm', + GoBack = 'Go back', +} + +export enum Verifications { + OnStep1 = 'On Step 1 page.', + OnStep2 = 'On Step 2 page.', + FlexSelected = 'Opentrons Flex selected.', + OT2Selected = 'Opentrons OT-2 selected.', + NinetySixChannel = '96-Channel option is available.', + NotNinetySixChannel = '96-Channel option is not available.', +} + +export enum Content { + Step1Title = 'Step 1', + Step2Title = 'Step 2', + AddPipette = 'Add a pipette', + NinetySixChannel = '96-Channel', + GoBack = 'Go back', + Confirm = 'Confirm', + OpentronsFlex = 'Opentrons Flex', + OpentronsOT2 = 'Opentrons OT-2', + LetsGetStarted = 'Let’s start with the basics', + WhatKindOfRobot = 'What kind of robot do you have?', +} + +export enum Locators { + Confirm = 'button:contains("Confirm")', + GoBack = 'button:contains("Go back")', + Step1Indicator = 'p:contains("Step 1")', + Step2Indicator = 'p:contains("Step 2")', + FlexOption = 'button:contains("Opentrons Flex")', + OT2Option = 'button:contains("Opentrons OT-2")', + NinetySixChannel = 'div:contains("96-Channel")', +} + +const executeAction = (action: Actions | UniversalActions): void => { + if (isEnumValue([UniversalActions], [action])) { + executeUniversalAction(action as UniversalActions) + return + } + + switch (action) { + case Actions.SelectFlex: + cy.contains(Content.OpentronsFlex).should('be.visible').click() + break + case Actions.SelectOT2: + cy.contains(Content.OpentronsOT2).should('be.visible').click() + break + case Actions.Confirm: + cy.contains(Content.Confirm).should('be.visible').click() + break + case Actions.GoBack: + cy.contains(Content.GoBack).should('be.visible').click() + break + default: + throw new Error(`Unrecognized action: ${action as string}`) + } +} + +const verifyStep = (verification: Verifications): void => { + switch (verification) { + case Verifications.OnStep1: + cy.contains(Content.Step1Title).should('be.visible') + break + case Verifications.OnStep2: + cy.contains(Content.Step2Title).should('be.visible') + cy.contains(Content.AddPipette).should('be.visible') + break + case Verifications.FlexSelected: + cy.contains(Content.OpentronsFlex).should( + 'have.css', + 'background-color', + 'rgb(0, 108, 250)' + ) + break + case Verifications.OT2Selected: + cy.contains(Content.OpentronsOT2).should( + 'have.css', + 'background-color', + 'rgb(0, 108, 250)' + ) + break + case Verifications.NinetySixChannel: + cy.contains(Content.NinetySixChannel).should('be.visible') + break + case Verifications.NotNinetySixChannel: + cy.contains(Content.NinetySixChannel).should('not.exist') + break + default: + throw new Error( + `Unrecognized verification: ${verification as Verifications}` + ) + } +} + +export const runCreateTest = ( + steps: Array +): void => { + const enumsToCheck = [Actions, Verifications, UniversalActions] + + if (!isEnumValue(enumsToCheck, steps)) { + throw new Error('One or more steps are unrecognized.') + } + + steps.forEach(step => { + if (isEnumValue([Actions], step)) { + executeAction(step as Actions) + } else if (isEnumValue([Verifications], step)) { + verifyStep(step as Verifications) + } else if (isEnumValue([UniversalActions], step)) { + executeAction(step as UniversalActions) + } + }) +} + +export const verifyCreateProtocolPage = (): void => { + // Verify step 1 and page content + cy.contains(Content.Step1Title).should('exist').should('be.visible') + cy.contains(Content.LetsGetStarted).should('exist').should('be.visible') + cy.contains(Content.WhatKindOfRobot).should('exist').should('be.visible') + cy.contains(Content.OpentronsFlex).should('exist').should('be.visible') + cy.contains(Content.OpentronsOT2).should('exist').should('be.visible') + cy.contains(Content.Confirm).should('exist').should('be.visible') +} diff --git a/protocol-designer/cypress/support/e2e.js b/protocol-designer/cypress/support/e2e.ts similarity index 100% rename from protocol-designer/cypress/support/e2e.js rename to protocol-designer/cypress/support/e2e.ts diff --git a/protocol-designer/cypress/support/import.ts b/protocol-designer/cypress/support/import.ts new file mode 100644 index 00000000000..2c1976a3778 --- /dev/null +++ b/protocol-designer/cypress/support/import.ts @@ -0,0 +1,23 @@ +// #/overview + +export enum Content { + OldProtocolMessage = 'Your protocol was made in an older version of Protocol Designer', + ConfirmButton = 'Confirm', + CancelButton = 'Cancel', +} + +export enum Locators { + ModalShellArea = '[aria-label="ModalShell_ModalArea"]', +} + +export const verifyOldProtocolModal = (): void => { + cy.get(Locators.ModalShellArea) + .should('exist') + .should('be.visible') + .within(() => { + cy.contains(Content.OldProtocolMessage).should('exist').and('be.visible') + cy.contains(Content.ConfirmButton).should('be.visible') + cy.contains(Content.CancelButton).should('be.visible') + cy.contains(Content.ConfirmButton).click() + }) +} diff --git a/protocol-designer/cypress/support/testFiles.ts b/protocol-designer/cypress/support/testFiles.ts new file mode 100644 index 00000000000..46efafaeb46 --- /dev/null +++ b/protocol-designer/cypress/support/testFiles.ts @@ -0,0 +1,126 @@ +import fs from 'fs' +import path from 'path' +import type { + ProtocolFileV3, + ProtocolFileV4, + ProtocolFileV5, + ProtocolFileV6, + ProtocolFileV7, + ProtocolFileV8, +} from '@opentrons/shared-data' + +import { isEnumValue } from './utils' + +const loadFileContent = (filePath: string): any => { + try { + const fileExtension = path.extname(filePath) + if (fileExtension === '.json') { + const rawContent = fs.readFileSync(filePath, 'utf8') + try { + return JSON.parse(rawContent) + } catch (parseError) { + return rawContent + } + } else { + return fs.readFileSync(filePath, 'utf8') + } + } catch (error) { + return `Error loading file: ${error.message}` + } +} + +// //////////////////////////////////////////// +// This is the data section where we map all the protocol files +// We map to have IDE autocompletion of all the protocol files we have available to test with +// //////////////////////////////////////////// + +export enum TestFilePath { + // Define the path relative to the protocol-designer directory + // PD root project fixtures + DoItAllV3MigratedToV6 = 'fixtures/protocol/6/doItAllV3MigratedToV6.json', + Mix_6_0_0 = 'fixtures/protocol/6/mix_6_0_0.json', + PreFlexGrandfatheredProtocolV6 = 'fixtures/protocol/6/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', + DoItAllV4MigratedToV6 = 'fixtures/protocol/6/doItAllV4MigratedToV6.json', + Example_1_1_0V6 = 'fixtures/protocol/6/example_1_1_0MigratedFromV1_0_0.json', + DoItAllV3MigratedToV7 = 'fixtures/protocol/7/doItAllV3MigratedToV7.json', + Mix_7_0_0 = 'fixtures/protocol/7/mix_7_0_0.json', + DoItAllV7 = 'fixtures/protocol/7/doItAllV7.json', + DoItAllV4MigratedToV7 = 'fixtures/protocol/7/doItAllV4MigratedToV7.json', + Example_1_1_0V7 = 'fixtures/protocol/7/example_1_1_0MigratedFromV1_0_0.json', + MinimalProtocolOldTransfer = 'fixtures/protocol/1/minimalProtocolOldTransfer.json', + Example_1_1_0 = 'fixtures/protocol/1/example_1_1_0.json', + PreFlexGrandfatheredProtocolV1 = 'fixtures/protocol/1/preFlexGrandfatheredProtocol.json', + DoItAllV1 = 'fixtures/protocol/1/doItAll.json', + PreFlexGrandfatheredProtocolV4 = 'fixtures/protocol/4/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', + DoItAllV3V4 = 'fixtures/protocol/4/doItAllV3.json', + DoItAllV4V4 = 'fixtures/protocol/4/doItAllV4.json', + NinetySixChannelFullAndColumn = 'fixtures/protocol/8/ninetySixChannelFullAndColumn.json', + NewAdvancedSettingsAndMultiTemp = 'fixtures/protocol/8/newAdvancedSettingsAndMultiTemp.json', + Example_1_1_0V8 = 'fixtures/protocol/8/example_1_1_0MigratedToV8.json', + DoItAllV4MigratedToV8 = 'fixtures/protocol/8/doItAllV4MigratedToV8.json', + DoItAllV8 = 'fixtures/protocol/8/doItAllV8.json', + DoItAllV3MigratedToV8 = 'fixtures/protocol/8/doItAllV3MigratedToV8.json', + Mix_8_0_0 = 'fixtures/protocol/8/mix_8_0_0.json', + DoItAllV7MigratedToV8 = 'fixtures/protocol/8/doItAllV7MigratedToV8.json', + MixSettingsV5 = 'fixtures/protocol/5/mixSettings.json', + DoItAllV5 = 'fixtures/protocol/5/doItAllV5.json', + BatchEditV5 = 'fixtures/protocol/5/batchEdit.json', + MultipleLiquidsV5 = 'fixtures/protocol/5/multipleLiquids.json', + PreFlexGrandfatheredProtocolV5 = 'fixtures/protocol/5/preFlexGrandfatheredProtocolMigratedFromV1_0_0.json', + DoItAllV3V5 = 'fixtures/protocol/5/doItAllV3.json', + TransferSettingsV5 = 'fixtures/protocol/5/transferSettings.json', + Mix_5_0_X = 'fixtures/protocol/5/mix_5_0_x.json', + Example_1_1_0V5 = 'fixtures/protocol/5/example_1_1_0MigratedFromV1_0_0.json', + // cypress fixtures + GarbageTextFile = 'cypress/fixtures/garbage.txt', + Generic96TipRack200ul = 'cypress/fixtures/generic_96_tiprack_200ul.json', + InvalidLabware = 'cypress/fixtures/invalid_labware.json', + InvalidTipRack = 'cypress/fixtures/invalid_tip_rack.json', + InvalidTipRackTxt = 'cypress/fixtures/invalid_tip_rack.txt', + InvalidJson = 'cypress/fixtures/invalid_json.json', +} + +export type TestProtocol = + | ProtocolFileV3 + | ProtocolFileV4 + | ProtocolFileV5 + | ProtocolFileV6 + | ProtocolFileV7 + | ProtocolFileV8 + +export type TestFileOther = string + +export interface TestFile { + path: string + protocolContent: TestProtocol | TestFileOther +} + +export const getTestFile = (id: TestFilePath): TestFile => { + if (!isEnumValue([TestFilePath], [id])) { + throw new Error(`Invalid file path: ${id as string}`) + } + + const filePath = id.valueOf() + const content = loadFileContent(filePath) + const fileExtension = path.extname(id) + + let typedContent: TestProtocol | TestFileOther + + if (fileExtension === '.json') { + if (typeof content === 'object') { + // TODO: logic here to cast to the correct protocol version + typedContent = content + } else { + typedContent = content as string + } + } else if (fileExtension === '.txt') { + typedContent = content as string + } else { + typedContent = content + } + + return { + path: filePath, + protocolContent: typedContent, + } +} diff --git a/protocol-designer/cypress/support/universalActions.ts b/protocol-designer/cypress/support/universalActions.ts new file mode 100644 index 00000000000..f98abb442bc --- /dev/null +++ b/protocol-designer/cypress/support/universalActions.ts @@ -0,0 +1,16 @@ +export enum UniversalActions { + Snapshot = 'Take a visual testing snapshot', + // Other examples of things that could be universal actions: + // Clear the cache +} + +export const executeUniversalAction = (action: UniversalActions): void => { + switch (action) { + case UniversalActions.Snapshot: + // Placeholder for future implementation of visual testing snapshot + // Currently, this does nothing + break + default: + throw new Error(`Unrecognized universal action: ${action as string}`) + } +} diff --git a/protocol-designer/cypress/support/utils.ts b/protocol-designer/cypress/support/utils.ts new file mode 100644 index 00000000000..33be46e7c7d --- /dev/null +++ b/protocol-designer/cypress/support/utils.ts @@ -0,0 +1,11 @@ +export const isEnumValue = ( + enumObjs: T[], + values: unknown | unknown[] +): boolean => { + const valueArray = Array.isArray(values) ? values : [values] + return valueArray.every(value => + enumObjs.some(enumObj => + Object.values(enumObj).includes(value as T[keyof T]) + ) + ) +} diff --git a/protocol-designer/tsconfig.cypress.json b/protocol-designer/tsconfig.cypress.json new file mode 100644 index 00000000000..a23471dd5d0 --- /dev/null +++ b/protocol-designer/tsconfig.cypress.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "cypress", + "outDir": "cypress/lib", + "types": ["cypress", "node"], + "composite": true + }, + "include": [ + "cypress", + "cypress/**/*.ts" + ] + } + \ No newline at end of file diff --git a/protocol-designer/tsconfig.json b/protocol-designer/tsconfig.json index 6a2a9eac5bd..cca2f020439 100644 --- a/protocol-designer/tsconfig.json +++ b/protocol-designer/tsconfig.json @@ -12,6 +12,9 @@ }, { "path": "../step-generation" + }, + { + "path": "./tsconfig.cypress.json" } ], "compilerOptions": {