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.
350 lines
13 KiB
TypeScript
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'")
|
|
}
|
|
)
|
|
})
|
|
})
|