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 => 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}`) }