// Marks this file as an ES module (rather than a global script) so its // top-level consts don't collide, under the TS type-checker, with the same // names declared in other spec files — see e.g. viewer-labels.cy.ts. export {} const username = Cypress.env('username') const password = Cypress.env('password') const hostUrl = Cypress.env('hosturl') const appLocation = Cypress.env('appLocation') const longerCommandTimeout = Cypress.env('longerCommandTimeout') const serverType = Cypress.env('serverType') const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`) // Fixture: MPE_X_SEARCH, a 1000-row demo table built by the mock data builder // (sas/mocks/sasjs/services/admin/makedata.js). It is deterministic, so every // count below can be derived by hand: // - 1000 rows, PK 1001-2000, columns in demo order: PRIMARY_KEY_FIELD, // SITE_NAME, VESSEL, SAMPLE_COUNT, DEPTH_M, SPECIES, CRUISE_DATE, // EXPEDITION_ID, NOTES // - 300 rows have a site name containing "Trench" (case sensitive, so // "trench" matches nothing) // - 280 rows contain "Halcyon": the site Halcyon Trench (100 rows) plus the // vessel RV Halcyon (200 rows) less the 20 rows that are both // - 3 rows carry "siphonophore" in the middle of their NOTES text, in the // sites Oceanus Rise, Nereid Trench and Halcyon Trench // - 100 rows observed Vampire squid (species is picked by (site+vessel)%10) // - 100 rows are at Oceanus Rise (site is picked by (n%50)/5) // - 9 rows have an expedition id starting EXP-000 (EXP-0001..EXP-0009) // - the first row (PK 1001) is the only one with EXPEDITION_ID EXP-0001 // - 12 rows were sounded at exactly 1000m (DEPTH_M = 1000 + (n%90)*37) // - 2 rows have SAMPLE_COUNT 4210 (the only numeric value that is not a // primary key, a depth or a date) // - PK 2000 is the last row of the table // - 200 rows were surveyed by RV Halcyon, 60 of them at a Trench site const demoTable = 'mpe_x_search' // Full-table search: the viewer's search box searches EVERY column of the // table, character columns by case-sensitive CONTAINS and numeric columns by // exact equality (see %mp_searchdata in the SASjs macro core, and // viewdata.sas / services/public/viewdata.js). // // The whole walkthrough is one test - the table is opened once and every // variation is a search in place, which is how a user works and what makes the // recording read as a demo rather than a set of page loads. // // Some searches legitimately match nothing: they pin the case sensitivity, the // exact-match numeric semantics and the "no data found" handling. Those steps // are wrapped in negativeStep() and can be skipped when recording the demo, so // the video only ever shows searches that return rows: // // npx cypress run --browser electron \ // --spec cypress/e2e/full-table-search.cy.ts --env skipNegative=true // // CI runs the file with no env, so every step runs there. const skipNegative = `${Cypress.env('skipNegative')}` === 'true' const negativeStep = (step: () => void) => { if (!skipNegative) step() } context('full table search tests: ', function () { this.beforeAll(() => { cy.visit(`${hostUrl}/SASLogon/logout`, { timeout: longerCommandTimeout }) // forceLicenceKey=true: applies a valid licence key instead of relying on // an earlier spec in the same run having already done so. cy.loginAndUpdateValidKey(true) }) this.beforeEach(() => { cy.visit(hostUrl + appLocation, { timeout: longerCommandTimeout }) // Visit 'home' first, matching filtering.cy.ts/viewer-labels.cy.ts — going // straight to 'view/data' skips the startup-data fetch 'home' triggers, // which leaves the nav tree empty in slower environments. visitPage('home') visitPage('view/data') }) it('searches the whole table from the viewer', () => { // Full view: every column, and the true row count for the table. openTableFromTree(libraryToOpenIncludes, demoTable) assertRowCount('1,000') assertGridContains(['Nereid Trench', 'RV Pelagia', 'Vampire squid']) // Partial character search: "siphonophore" only ever appears in the middle // of the NOTES text, so a match proves the search is not column-specific. // Three rows is narrow enough that the grid grows a horizontal scrollbar // and clips NOTES - the column the match is actually in - so scroll it // into view, assert on what is now on screen, then put the grid back. searchFor('siphonophore') assertRowCount('3') scrollGridTo('right') pauseForDemo() assertGridContains(['Oceanus Rise', 'Nereid Trench', 'Halcyon Trench']) assertGridExcludes(['Kraken Hollow', 'Vostok Deep']) assertGridContains(['bioluminescent siphonophore was filmed here']) scrollGridTo('left') // A search spans every column at once: "Halcyon" is both a site (Halcyon // Trench) and a vessel (RV Halcyon), and the 280 matches are the union. searchFor('Halcyon') assertRowCount('280') assertGridContains(['Halcyon Trench', 'RV Halcyon']) // A multi-word site name: the search is not limited to single tokens. searchFor('Oceanus Rise') assertRowCount('100') assertGridContains(['Oceanus Rise']) // Multi-word values work the same way - species names are two words. searchFor('Vampire squid') assertRowCount('100') assertGridContains(['Vampire squid']) assertGridExcludes(['Yeti crab']) // A partial value in a short code column: EXP-000 matches the first nine // expedition ids. searchFor('EXP-000') assertRowCount('9') assertGridContains(['1001', '1009']) // A value that is unique in the table: the first row's expedition id. searchFor('EXP-0001') assertRowCount('1') assertGridContains(['1001']) // Character search is case sensitive: the same word in the wrong case // matches nothing. negativeStep(() => { searchFor('trench') assertNoData() }) searchFor('Trench') assertRowCount('300') assertGridContains(['Halcyon Trench']) assertGridExcludes(['Kraken Hollow']) // Numeric search is an exact match across the numeric columns. setNumericSearch(true) searchFor('4210') assertRowCount('2') assertGridContains(['1221', '1781', '4210']) // The numeric search covers every numeric column, not just the primary // key: 12 rows were sounded at exactly 1000m. searchFor('1000') assertRowCount('12') assertGridContains(['1091']) // ...and an exact primary key is a single row. searchFor('2000') assertRowCount('1') assertGridContains(['2000']) negativeStep(() => { searchFor('421') assertNoData() }) // Back to a character search: clearing the box restores the full table. setNumericSearch(false) searchFor('') assertRowCount('1,000') // A search runs inside the current filter, not the whole table. openFilterPopup(() => { setFilterWithValue('VESSEL', 'RV Halcyon', () => { assertRowCount('200') checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`) searchFor('Trench') assertRowCount('60') assertGridContains(['Halcyon Trench']) checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`) // Clearing the search leaves the filter in place. searchFor('') assertRowCount('200') checkInfoBarIncludes(`(( VESSEL = 'RV Halcyon' ))`) }) }) negativeStep(() => { searchFor('not-in-this-table') assertNoData() }) }) }) const visitPage = (url: string) => { cy.visit(`${hostUrl}${appLocation}/#/${url}`) } // Recording-only linger. A beat's row count is the moment its result is on // screen, so that is where a demo recording pauses to let it be read. CI runs // with no env, so demoPauseMs is 0 and the spec contains no waits at all: // // npx cypress run --browser electron --spec cypress/e2e/full-table-search.cy.ts \ // --env skipNegative=true,demoPause=1500 const demoPauseMs = Number(Cypress.env('demoPause')) || 0 // Linger for a recording. A no-op unless demoPause is set, which only the // recording run does - CI passes no env. const pauseForDemo = () => { if (demoPauseMs > 0) cy.wait(demoPauseMs) } // The row/column count the viewer shows next to the table name, e.g. // "(1,000 rows, 9 cols)". The viewer pluralises: a single-row result reads // "(1 row, 9 cols)". const assertRowCount = (rows: string) => { cy.get('.title-col p', { timeout: longerCommandTimeout }).should( ($el: any) => { const unit = rows === '1' ? 'row' : 'rows' expect($el.text().replace(/\s+/g, ' ')).to.contain(`(${rows} ${unit},`) } ) pauseForDemo() } // The grid body is a Handsontable .wtHolder, and a narrow result is wider than // the grid viewport, which clips the rightmost column (NOTES). Setting // scrollLeft is enough - Handsontable keeps the header clone in step - and the // assertion right after proves the scroll landed rather than assuming it. The // browser clamps scrollLeft to (scrollWidth - clientWidth), so that is what // scrolling right has to land on. const scrollGridTo = (position: 'left' | 'right') => { cy.get('#hotTable .wtHolder') .first() .then(($holder: any) => { const el = $holder[0] const maxScroll = el.scrollWidth - el.clientWidth el.scrollLeft = position === 'right' ? el.scrollWidth : 0 if (position === 'right') { // This step only means anything if the grid really is clipping a // column - fail loudly rather than silently doing nothing. expect(el.scrollWidth).to.be.greaterThan(el.clientWidth) expect(el.scrollLeft).to.equal(maxScroll) } else { expect(el.scrollLeft).to.equal(0) } }) } // A search that matches nothing replaces the grid with the viewer's "no data" // panel - and must not raise a request error. const assertNoData = () => { cy.get('.noData h3', { timeout: longerCommandTimeout }).should( 'contain.text', 'No data found with given conditions' ) } const assertGridContains = (values: string[]) => { cy.get('#hotTable', { timeout: longerCommandTimeout }).should( ($grid: any) => { const text = $grid.text().replace(/\s+/g, ' ') for (const value of values) expect(text).to.contain(value) } ) } const assertGridExcludes = (values: string[]) => { cy.get('#hotTable', { timeout: longerCommandTimeout }).should( ($grid: any) => { const text = $grid.text().replace(/\s+/g, ' ') for (const value of values) expect(text).to.not.contain(value) } ) } const searchFor = (value: string) => { cy.get('input[name="search-input"]', { timeout: longerCommandTimeout }) .should('be.visible') .clear() if (value.length > 0) cy.get('input[name="search-input"]').type(value) cy.get('input[name="search-input"]').type('{enter}') } const setNumericSearch = (enabled: boolean) => { cy.get('input[name="numeric_check"]', { timeout: longerCommandTimeout }).then( (checkbox: any) => { if (checkbox[0].checked !== enabled) checkbox[0].click() } ) } const checkInfoBarIncludes = (text: string) => { cy.get('.infoBar b', { timeout: longerCommandTimeout }).should(($el: any) => { expect($el.text().toLowerCase()).to.contain(text.toLowerCase()) }) } // Opens the viewer's options menu and clicks through to the filter modal. const openFilterPopup = (callback?: any) => { cy.get('.filterSide', { timeout: longerCommandTimeout }).first().click() cy.get('.dropdown-menu', { timeout: longerCommandTimeout }) .contains('Filter') .click() cy.get('#vals_var_id0_0', { timeout: longerCommandTimeout }).should('exist') if (callback) callback() } // Fills the first clause of the filter modal (variable, then value) and // submits it - the viewer stores the clause, reloads the table with the new // filter RK and shows the generated WHERE text in the info bar. const setFilterWithValue = ( variableValue: string, valueString: string, callback?: any ) => { cy.get('#vals_var_id0_0').type(variableValue) cy.get('#datalist_vals_var_id0_0 option') .contains(variableValue) .click({ force: true }) // The soft-select closes its suggestion list shortly after a selection - // wait for that closed state instead of sleeping. cy.get('#datalist_vals_var_id0_0').should('have.class', 'hidden') cy.get('#vals_0_0').type(valueString) closeAutocompleteList('#vals_0_0') cy.get('#datalist_vals_0_0').should('have.class', 'hidden') cy.get('.filter-modal button[aria-label="Ok"]').click() if (callback) callback() } // The soft-select inputs open a suggestion list that drops a transparent // click-catcher over the modal until it closes, so send the same Escape key a // user would press. `force` is needed because the input sits underneath that // click-catcher while the list is open. const closeAutocompleteList = (inputSelector: string) => { cy.get(inputSelector).trigger('keyup', { key: 'Escape', force: true }) } const openTableFromTree = (libNameIncludes: string, tablename: string) => { cy.get('.app-loading', { timeout: longerCommandTimeout }) .should('not.exist') .then(() => { cy.get('.nav-tree clr-tree > clr-tree-node', { timeout: longerCommandTimeout }).then((treeNodes: any) => { let viyaLib for (let node of treeNodes) { if (node.innerText.toLowerCase().includes(libNameIncludes)) { viyaLib = node break } } cy.get(viyaLib).within(() => { cy.get( '.clr-tree-node-content-container .clr-treenode-content p' ).click() cy.get('.clr-treenode-link').then((innerNodes: any) => { for (let innerNode of innerNodes) { if (innerNode.innerText.toLowerCase().includes(tablename)) { innerNode.click() break } } }) }) }) }) // Selecting the table triggers async SPA routing + a viewdata fetch; wait // for the grid to actually render before any subsequent action. cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', { timeout: longerCommandTimeout }).should('exist') }