Files
dc/client/cypress/e2e/full-table-search.cy.ts
T
dc d2242d919b
Build / Build-and-ng-test (pull_request) Successful in 3m55s
Build / lighthouse (pull_request) Successful in 6m3s
Build / Build-and-test-development (pull_request) Successful in 19m56s
test(cypress): one openTableFromTree command instead of thirteen copies
Thirteen specs carried the same local helper: find the library node whose text
contains a fragment, expand it, then click the table whose name contains
another, by iterating a jQuery collection captured a moment earlier and calling
.click() on the node that matches.

That captured reference is the reason the suite is fragile under a route that
fetches its chunk: expanding a library replaces its subtree once the child
tables load, and a route that is still fetching re-renders the tree again, so
the node clicked is a node the DOM no longer holds.

The command re-queries at each step - filter the tree nodes by text, then
filter the table links the same way - and ends by waiting for the header's
per-column dropdown, which is what proves Handsontable has populated the header
rather than just the row shells. Specs call cy.openTableFromTree(library,
table); the two specs that need a callback of their own (licensing, liveness)
and viewbox's variant keep their local helpers for now.

Not verified locally: the Cypress renderer crashed under the load this machine
is carrying, so CI is the first run of this.
2026-11-08 00:49:12 +00:00

349 lines
13 KiB
TypeScript

// 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/createdemodata.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.clearSession()
// 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.
cy.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')
// Live on the markup rather than a label: the filter modal's submit button
// has its text ("Ok") and no explicit aria-label, and the ARIA pass no
// longer synthesises one.
cy.contains('.filter-modal .modal-footer button', /^\s*Ok\s*$/).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 })
}