test(embed): find the stored clause by this run's filter hash, without wiping
Build / Build-and-ng-test (pull_request) Successful in 6m47s
Lighthouse Checks / lighthouse (pull_request) Successful in 9m48s
Build / Build-and-test-development (pull_request) In progress

The spec located its evidence as "the last stored row for this variable", which
cannot tell this run's row from one an earlier case or run left behind - so it
deleted the DC_JSLIB filter tables before each case instead, and that bought a
scheduling contract: it had to run after every other spec on the shared mock
estate, and it left the estate falsified for anything that ran after it.

The mock validatefilter already returns the FILTER_HASH it computed for the
clause, so the rows are now selected by the hash this case's own response names.
Nothing is cleared, and no spec has to run before or after it. A store that
already holds this clause holds the same rows; a clause that changed hashes
differently, so a regression cannot be satisfied by a stale row.

va-ddc-host moves back in front of metanav, which is the one spec with a real
ordering constraint (its licence key must not change another spec).
This commit is contained in:
dc committed 2026-11-03 20:53:40 +00:00
1 parent f9099ffaa6
commit e6881a2900
2 files changed
+69 -46

No files matched your search

+2 -11
View File
@@ -10,16 +10,7 @@
# target table; it runs early, before other specs have added rows to the
# approval tables.
# - metanav only reads the metadata tree, and applies a licence key so it can
# run on its own; it runs after every spec that asserts a free-tier or
# unlicensed state (csv-limited first), because that key removes the "Locked
# Feature" modal. Anything placed after it must not depend on the absence of
# a licence - va-ddc-host is the one that does, and it asserts nothing about
# licensing.
# - va-ddc-host goes last: it clears the DC_JSLIB filter tables
# (mpe_filtersource, mpe_filteranytable, mpe_maxkeyvalues) before each case,
# and every spec in this job shares one mock estate, so it has to run after
# everything that reads or writes them. See resetFilterStore in
# client/cypress/e2e/va-ddc-host.cy.ts.
# run on its own; it goes last so that key cannot change another spec.
#
# Specs added later are appended rather than inserted, so the sequence that was
# known green keeps its shape.
@@ -34,7 +25,7 @@
# reordering the sequence that was known green.
set -euo pipefail
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download metanav va-ddc-host"
SPECS="csv-limited hook-programs liveness editor excel-multi-load excel csv filtering filter-panel licensing viewer-labels full-table-search viewbox stage browser-info ddl-export row-cell-limits derived-variables startupservice-launcher coltype-delete-record submitted-details embed-va download va-ddc-host metanav"
# Specs that exist but are held out of CI, each with the reason. Listed here so
# the coverage check below can tell a deliberate exclusion from a forgotten file.
+67 -35
View File
@@ -18,13 +18,18 @@
* 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.
* (`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 = (
@@ -85,47 +90,61 @@ const readStoredClauses = (): Cypress.Chainable<StoredClause[]> =>
})
/**
* Empty the filter store before a case.
* The FILTER_HASH the service returned for the clause in `body`.
*
* 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.
* 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 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
})
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`, retried until the service has written it. */
/** 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.VARIABLE_NM).toUpperCase() === variable
(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} was stored (60 reads, ${rows.length} rows)`
`no clause for ${variable} under ${hash} (60 reads, ${rows.length} rows)`
)
}
cy.wait(250)
return storedClauseFor(variable, attempt + 1)
return storedClauseFor(hash, variable, attempt + 1)
})
/**
@@ -151,17 +170,30 @@ const mountWithEarlyMessage = (message: object) => {
}
/**
* 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.
* 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.
*/
const applyVaFilter = (
message: object,
variable: string
): Cypress.Chainable<StoredClause> => {
resetFilterStore()
let filterHash = ''
cy.intercept({ method: 'POST', url: '**/*validatefilter*' }, (req) => {
req.continue((res) => {
filterHash = filterHashFrom(res.body, variable)
})
}).as('validatefilter')
mountWithEarlyMessage(message)
return storedClauseFor(variable)
return cy
.wait('@validatefilter')
.then(() => storedClauseFor(filterHash, variable))
}
context('VA data-driven content through /ddc-host:', () => {