fix(va-host): deliver in load mode, honour dataType, and survive odd prompt names
Build / Build-and-ng-test (pull_request) Successful in 6m37s
Lighthouse Checks / lighthouse (pull_request) Successful in 9s
Build / Build-and-test-development (pull_request) Successful in 29m30s

Review findings on the harness.

- timing=load posted nothing: mount() only started a replay for early/both and
  the load handler only logged. Both and load now post on the frame's load as
  well, which is the metadata path and the second copy VA sends, so the mode the
  controls offer is a mode that works.
- a re-apply with the same prompts did not reload the frame: setting src to the
  value it already has is a no-op in the browser, so the new load post (and the
  refresh path generally) delivered nothing. The frame is reloaded explicitly
  when the URL is unchanged.
- dataType was declared and never applied. A prompt configured as number now
  arrives as 42 rather than "42", which is what DC's clause builder branches on;
  a non-numeric value is left alone rather than becoming NaN.
- the prompt select is found by walking the selects instead of building a
  [data-name="..."] selector, so a prompt name with a quote or bracket in it
  cannot throw inside apply() and leave the page with no frame.
- the replay's give-up message now says when the app does not expose
  window.__vaLastMessage, so a changed probe is not reported as a delivery
  failure.
- deploy.mjs throws when the Node runtime cannot read Set-Cookie rather than
  silently dropping every cookie and failing later as an unauthenticated 401.

report.json carries a numeric prompt so the numeric path is exercised by
default rather than only in theory.
This commit is contained in:
dc committed 2026-11-03 19:41:56 +00:00
1 parent 88b5b3071a
commit 7d4155dc6c
4 files changed
+129 -15

No files matched your search

