Files
dc/client/cypress/e2e/va-ddc-host.cy.ts
T
dc 5aba6bfc2e
Build / Build-and-ng-test (pull_request) Successful in 5m44s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m8s
Build / Build-and-test-development (pull_request) Successful in 31m4s
test(embed): track the held-out spec, and keep its comments self-contained
Review follow-up on the browser harness.

The exclusion of va-ddc-host now names the issue that tracks re-enabling it
(dc/dc#449), so the coverage gate cannot report a rotted exclusion as accounted
for. The comment also states the ordering constraint the spec carries: it clears
the DC_JSLIB filter tables per case, and every spec in that job shares one mock
estate, so it has to run last or be given its own.

The spec's comments and test name describe the behaviour and the harness, with
no ticket numbers, per AGENTS.md. The va-filter docstring says what the inference
is: a guess from the value's shape alone, which can disagree with the column in
either direction, and which costs the deferred call rather than the page because
the attempt suppresses the abort modal and falls back to the unfiltered load.
2026-11-03 14:19:20 +00:00

200 lines
7.5 KiB
TypeScript

/**
* SAS Visual Analytics data-driven content, end to end.
*
* VA hands its DDC message to the embedded object as the document appears -
* before the app boots - so the app's earliest listener (src/assets/va-early.js)
* captures it, and the editor applies a pre-set filter on the very first load.
* Posting a message into a running page exercises a different path: it arrives
* after getdata, with column metadata available, so a date is formatted
* correctly there whatever the client does. Those two paths take the same
* clause builder with different inputs, so a value can be formatted correctly on
* one and not the other - which is why this spec pins the timing as well as the
* clause.
*
* sasjs/server's /ddc-host supplies the missing half: an iframe carrying VA's
* sandbox attribute set, and `timing=early` to deliver the message repeatedly
* from the moment the frame is created, so a copy lands before the app
* bootstraps. The app under test is the build streamed by the same server, so
* everything here runs against one origin and needs no SAS.
*
* Where the evidence is: the mock public/validatefilter stores what it is sent
* (`mpe_filtersource.json` on the drive), so the clause is read back from there
* rather than inferred from a request payload. The host page's own log is the
* second witness - it records the capture before the frame finishes loading,
* which is the timing that makes the case meaningful. The frame's internals are
* deliberately not read: Cypress proxies the framed document, so its
* `contentWindow` and navigation timings cannot be trusted, and the drive is the
* authority anyway.
*/
const serverUrl = (
Cypress.env('sasjsServerUrl') || 'http://localhost:5000'
).replace(/\/$/, '')
const appLoc = Cypress.env('streamedAppLocation') || '/Public/app/dc'
const table = 'DC_JSLIB.MPE_X_TEST'
const filterSourcePath = `${appLoc}/data/DC_JSLIB/mpe_filtersource.json`
const appUrl = `${serverUrl}/AppStream/clickme/#/editor/${table}?embed=va`
const longerCommandTimeout = Cypress.env('longerCommandTimeout') || 50000
interface StoredClause {
FILTER_HASH: string
VARIABLE_NM: string
OPERATOR_NM: string
RAW_VALUE: string
PROCESSED_DTTM: number
}
/**
* A VA message carrying one parameter. `dataType: 'string'` is what a date
* parameter carries when VA displays it with a month name, and on the
* metadata-less path this type and the value's shape are all the builder has to
* go on. The `dataType: 'number'` variant is covered by the unit spec, where the
* dropped-clause branch can be driven directly.
*/
const vaMessage = (label: string, value: string, dataType = 'string') => ({
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: [{ name: 'pr1', label, dataType, value }]
})
const ddcHostUrl = (message: object) =>
`${serverUrl}/ddc-host?` +
new URLSearchParams({
url: appUrl,
message: JSON.stringify(message),
timing: 'early'
})
/** Everything the mock validatefilter has stored. */
const readStoredClauses = (): Cypress.Chainable<StoredClause[]> =>
cy
.request({
url: `${serverUrl}/SASjsApi/drive/file`,
qs: { _filePath: filterSourcePath },
failOnStatusCode: false
})
.then((res: any) => {
if (res.status === 404) return []
const body =
typeof res.body === 'string' ? JSON.parse(res.body) : res.body
return (body?.rows ?? []) as StoredClause[]
})
/**
* Empty the filter store before a case.
*
* The mock `mpFilterStore` derives a hash from the clause and reuses the existing
* FILTER_RK when it has seen that clause before, so a repeated case writes
* nothing - which would make "the clause this run stored" indistinguishable from
* whatever an earlier run (or a retry) left behind. Starting from an empty store
* keeps the assertion about this run, and it also keeps FILTER_RK allocation
* predictable.
*/
const resetFilterStore = () => {
for (const name of [
'mpe_filtersource',
'mpe_filteranytable',
'mpe_maxkeyvalues'
]) {
cy.request({
method: 'DELETE',
url: `${serverUrl}/SASjsApi/drive/file`,
qs: { _filePath: `${appLoc}/data/DC_JSLIB/${name}.json` },
failOnStatusCode: false
})
}
}
/** The clause stored for `variable`, retried until the service has written it. */
const storedClauseFor = (
variable: string,
attempt = 0
): Cypress.Chainable<StoredClause> =>
readStoredClauses().then((rows: StoredClause[]) => {
const stored = rows.filter(
(row) => String(row.VARIABLE_NM).toUpperCase() === variable
)
if (stored.length) return stored[stored.length - 1]
if (attempt > 60) {
throw new Error(
`no clause for ${variable} was stored (60 reads, ${rows.length} rows)`
)
}
cy.wait(250)
return storedClauseFor(variable, attempt + 1)
})
/**
* Mount the host page, then assert the two facts that make a case valid: the app
* captured the message, and it did so before the frame finished loading (the
* app's earliest listener runs at document parse, so this is the window a report
* refresh exercises). A capture that only lands after load takes the metadata
* path, which formats a date correctly whatever the client does, and so would
* prove nothing about the metadata-less path.
*/
const mountWithEarlyMessage = (message: object) => {
cy.visit(ddcHostUrl(message))
cy.get('#log', { timeout: longerCommandTimeout }).should(($log) => {
const lines = $log.text()
const captured = lines.indexOf('early replay: the app captured the message')
const loaded = lines.indexOf('frame loaded')
expect(captured, 'the app captured the message').to.be.greaterThan(-1)
expect(loaded, 'the frame finished loading').to.be.greaterThan(-1)
expect(captured, 'capture happened before the frame loaded').to.be.lessThan(
loaded
)
})
}
/**
* Run one case and return the clause the service received. Cypress queues the
* commands in call order, so the store is empty before the page is visited and
* the read runs only once the page has captured and applied the message.
*/
const applyVaFilter = (
message: object,
variable: string
): Cypress.Chainable<StoredClause> => {
resetFilterStore()
mountWithEarlyMessage(message)
return storedClauseFor(variable)
}
context('VA data-driven content through /ddc-host:', () => {
it('1 | stores a month-name date as a date literal', () => {
// `August 02, 1971` is how VA displays SOME_DATE=4231 under WORDDATE. A
// month-name value formatted as character cannot be compared with a numeric
// date column: on a live estate that is the PROC SQL type clash inside
// mp_filtervalidate, and on this mock estate it is the quoted literal this
// assertion rejects.
applyVaFilter(vaMessage('SOME_DATE', 'August 02, 1971'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.OPERATOR_NM).to.equal('=')
expect(clause.RAW_VALUE).to.equal("'02AUG1971'd")
}
)
})
it('2 | stores a DATE9 date as a date literal (control)', () => {
// DATE9, the control: it separates a parsing problem in the month-name case
// from a harness that stopped delivering dates at all.
applyVaFilter(vaMessage('SOME_DATE', '01DEC2021'), 'SOME_DATE').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'01DEC2021'd")
}
)
})
it('3 | stores a character parameter as a quoted string (control)', () => {
applyVaFilter(vaMessage('SOME_DROPDOWN', 'Option 2'), 'SOME_DROPDOWN').then(
(clause: StoredClause) => {
expect(clause.RAW_VALUE).to.equal("'Option 2'")
}
)
})
})