Files
dc/client/cypress/e2e/viewbox.cy.ts
T
YuryShkoda 37a98e1d64
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m19s
Build / Build-and-test-development (pull_request) Successful in 20m2s
test(cypress): fix flaky viewbox.cy.ts assertions and DOM lookups
Replace one-shot .then()/for-loop matching with retrying .should()/cy.contains()
lookups, drop stale cached DOM node references (viyaLib) in favor of live re-queries.
2026-07-24 16:21:42 +03:00

650 lines
19 KiB
TypeScript

import { cloneDeep } from 'lodash-es'
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}`)
context('editor tests: ', function () {
this.beforeAll(() => {
cy.visit(`${hostUrl}/SASLogon/logout`)
cy.loginAndUpdateValidKey(true)
})
this.beforeEach(() => {
cy.visit(hostUrl + appLocation)
cy.wait(2000)
cy.get('body').then(($body) => {
const usernameInput = $body.find('input.username')[0]
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
cy.get('input.username').type(username)
cy.get('input.password').type(password)
cy.get('.login-group button').click()
}
})
visitPage('home')
})
it('1 | Add one viewbox', (done) => {
const viewbox_table = 'mpe_audit'
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
checkColumns(columns, () => {
done()
})
})
it('2 | Add two viewboxes', (done) => {
const viewboxes = [
{
viewbox_table: 'mpe_audit',
columns: ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
},
{
viewbox_table: 'mpe_alerts',
columns: [
'TX_FROM',
'ALERT_EVENT',
'ALERT_LIB',
'ALERT_DS',
'ALERT_USER'
]
}
]
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(
libraryToOpenIncludes,
viewboxes.map((viewbox) => viewbox.viewbox_table)
)
viewboxes.forEach((viewbox) => {
cy.contains('.open-viewbox', viewbox.viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
})
let result = 0
viewboxes.forEach((viewbox) => {
checkColumns(viewbox.columns, () => {
result++
if (result === viewboxes.length) done()
})
})
})
it('3 | Add viewbox, add columns', (done) => {
const viewbox_table = 'mpe_audit'
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
const additionalColumns = ['IS_PK']
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
removeAllColumns()
addColumns(additionalColumns)
checkColumns([...columns, ...additionalColumns], () => {
done()
})
})
it('4 | Add viewbox, add columns and reorder', (done) => {
const viewbox_table = 'mpe_audit'
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
const additionalColumns = ['IS_PK', 'MOVE_TYPE']
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
})
it('5 | Add viewbox, add columns, reorder, remove column, add again', (done) => {
const viewbox_table = 'mpe_audit'
const columns = ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM']
const additionalColumns = ['IS_PK', 'MOVE_TYPE']
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
cy.contains('.open-viewbox', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
openViewboxConfig(viewbox_table)
removeAllColumns()
addColumns(additionalColumns, () => {
cy.wait(1000)
//reorder
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
//reorder end
cy.wait(500)
checkColumns([...columns, ...additionalColumns.reverse()], () => {
const colToRemove = 'MOVE_TYPE'
removeColumn(colToRemove)
checkColumns(
[
...columns,
...additionalColumns.filter((col) => col !== colToRemove)
],
() => {
addColumns([colToRemove], () => {
checkColumns([...columns, ...additionalColumns.reverse()], () => {
done()
})
})
}
)
})
})
})
it('6 | Add viewboxes, reload and check url restored configuration', (done) => {
const viewboxes = [
{
viewbox_table: 'mpe_audit',
columns: ['LOAD_REF', 'LIBREF', 'DSN', 'KEY_HASH', 'TGTVAR_NM'],
additionalColumns: ['IS_PK', 'MOVE_TYPE']
},
{
viewbox_table: 'mpe_alerts',
columns: [
'TX_FROM',
'ALERT_EVENT',
'ALERT_LIB',
'ALERT_DS',
'ALERT_USER'
],
additionalColumns: ['TX_TO']
}
]
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
cy.get('.viewbox-open').click()
openTableFromViewboxTree(libraryToOpenIncludes, [
viewboxes[0].viewbox_table,
viewboxes[1].viewbox_table
])
openViewboxConfig(viewboxes[0].viewbox_table)
cy.wait(500)
removeAllColumns()
addColumns(viewboxes[0].additionalColumns, () => {
cy.wait(1000)
if (viewboxes[0].viewbox_table === 'mpe_audit') {
cy.get('.col-box.column-MOVE_TYPE')
.realMouseDown({ button: 'left', position: 'center' })
.realMouseMove(0, 10, { position: 'center' })
cy.wait(200) // In our case, we wait 200ms cause we have animations which we are sure that take this amount of time
cy.get('.col-box.column-IS_PK')
.realMouseMove(0, 0, { position: 'center' })
.realMouseUp()
}
cy.wait(1000)
openViewboxConfig(viewboxes[1].viewbox_table)
addColumns(viewboxes[1].additionalColumns, () => {
cy.wait(1000).reload()
let result = 0
checkColumns(
[
...viewboxes[0].columns,
...cloneDeep(viewboxes[0].additionalColumns.reverse())
],
() => {
result++
if (result === 2) done()
}
)
checkColumns(
[...viewboxes[1].columns, ...viewboxes[1].additionalColumns],
() => {
result++
if (result === 2) done()
}
)
})
})
})
// The base VIEW table and a viewbox on a different table must never
// share a filter dialog's cached state - each keeps its own entry in
// the globals filter cache (getFilterObjPath), keyed by viewboxId where
// one is present. Opened from the VIEW page specifically
// (rootParam === 'view') - the one branch where this previously broke,
// since a 'view'-page QueryComponent was always treated as the base
// table regardless of viewboxId.
it('7 | Base table filter and viewbox filter stay isolated from each other', () => {
const viewbox_table = 'mpe_audit'
visitPage('view/data')
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
// Filter the base table.
openViewFilter()
setFilterWithValue('SOME_CHAR', 'this is dummy data', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
// Open a viewbox on a *different* table and check its filter dialog
// does not show the base table's filter.
openViewViewboxes()
openTableFromViewboxTree(libraryToOpenIncludes, [viewbox_table])
closeViewboxModal()
// cy.contains retries until the viewbox's title actually shows the
// expected table name (it can render before its data/title loads),
// then .parents() walks back up to its container - avoids a
// one-shot .then() racing the title's own async update, which is
// what made this flaky in the first place.
cy.contains('.viewboxes-container .viewbox .table-title', viewbox_table, {
matchCase: false,
timeout: longerCommandTimeout
})
.parents('.viewbox')
.first()
.within(() => {
cy.get('clr-icon[shape="filter"]').click()
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.not.contain('SOME_CHAR')
})
// Reverse direction: filter the viewbox, then confirm
// re-opening the base table's own filter dialog still shows
// the base table's filter, not the viewbox's.
setFilterWithValue('LIBREF', 'sasdemo', 'value', () => {
cy.get('app-query', { withinSubject: null }).should('not.exist')
openViewFilter()
cy.get('.filter-modal code.language-sql', {
withinSubject: null
}).should(($code) => {
expect($code.text()).to.contain('SOME_CHAR')
expect($code.text()).to.not.contain('LIBREF')
})
})
})
})
})
})
const removeAllColumns = () => {
cy.get('.configuration-wrapper clr-icon[shape="trash"]').then(
(removeNodes) => {
for (let removeNode of removeNodes) {
removeNode.click()
}
}
)
}
// Asserts that at least one open viewbox currently shows exactly this
// column list, in order. Uses a single .should() callback (synchronous
// jQuery reads only, no nested cy.get()/.then()) so Cypress retries the
// whole check against a fresh DOM snapshot until it passes or times out -
// unlike .then(), which only ever sees one snapshot and, combined with an
// early return on a mismatch, would silently never invoke the caller's
// callback while Handsontable/the viewbox's data are still loading or
// re-rendering asynchronously.
const checkColumns = (columns: string[], callback: () => void) => {
cy.get('.viewboxes-container .viewbox', { withinSubject: null }).should(
($viewboxNodes) => {
const matches = $viewboxNodes.toArray().some((viewboxNode) => {
// rowHeaders: true (viewboxes.component.ts) adds a leading, unlabeled
// corner <th> before the actual data columns - skip it, or every
// comparison is off by one against the row-header cell instead of
// the first real column.
const headerCells = Cypress.$(viewboxNode)
.find('.ht_master.handsontable thead tr th')
.toArray()
.slice(1)
return columns.every((col, i) =>
(headerCells[i]?.innerHTML || '')
.toLowerCase()
.includes(col.toLowerCase())
)
})
expect(matches, `a viewbox showing columns: ${columns.join(', ')}`).to.be
.true
}
)
cy.then(() => callback())
}
const closeViewboxModal = () => {
cy.get('app-viewboxes .close', { withinSubject: null }).click()
}
const removeColumn = (column: string) => {
cy.get(`.col-box.column-${column} clr-icon`, { withinSubject: null }).click()
}
const addColumns = (columns: string[], callback?: () => void) => {
for (const column of columns) {
cy.get('.cols-search input', { withinSubject: null }).type(column)
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'ArrowDown' })
cy.get('.cols-search .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'Enter' })
// Wait for the actual column box to render before moving on to the
// next column (or firing the callback) - selecting it doesn't add it
// to the DOM synchronously, and cy.get()'s own retry covers that gap
// instead of relying on a fixed cy.wait().
cy.get(`.col-box.column-${column}`, { timeout: longerCommandTimeout })
}
if (callback) cy.then(() => callback())
}
// The VIEW page's "options" dropdown (viewer.component.html) - distinct
// from the editor page's dedicated .viewbox-open button used by the
// tests above, since test 7 runs on the VIEW page specifically.
const openViewOptionsDropdown = () => {
cy.get('.filterSide', { withinSubject: null }).click()
}
const openViewFilter = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Filter')
.click()
}
const openViewViewboxes = () => {
openViewOptionsDropdown()
cy.get('[clrDropdownItem]', { withinSubject: null })
.contains('Viewboxes')
.click()
}
// cy.contains retries until a matching node appears/updates - the open
// viewbox's own list entry can render before its table-name text has
// actually loaded, so a one-shot .then() lookup here would race it.
const openViewboxConfig = (viewbox_tablename: string) => {
cy.contains('.open-viewbox', viewbox_tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
}
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
// Small settle wait: right after a fresh visit (an extra navigation on
// top of beforeEach's own, e.g. test 7's visitPage('view/data')), the
// tree component can still be re-rendering, causing a node to be found
// then swapped out mid-click - same fix already proven in
// viewer-labels.cy.ts.
cy.wait(300)
const libraryNodeSelector = '.nav-tree clr-tree > clr-tree-node'
const libraryMatcher = new RegExp(libNameIncludes, 'i')
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
})
.find('.clr-tree-node-content-container p')
.click()
// Re-query the library node fresh rather than reusing the reference
// from before the click above - expanding a library can replace its
// whole DOM subtree once its child tables load (that's what made the
// earlier cached-reference version of this helper flaky: Cypress
// detected the click's own side effect detaching the element it was
// still verifying actionability against).
cy.contains(libraryNodeSelector, libraryMatcher, {
timeout: longerCommandTimeout
}).within(() => {
cy.contains('.clr-treenode-link', tablename, {
matchCase: false,
timeout: longerCommandTimeout
}).click()
})
}
const setFilterWithValue = (
variableValue: string,
valueString: string,
valueField: 'value' | 'time' | 'date' | 'datetime' | 'in' | 'between',
callback?: any
) => {
cy.wait(600)
cy.focused().type(variableValue)
cy.wait(100)
// cy.focused().trigger('input')
cy.get('.variable-col .autocomplete-wrapper', { withinSubject: null })
.first()
.trigger('keydown', { key: 'ArrowDown' })
cy.get('.variable-col .autocomplete-wrapper', {
withinSubject: null
}).trigger('keydown', { key: 'Enter' })
cy.focused().tab()
cy.wait(100)
if (valueField === 'in') {
cy.focused().select(valueField.toUpperCase()).trigger('change')
} else if (valueField === 'between') {
cy.focused().select(valueField.toUpperCase()).trigger('change')
} else {
cy.focused().tab()
cy.wait(100)
}
switch (valueField) {
case 'value': {
cy.focused().type(valueString)
break
}
case 'time': {
cy.focused().type(valueString)
break
}
case 'date': {
cy.focused().type(valueString)
cy.focused().tab()
break
}
case 'datetime': {
const date = valueString.split(' ')[0]
const time = valueString.split(' ')[1]
cy.focused().type(date)
cy.focused().tab()
cy.focused().tab()
cy.focused().type(time)
break
}
case 'in': {
cy.get('.checkbox-vals').then(() => {
cy.focused().tab()
cy.focused().click()
cy.get('.no-values')
.should('not.exist')
.then(() => {
cy.get('clr-checkbox-wrapper input').then((inputs: any) => {
inputs[0].click()
cy.get('.in-values-modal .modal-footer button').click()
cy.get('.modal-footer .btn-success-outline').click()
if (callback) callback()
})
})
})
break
}
case 'between': {
cy.focused().tab()
const start = valueString.split('-')[0]
const end = valueString.split('-')[1]
cy.focused().type(start)
cy.focused().tab()
cy.focused().type(end)
}
default: {
break
}
}
cy.wait(100)
cy.focused().tab()
cy.wait(100)
cy.focused().tab()
cy.wait(100)
cy.focused().tab()
cy.wait(100)
cy.focused().tab()
cy.wait(100)
cy.focused().click()
if (callback) callback()
}
const openTableFromViewboxTree = (
libNameIncludes: string,
tablenames: string[]
) => {
cy.get('.add-new 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('p')
.click()
.then(() => {
cy.get('.clr-treenode-link').then(async (innerNodes: any) => {
for (let innerNode of innerNodes) {
for (let tablename of tablenames) {
await pause(300)
if (innerNode.innerText.toLowerCase().includes(tablename)) {
innerNode.click()
}
}
}
})
})
})
})
}
const pause = (ms: number) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null)
}, ms)
})
}
const visitPage = (url: string) => {
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
}