+15 -4
View File
@@ -44,7 +44,11 @@ Two things this is not:
- `table` goes into the editor route: `#/editor/<table>`.
- `parameters[].label` is the mapping key. DC matches `va_parmap` entries against the parameter **label** (not `name`, which is the report's own bookkeeping), case-insensitively, splitting each entry at its last `-`. `column` here becomes `va_parmap=<label>-<column>`; a parameter with no `column` is sent to the app but not mapped.
- `dataType` is `string` or `number` and is preserved in the message: a numeric prompt and a month-name date string take different paths in DC's clause builder.
- `mode` is `refresh` (reload the frame per apply - the report-refresh path) or `prompt` (post into the loaded frame - an in-place prompt change). `timing` is `both` (default), `early` or `load`.
- `mode` is `refresh` (reload the frame per apply - the report-refresh path) or `prompt` (post into the loaded frame - an in-place prompt change). `timing` is `both` (default), `early` or `load`: `early` posts only before the frame loads, `load` only after it has, and `both` does each, which is what VA does for a report with a stored prompt.
## What the app receives
The message is the DDC shape VA sends, one entry per prompt, and `dataType` is honoured rather than cosmetic: a prompt configured as `number` arrives as `42`, not `"42"`, because DC's clause builder branches on the type and a numeric column compared with a quoted value is the failure this pair exists to catch. A prompt whose configured value is not numeric is left as it is rather than becoming `NaN`.
## Deploying
@@ -73,12 +77,19 @@ On the deployments this emulates, the VA report and the DC app share a domain. T
## What to look for in the log
- `early replay: the app captured the message after N post(s)` before `frame loaded` - the pre-bootstrap path, which is the one a report refresh exercises and the one that a single post on load cannot reach.
- With `mode=prompt`, `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
- `posted on load (timing=both)` after `frame loaded` - the second copy VA sends, read on the app's ordinary path.
- With `timing=load`: no replay lines at all, then `frame loaded` followed by `posted on load (timing=load)`.
- With `mode=prompt`: `posted one message:` and no `mounting` line - the metadata path, for an in-place prompt change.
- If the replay gives up and also says the app does not expose `window.__vaLastMessage`, the probe has changed rather than the delivery: that global is how the replay knows to stop early.
In the embedded app, the FILTER bar shows the clause the report produced, e.g.
```
FILTER : (( SOME_DROPDOWN = 'Option 2' AND SOME_DATE = '12FEB1960'd ))
FILTER : (( SOME_DROPDOWN = 'Option 2' AND SOME_DATE = '12FEB1960'd AND SOME_NUM = 42 ))
```
A date arriving as a character value instead of a date literal is the defect this pair exists to catch.
A date arriving as a character value instead of a date literal, or a number arriving quoted, is the defect this pair exists to catch.
## Nothing here reaches a DC release artefact
`frontend.zip` is built from `client/dist` and `sasjs_server.json.zip` from `sasjs/sasjsbuild`; `va-host/` is outside both trees and is not referenced by `sasjsconfig.json`. Checked by building them: `sasjs c -t server && sasjs b -t server` produces a `server.json` file tree of 100 members (40 under `/services/web`) with no `va-host` entry, and `client/dist` holds 40 files, none of them `va-host`. The separation is a property of the build inputs, not a convention someone has to remember.
+10 -3
View File
@@ -40,9 +40,16 @@ const pass = arg('pass', process.env.SASJS_PASS)
const jar = new Map()
const rememberCookies = (response) => {
const headers = response.headers.getSetCookie
? response.headers.getSetCookie()
: []
// Node only exposes getSetCookie from 18.14. Feature-detecting it silently
// would drop every cookie and turn the failure into an unauthenticated 401
// from the deploy call, with nothing pointing at the cause.
if (typeof response.headers.getSetCookie !== 'function') {
throw new Error(
`this Node (${process.version}) cannot read Set-Cookie headers - deploy from Node 18.14 or later`
)
}
const headers = response.headers.getSetCookie()
for (const header of headers) {
const [pair] = header.split(';')
+96 -8
View File
@@ -36,6 +36,8 @@
var frameLoaded = false
var earlyTimer = null
var attempts = 0
var pendingPayload = null
var pendingTiming = 'both'
function log(message) {
var time = new Date().toISOString().slice(11, 23)
@@ -47,6 +49,23 @@
return report && report.parameters ? report.parameters : []
}
/**
* The select a prompt renders, found by walking the selects.
*
* Deliberately not `querySelector('[data-name="..."]')`: a name from the
* config that carries a quote or a bracket would make that selector throw and
* abort the whole apply, leaving the page with no frame at all.
*/
function promptField(name) {
var selects = PROMPTS.querySelectorAll('select')
for (var i = 0; i < selects.length; i++) {
if (selects[i].getAttribute('data-name') === name) return selects[i]
}
return null
}
/** The value the report would send for each prompt, right now. */
function message() {
return {
@@ -57,22 +76,36 @@
data: [],
columns: [],
parameters: promptNames().map(function (parameter) {
var field = PROMPTS.querySelector(
'select[data-name="' + parameter.name + '"]'
)
var field = promptField(parameter.name)
var dataType = parameter.dataType || 'string'
var raw = field ? field.value : parameter.default
return {
name: parameter.name,
// The label is the key DC maps on (`va_parmap`), so it is config, not
// something derived from the name.
label: parameter.label,
dataType: parameter.dataType || 'string',
value: field ? field.value : parameter.default
dataType: dataType,
// A numeric prompt carries a number in VA's message, and DC's clause
// builder branches on the type: sending "42" instead of 42 quotes a
// value that the backend compares as a number.
value: coerce(raw, dataType)
}
})
}
}
/** A prompt value in the shape its dataType promises. */
function coerce(value, dataType) {
if (dataType !== 'number') return value
var asNumber = Number(value)
// a prompt whose configured value is not numeric stays as it is rather than
// becoming NaN in the message
return value !== null && value !== '' && !isNaN(asNumber) ? asNumber : value
}
/**
* The embedded object's URL.
*
@@ -123,6 +156,21 @@
}
}
/**
* Whether the frame exposes the property the replay uses to stop early.
*
* It is a private global on the app's side, so it can go away without notice.
* Saying so when it is absent keeps "no capture seen" from reading as a
* harness failure when the probe itself is what changed.
*/
function captureProbeVisible() {
try {
return !!(FRAME.contentWindow && '__vaLastMessage' in FRAME.contentWindow)
} catch (error) {
return false
}
}
function stopEarlyReplay() {
if (earlyTimer === null) return
clearInterval(earlyTimer)
@@ -151,7 +199,11 @@
log('early replay: the app captured the message after ' + attempts + ' post(s)')
stopEarlyReplay()
} else if (Date.now() > deadline) {
log('early replay: gave up after ' + attempts + ' post(s), no capture seen')
log(
captureProbeVisible()
? 'early replay: gave up after ' + attempts + ' post(s), no capture seen'
: 'early replay: gave up after ' + attempts + ' post(s) - the app does not expose window.__vaLastMessage, so this replay cannot tell whether it captured'
)
stopEarlyReplay()
}
}, interval)
@@ -159,20 +211,43 @@
function mount(payload, timing) {
frameLoaded = false
pendingPayload = payload
pendingTiming = timing
stopEarlyReplay()
var sandbox = report.sandbox || defaultSandbox
var url = childUrl()
var sameUrl = FRAME.getAttribute('src') === url
if (sandbox) FRAME.setAttribute('sandbox', sandbox)
else FRAME.removeAttribute('sandbox')
log('mounting ' + childUrl())
log('mounting ' + url)
log('sandbox: ' + (sandbox || '(none)'))
FRAME.setAttribute('src', childUrl())
if (sameUrl) {
// Re-applying with the same prompts yields the same URL, and setting a
// frame's src to the value it already has does not reload it - which
// would post nothing in 'load' mode and leave the previous state on
// screen. Reload explicitly instead.
try {
FRAME.contentWindow.location.reload()
} catch (error) {
FRAME.setAttribute('src', 'about:blank')
FRAME.setAttribute('src', url)
}
} else {
FRAME.setAttribute('src', url)
}
if (timing === 'early' || timing === 'both') {
log('timing: ' + timing)
startEarlyReplay(payload)
} else {
// timing=load delivers on the frame's own load event, which is the
// metadata path: the app has bootstrapped and reads the message there.
// Without this the mode advertised in the controls posts nothing at all.
log('timing: load - the message goes in when the frame has loaded')
}
}
@@ -237,6 +312,19 @@
stopEarlyReplay()
log('frame loaded')
// timing=load and timing=both deliver on load as well: the app is up, so
// this is the metadata path, and for 'both' it is the second copy VA sends.
// timing=early deliberately stops at the captured copy.
if (pendingTiming !== 'early' && pendingPayload) {
postToFrame(pendingPayload)
log(
'posted on load (timing=' +
pendingTiming +
'): ' +
JSON.stringify(pendingPayload).slice(0, 200)
)
}
})
window.addEventListener('message', function (event) {
+8
View File
@@ -24,6 +24,14 @@
"column": "SOME_DATE",
"options": ["12FEB1960", "01DEC2021", "August 02, 1971"],
"default": "12FEB1960"
},
{
"name": "pr3",
"label": "Some number",
"dataType": "number",
"column": "SOME_NUM",
"options": ["42", "1613.001"],
"default": "42"
}
]
}