Brings in the diagnostics fixes from the base branch; conflicts were the paramMap type widening to VaParmapTarget and the new paramsStable argument, both of which the transform branch already carries.
551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
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}`)
|
|
|
|
// SAS Visual Analytics data-driven content embed mode (`?embed=va`):
|
|
// - chrome is hidden (same as embed=true),
|
|
// - the editor opens READ-ONLY (the user clicks Edit, as in normal use),
|
|
// - the Filter button is hidden - VA filters replace the filter builder,
|
|
// - the Add Row / Add Record buttons are hidden.
|
|
//
|
|
// The detailed PK+label data-merge logic is covered by the VaMessagingService
|
|
// unit spec; here we assert the deterministic UI layout, that pushing a VA
|
|
// postMessage does not break the page, and (case 3) that a `va_parmap` URL
|
|
// mapping filters the column it names end to end.
|
|
/**
|
|
* Assert on the row count the editor shows. The assertion runs inside a Cy
|
|
* `should`, so it retries until the grid has loaded: reading the text once would
|
|
* capture the count from before a filter is applied.
|
|
*/
|
|
const parseRows = (text: string): number => {
|
|
const match = /\(([\d,]+) rows?/.exec(text)
|
|
return match ? Number(match[1].replace(/,/g, '')) : NaN
|
|
}
|
|
|
|
const expectRows = (check: (rows: number) => void) =>
|
|
cy.get('h4', { timeout: longerCommandTimeout }).should(($h4) => {
|
|
check(parseRows($h4.text()))
|
|
})
|
|
|
|
/** Assert the grid shrank, without pinning a fixture-specific number. */
|
|
const expectFewerRowsThan = (baseline: number) =>
|
|
expectRows((rows) => {
|
|
expect(rows, 'filtered row count').to.be.lessThan(baseline)
|
|
})
|
|
|
|
/** Read the unfiltered row count from the app itself, retrying until it loads. */
|
|
const readRowCount = (): Cypress.Chainable<number> =>
|
|
expectRows((rows) => {
|
|
expect(rows, 'row count').to.be.greaterThan(0)
|
|
})
|
|
.get('h4')
|
|
.invoke('text')
|
|
.then(parseRows)
|
|
|
|
context('embed=va (VA data-driven content) tests: ', function () {
|
|
// How many rows the table has unfiltered. Captured once, from the app itself.
|
|
let unfilteredRows = 0
|
|
|
|
this.beforeAll(() => {
|
|
cy.visit(`${hostUrl}/SASLogon/logout`)
|
|
cy.loginAndUpdateValidKey()
|
|
// Baseline for the relative row-count assertions: open the table with no VA
|
|
// message and record what the editor itself reports.
|
|
cy.visit(`${hostUrl}${appLocation}/#/editor/DC_JSLIB.MPE_X_TEST`, {
|
|
timeout: 60000
|
|
})
|
|
readRowCount().then((count) => {
|
|
unfilteredRows = count
|
|
})
|
|
})
|
|
|
|
this.beforeEach(() => {
|
|
cy.visit(hostUrl + appLocation)
|
|
// A cy.visit that differs from the current URL only in the hash is a
|
|
// same-document navigation, so the app is not re-bootstrapped. Reload so
|
|
// each test starts from a fresh document (the previous test may have left
|
|
// ?embed=va in the URL, which the app reads once at init).
|
|
cy.reload()
|
|
visitPage('home')
|
|
})
|
|
|
|
it('1 | opens the editor in VA mode: chrome hidden, read-only, no Filter button', () => {
|
|
openEditorInVaMode('mpe_x_test', () => {
|
|
// Chrome hidden (same as embed=true)
|
|
cy.get('header.app-header').should('not.exist')
|
|
|
|
// Grid present, read-only: the user enters edit mode with Edit, as usual
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
|
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
|
|
|
// The filter builder is replaced by VA filters
|
|
cy.get('.btnCtrl button').contains('Filter').should('not.exist')
|
|
|
|
// Not in edit mode, so no edit-mode controls
|
|
cy.get('.btnCtrl button.btn-outline-danger').should('not.exist') // Cancel
|
|
cy.get('.btnCtrl').contains('Add Row').should('not.exist')
|
|
cy.get('.btnCtrl').contains('Submit').should('not.exist')
|
|
|
|
// Bottom Add Record hidden in VA mode
|
|
cy.contains('button', 'Add Record').should('not.exist')
|
|
})
|
|
})
|
|
|
|
it('2 | accepts a VA postMessage and stays in VA mode', () => {
|
|
// A VA message drives column visibility + filtering; an empty/unmatched
|
|
// message reloads the editor (unfiltered) while preserving ?embed=va.
|
|
openEditorInVaMode('mpe_x_test', () => {
|
|
cy.window().then((win) => {
|
|
win.postMessage(
|
|
{
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters: []
|
|
},
|
|
'*'
|
|
)
|
|
})
|
|
|
|
// Editor still loads in VA mode after the message (chrome hidden, grid up)
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout }).should('exist')
|
|
cy.get('header.app-header').should('not.exist')
|
|
cy.get('.btnCtrl button').contains('Edit').should('exist')
|
|
})
|
|
})
|
|
it('3 | va_parmap filters a column from a parameter that is not its name (tab 1)', () => {
|
|
// Two DC instances over one table (one per report tab), each filtering the
|
|
// same DC column from its own report parameter. Without the URL mapping the
|
|
// parameter label is not a column name, so nothing would filter at all. The
|
|
// mapping here is written in lower case against an upper-case label and
|
|
// column, which pins the case-insensitivity of both sides end to end.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
// The applied filter names the MAPPED column, and the grid shrinks to
|
|
// the rows matching that value (Option 2 occurs twice in this table).
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DROPDOWN'
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', "'Option 2'")
|
|
expectFewerRowsThan(unfilteredRows)
|
|
},
|
|
'va_parmap=dropdown_tab1-some_dropdown'
|
|
)
|
|
})
|
|
|
|
it('4 | the other tab maps its own parameter to the same column', () => {
|
|
// Same table, same column, a different parameter label and value (Option 1
|
|
// occurs four times) - the per-instance URL mapping is what keeps the two
|
|
// tabs independent.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 1'
|
|
})
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DROPDOWN'
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', "'Option 1'")
|
|
expectFewerRowsThan(unfilteredRows)
|
|
},
|
|
'va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('5 | warns in the grid when a mapping names a column the table lacks', () => {
|
|
// Written by hand in the report's URL, so a typo must be visible to the
|
|
// report author: the parameter is skipped, and the reason is shown rather
|
|
// than leaving an unfiltered grid that looks like a working filter.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'VA parameter mapping problem'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
cy.get('.va-parmap-warning').should('contain.text', 'NOT_A_COLUMN')
|
|
// VA mode hides the app header, so the banner must paint inside the
|
|
// viewport on its own - assert the real box, not just its presence.
|
|
cy.get('.va-parmap-warning').should(($alert) => {
|
|
const rect = $alert[0].getBoundingClientRect()
|
|
expect(rect.width, 'banner width').to.be.greaterThan(100)
|
|
expect(rect.height, 'banner height').to.be.greaterThan(20)
|
|
expect(rect.top, 'banner top').to.be.at.least(0)
|
|
expect(rect.bottom, 'banner bottom').to.be.at.most(
|
|
Cypress.config('viewportHeight')
|
|
)
|
|
})
|
|
cy.screenshot('va-parmap-banner', { capture: 'viewport' })
|
|
// Nothing was filtered: the row count matches the unfiltered baseline.
|
|
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN'
|
|
)
|
|
})
|
|
|
|
it('6 | warns straight away when a mapping entry cannot be parsed', () => {
|
|
// No separator, so the entry never becomes a mapping - reported before any
|
|
// VA message arrives.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'it needs a "-" between the VA parameter and the column name'
|
|
)
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1'
|
|
)
|
|
})
|
|
|
|
it('7 | warns when a mapping names a parameter VA is not sending', () => {
|
|
// Parameter-side typo: the mapping never applies, so without the banner this
|
|
// is indistinguishable from a working filter over every row.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
// One message only tells us this object's parameter set is still being
|
|
// discovered; the warning waits for a message that introduces no new name.
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'not.exist'
|
|
)
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'names VA parameter "dropdown_tab_1", which VA is not sending'
|
|
)
|
|
expectRows((rows) => expect(rows).to.equal(unfilteredRows))
|
|
},
|
|
'va_parmap=DROPDOWN_TAB_1-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('8 | applies a mapping from the buffered message on the very first load', () => {
|
|
// VA posts once on iframe load, before the app bootstraps, so the first
|
|
// getdata is already filtered from that buffered message - with no column
|
|
// metadata available, the mapping is what names the column.
|
|
visitWithBufferedMessage(
|
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN',
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
},
|
|
() => {
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
"SOME_DROPDOWN = 'Option 2'"
|
|
)
|
|
expectFewerRowsThan(unfilteredRows)
|
|
cy.url().should('match', /\/editor\/[^/]+\/\d+/)
|
|
}
|
|
)
|
|
})
|
|
|
|
it('9 | a bad mapping on the first load still ends at the banner', () => {
|
|
// The deferred first load cannot check the column against metadata, so its
|
|
// saveQuery may fail and fall back to an unfiltered load - the banner is
|
|
// what stops that reading as a working filter.
|
|
visitWithBufferedMessage(
|
|
'va_parmap=DROPDOWN_TAB1-NOT_A_COLUMN',
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
},
|
|
() => {
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'NOT_A_COLUMN'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
// Deliberately no row-count assertion: this path cannot validate the
|
|
// column (no metadata yet), so the outcome depends on the platform - on
|
|
// SAS, mp_filtercheck aborts the save and the load falls back to
|
|
// unfiltered; the JS mock stores the filter and returns 0 rows. The
|
|
// banner is the part that must hold either way, because both outcomes
|
|
// are otherwise indistinguishable from a working filter.
|
|
}
|
|
)
|
|
})
|
|
|
|
it('10 | warns when two parameters filter the same column (an empty grid)', () => {
|
|
// Clauses are AND-ed, so two different values on one column produce no rows
|
|
// at all - the last silent-empty-grid path. Reported rather than rewritten,
|
|
// so the author decides whether to use one parameter carrying both values.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage(
|
|
{
|
|
name: 'pr1',
|
|
label: 'DROPDOWN_TAB1',
|
|
dataType: 'string',
|
|
value: 'Option 1'
|
|
},
|
|
{
|
|
name: 'pr2',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
}
|
|
)
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'combined with AND'
|
|
)
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB1')
|
|
cy.get('.va-parmap-warning').should('contain.text', 'DROPDOWN_TAB2')
|
|
// The consequence: rows must match both clauses, and the two values
|
|
// differ, so none can match.
|
|
expectRows((rows) => expect(rows, 'matching rows').to.equal(0))
|
|
},
|
|
'va_parmap=DROPDOWN_TAB1-SOME_DROPDOWN&va_parmap=DROPDOWN_TAB2-SOME_DROPDOWN'
|
|
)
|
|
})
|
|
|
|
it('12 | a transform adds the previous quarter to the filter', () => {
|
|
// ADD(QUARTER_START,-1) is the reported requirement: one clause carrying the
|
|
// selected date and the same point one quarter earlier. 2026-10-01 = 24380,
|
|
// 2026-07-01 = 24288 in SAS days; nothing in the fixture matches either, so
|
|
// the assertion is on the clause rather than the row count.
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'SELECTED_DATE',
|
|
dataType: 'string',
|
|
value: '01OCT2026'
|
|
})
|
|
cy.get('.infoBar-text', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'SOME_DATE IN ('
|
|
)
|
|
cy.get('.infoBar-text').should('contain.text', '24380')
|
|
cy.get('.infoBar-text').should('contain.text', '24288')
|
|
cy.get('h4').should('contain.text', '(0 rows')
|
|
},
|
|
'va_parmap=SELECTED_DATE-SOME_DATE:ADD(QUARTER_START,-1)'
|
|
)
|
|
})
|
|
|
|
it('13 | warns when a transform lands on a column it cannot apply to', () => {
|
|
openEditorInVaMode(
|
|
'mpe_x_test',
|
|
() => {
|
|
pushVaMessage({
|
|
name: 'pr1',
|
|
label: 'SELECTED_DATE',
|
|
dataType: 'string',
|
|
value: 'x'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'is not a date or datetime column'
|
|
)
|
|
cy.get('h4').should('contain.text', '(7 rows')
|
|
},
|
|
'va_parmap=SELECTED_DATE-SOME_CHAR:ADD(QUARTER_START,-1)'
|
|
)
|
|
})
|
|
|
|
it('11 | logs a parse-time problem once, however many messages arrive', () => {
|
|
// The parse problem is logged at app init; the editor's own loop must not log
|
|
// it again. The second entry is valid-but-missing, so the loop definitely runs
|
|
// and logs its own line - which is what makes this a real assertion.
|
|
cy.visit(
|
|
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&va_parmap=DROPDOWN_TAB1&va_parmap=DROPDOWN_TAB2-NOT_A_COLUMN`,
|
|
{
|
|
onBeforeLoad(win: any) {
|
|
win.__warns = []
|
|
const original = win.console.warn.bind(win.console)
|
|
win.console.warn = (...args: any[]) => {
|
|
win.__warns.push(args.join(' '))
|
|
original(...args)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
|
'not.exist'
|
|
)
|
|
pushVaMessage({
|
|
name: 'pr2',
|
|
label: 'DROPDOWN_TAB2',
|
|
dataType: 'string',
|
|
value: 'Option 2'
|
|
})
|
|
cy.get('.va-parmap-warning', { timeout: longerCommandTimeout }).should(
|
|
'contain.text',
|
|
'NOT_A_COLUMN'
|
|
)
|
|
cy.window().then((win: any) => {
|
|
const warns: string[] = win.__warns || []
|
|
const parseLines = warns.filter((w) =>
|
|
w.includes('va_parmap "DROPDOWN_TAB1"')
|
|
)
|
|
const runtimeLines = warns.filter((w) => w.includes('NOT_A_COLUMN'))
|
|
expect(parseLines.length, warns.join(' | ')).to.eq(1)
|
|
expect(runtimeLines.length, warns.join(' | ')).to.eq(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Loads the editor with a VA message already in the pre-bootstrap buffer, the
|
|
* way VA's own postMessage arrives before Angular starts. A distinct pathname is
|
|
* used so this is a real document load: a cy.visit that differs only in the hash
|
|
* is a same-document navigation and would not re-bootstrap the app.
|
|
*/
|
|
const visitWithBufferedMessage = (
|
|
extraParams: string,
|
|
parameter: any,
|
|
callback?: any
|
|
) => {
|
|
cy.visit(
|
|
`${hostUrl}${appLocation}/index.html#/editor/DC_JSLIB.MPE_X_TEST?embed=va&${extraParams}`,
|
|
{
|
|
onBeforeLoad(win: any) {
|
|
win.__vaLastMessage = {
|
|
t: Date.now(),
|
|
origin: win.location.origin,
|
|
data: {
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters: [parameter]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should('not.exist')
|
|
if (callback) callback()
|
|
}
|
|
|
|
/** Pushes one VA data-driven content message carrying the given parameters. */
|
|
const pushVaMessage = (...parameters: any[]) => {
|
|
cy.window().then((win) => {
|
|
win.postMessage(
|
|
{
|
|
version: '1',
|
|
resultName: 'dd1',
|
|
rowCount: 0,
|
|
availableRowCount: 0,
|
|
data: [],
|
|
columns: [],
|
|
parameters
|
|
},
|
|
'*'
|
|
)
|
|
})
|
|
}
|
|
|
|
// Opens a table from the tree (which routes to #/editor/LIB.TABLE), then
|
|
// re-visits the same route with ?embed=va so the app parses VA embed mode.
|
|
// `extraParams` is appended to that query string (e.g. va_parmap entries).
|
|
const openEditorInVaMode = (
|
|
tablename: string,
|
|
callback?: any,
|
|
extraParams?: string
|
|
) => {
|
|
openTableFromTree(libraryToOpenIncludes, tablename)
|
|
|
|
cy.get('#hotTable', { timeout: longerCommandTimeout })
|
|
.should('exist')
|
|
.then(() => {
|
|
cy.url().then((url) => {
|
|
const separator = url.includes('?') ? '&' : '?'
|
|
const extra = extraParams ? `&${extraParams}` : ''
|
|
cy.visit(`${url}${separator}embed=va${extra}`)
|
|
// A cy.visit that differs from the current URL only in the hash is a
|
|
// same-document navigation, so the app is not re-bootstrapped and the
|
|
// embed parameter (read once at app init) never takes effect. Reload to
|
|
// bootstrap it, as the VA iframe load does.
|
|
cy.reload()
|
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
|
'not.exist'
|
|
)
|
|
if (callback) callback()
|
|
})
|
|
})
|
|
}
|
|
|
|
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 > button').click()
|
|
|
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
|
for (let innerNode of innerNodes) {
|
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
|
innerNode.click()
|
|
break
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
const visitPage = (url: string) => {
|
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
|
}
|