Files
dc/client/cypress/e2e/va-ddc-host.cy.ts
T
dc 1225eb122a
Build / Build-and-ng-test (pull_request) Successful in 6m45s
Lighthouse Checks / lighthouse (pull_request) Successful in 10m14s
Build / Build-and-test-development (pull_request) Failing after 37m14s
test(embed): fail on a missing FILTER_HASH before reading the store back
Review finding: when every body is refused by carriesClause, or the response
carries no FILTER_HASH, capture.hash stays empty - and storedClauseFor('')
then spends its 60 retries before reporting "no clause for <var> under",
which names neither the refused body nor the missing hash.

The hash is now asserted non-empty before the read, with the case's own
message, so a broken delivery fails where it happened.

The call count stays where it was - after the data load - because moving it
ahead of the read would check it before the app has issued the second POST
that the count exists to catch.
2026-11-04 09:08:55 +00:00

350 lines
13 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) and returns the FILTER_HASH it computed
* for that clause, so the rows are read back by the hash this run's own response
* named. That is what makes the assertion about the clause DC built rather than
* about a request payload; and selecting by hash rather than emptying the store
* first is what keeps the spec out of every other spec's way - it clears nothing,
* so nothing has to run before or after it. A store that already holds this clause
* holds the same rows, and one that holds a different clause cannot be mistaken for
* this one. 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[]
})
/**
* The candidate strings a request body carries, any of which could hold the CSV
* row: the raw text, the percent-decoded text, each form field's value, and every
* string leaf of a JSON body.
*
* Matching the raw text alone is not enough - the same row arrives wrapped in a
* multipart part, as `filterquery=...` with `%2C` for every comma, or inside a
* parsed object - and widening the delimiters instead lets a quoted literal
* through, which is the thing this guard exists to refuse.
*/
const bodyTexts = (body: any): string[] => {
const raw = typeof body === 'string' ? body : JSON.stringify(body ?? '')
const texts = [raw]
let decoded = raw
try {
decoded = decodeURIComponent(raw)
} catch (error) {
// not valid percent-encoding: the raw text is all there is
}
if (decoded !== raw) texts.push(decoded)
for (const part of decoded.split('&')) {
const index = part.indexOf('=')
if (index >= 0) texts.push(part.slice(index + 1))
}
let parsed: any = null
try {
parsed = JSON.parse(raw)
} catch (error) {
parsed = null
}
const collect = (value: any) => {
if (typeof value === 'string') texts.push(value)
else if (value && typeof value === 'object')
Object.values(value).forEach(collect)
}
collect(parsed)
return texts
}
/** The fields of a CSV row: split on the separators, nothing else. */
const csvFields = (text: string): string[] => text.split(/[,\r\n]/)
/**
* Whether the request carries a `filterquery` row for `variable`.
*
* The name has to BE a field of the candidate, not appear inside one: a
* RAW_VALUE, an SQL fragment, a quoted literal, or another variable whose name
* merely contains this one is therefore not counted as another delivery of this
* clause.
*/
const carriesClause = (body: any, variable: string): boolean =>
bodyTexts(body).some((text) =>
csvFields(text).some((field) => field.trim() === variable)
)
/**
* The FILTER_HASH the service returned for the clause in `body`.
*
* It is the service's own hash of the query, so it names this run's clause
* exactly: a clause that changed produces a different hash and the stored rows
* for the old one no longer match.
*/
const filterHashFrom = (body: any, variable: string): string => {
// The response body arrives as text, not an object: the service writes a JSON
// string and the adapter passes it through unchanged.
let payload: any = body
if (typeof body === 'string') {
try {
payload = JSON.parse(body)
} catch (error) {
throw new Error(
`the validatefilter response for ${variable} was not JSON: ` +
String(body).slice(0, 300)
)
}
}
const rows = Array.isArray(payload?.result) ? payload.result : []
const hash = rows[0]?.FILTER_HASH
if (!hash) {
throw new Error(
`the validatefilter response carried no FILTER_HASH for ${variable}: ` +
String(body).slice(0, 300)
)
}
return String(hash)
}
/** The clause stored for `variable` under `hash`, retried until it is written. */
const storedClauseFor = (
hash: string,
variable: string,
attempt = 0
): Cypress.Chainable<StoredClause> =>
readStoredClauses().then((rows: StoredClause[]) => {
const stored = rows.filter(
(row) =>
String(row.FILTER_HASH) === hash &&
String(row.VARIABLE_NM).toUpperCase() === variable
)
if (stored.length) return stored[stored.length - 1]
if (attempt > 60) {
throw new Error(
`no clause for ${variable} under ${hash} (60 reads, ${rows.length} rows)`
)
}
cy.wait(250)
return storedClauseFor(hash, 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')
// The log tail goes into the message: a failure here means either the app
// never exposed its buffer or the frame never loaded, and the host page says
// which. Without it the failure is only "expected -1 to be above -1".
expect(
captured,
`the app captured the message. Log: ${lines.slice(-500)}`
).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 stored for it.
*
* The hash is read from the response to this case's own validatefilter call, so
* the rows are selected by what this run sent rather than by whatever the store
* happened to hold. Nothing is cleared first, which is why this spec needs no
* position in the run order.
*
* Which response is "this case's" is decided by the clause itself: the request
* body carries the filterquery CSV, so a call naming a different variable is
* ignored and cannot overwrite the hash. Two calls carrying the same variable
* would be the same clause, but they would also mean the harness is delivering
* twice - so the case fails on that count rather than silently taking the last.
*/
const applyVaFilter = (
message: object,
variable: string
): Cypress.Chainable<StoredClause> => {
const capture: { calls: number; hash: string; error: Error | null } = {
calls: 0,
hash: '',
error: null
}
cy.intercept({ method: 'POST', url: '**/*validatefilter*' }, (req) => {
const forThisCase = carriesClause(req.body, variable)
req.continue((res) => {
if (!forThisCase) return
capture.calls++
// An exception thrown here surfaces as a Cypress uncaught error rather
// than as this case failing, so it is kept and rethrown in the chain.
try {
capture.hash = filterHashFrom(res.body, variable)
} catch (error) {
capture.error = error as Error
}
})
}).as('validatefilter')
// The apply's own request sequence: validatefilter, then the data load for the
// filtered route. Waiting for the load is the app's completion signal, so the
// duplicate check below does not rest on elapsed time.
cy.intercept({ method: 'POST', url: '**/*getdata*' }).as('getdata')
mountWithEarlyMessage(message)
return cy
.wait('@validatefilter', { timeout: longerCommandTimeout })
.then(() => {
if (capture.error) throw capture.error
// Before anything is read back: an empty hash means no delivery matched
// this case - every body was refused by carriesClause, or the response
// carried no FILTER_HASH - and storedClauseFor('') would spend its retries
// and then report a missing row, naming neither cause.
expect(
capture.hash,
'validatefilter FILTER_HASH for this case'
).to.not.equal('')
return storedClauseFor(capture.hash, variable)
})
.then((clause: StoredClause) =>
// Counted after the data load rather than after a pause: the app issues the
// load only once the filter request has been answered, so a second POST
// from the same apply has been made by then, and nothing here depends on
// how fast the runner is.
cy.wait('@getdata', { timeout: longerCommandTimeout }).then(() => {
expect(
capture.calls,
`validatefilter calls carrying ${variable} in this case`
).to.equal(1)
return clause
})
)
}
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'")
}
)
})
})