Merge pull request 'feat(va-host): emulate a VA report that embeds DC, as a deployable AppStream app' (#452) from feat/va-report-emulation into release/7.22
Sites / deploy (push) Canceled after 0s
Build / Build-and-ng-test (pull_request) Canceled after 0s
Build / Build-and-test-development (pull_request) Canceled after 0s
Lighthouse Checks / lighthouse (pull_request) Canceled after 0s

Reviewed-on: #452
This commit was merged in pull request #452.
This commit is contained in:
allan committed 2026-11-04 00:18:21 +00:00
commit 1a31bcf244
5 files changed
+969

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
# VA report emulation (host for the embedded DC app)
A parent page that stands in for a SAS Visual Analytics report which embeds Data Controller as data-driven content. The dropdowns are the report's prompts; the iframe is framed the way VA frames the object, and is fed the same DDC message VA sends.
Two things this is not:
- **It is not part of the DC build.** The host is not the child. Shipping it in DC's deployment would put a test harness on customer servers and let DC be opened top-level as a page that frames itself. Nothing here is in `frontend.zip` or `sasjs_server.json.zip`.
- **It is not a substitute for a real VA report.** It reproduces the contract DC sees: the message shape, the delivery timing, the sandbox attribute set and the `embed=va` / `va_parmap` URL contract. It does not reproduce VA's UI or how VA computes a prompt's value.
## Files
- `index.html` - the report page: prompt bar, delivery/timing controls, the iframe, and a log.
- `host.js` - reads the config, builds the message, mounts the frame, replays the message early, posts prompt changes.
- `report.json` - the report definition (below). The committed copy points at a local server for local runs; `deploy.mjs` rewrites it for the target it deploys to.
- `deploy.mjs` - deploys the three files above to a SASjs Server as an AppStream app.
## report.json
The committed [report.json](report.json) is the working example, and it is the one to read: three parameters, because those are the three shapes that reach DC differently - a plain string, a month-name date string and a number.
| key | meaning |
|---|---|
| `title` | heading on the page |
| `server` | the server the app is served from; the frame's origin comes from it |
| `app` | the **AppStream name** of the DC deployment, not its app-loc: where one folder has several names (`DataController`, `DataController_Main`, ...) any of them serves the same build, and this is the one the URL needs |
| `table` | goes into the editor route: `#/editor/<table>` |
| `sandbox` | the iframe's sandbox attribute set |
| `mode` | `refresh` (reload per apply) or `prompt` (post into the loaded frame) |
| `timing` | `both`, `early` or `load` |
| `intervalMs`, `replayTimeoutMs` | how often the early replay posts, and for how long |
| `parameters[]` | `name`, `label`, `dataType`, `column`, `options`, `default` |
- `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`: `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
```bash
# local, desktop-mode server (no login)
node deploy.mjs --server http://localhost:5000 --app clickme --table DC_JSLIB.MPE_X_TEST
# a server-mode estate (local login; credentials from SASJS_USER/SASJS_PASS)
SASJS_USER=me SASJS_PASS=... node deploy.mjs --server https://example.com \
--app DataControllerDev --table DC996664.MPE_X_TEST --name va-host \
--apploc /Public/app/va-host
# the same, with the password read from stdin so it stays out of argv and history
printf '%s' "$PASS" | node deploy.mjs --pass-stdin --server https://example.com ...
# re-check what is deployed, without deploying anything
node deploy.mjs --verify-only --server https://example.com --app DataControllerDev
```
There is no `--pass`: a password on the command line is readable from `ps` by every account on the host and is kept in the shell history. Use `SASJS_PASS`, or `--pass-stdin` to pipe one line in.
The script is the same route `sasjs cbd` uses: `POST /SASjsApi/drive/deploy` with a file tree plus `streamWebFolder: "web"` and `streamServiceName`, after which the server serves the folder at `/AppStream/<name>/`. No services are compiled, because the app is static.
The deploy is verified after it is sent. The route answering 2xx means the tree was accepted, not that the app is being served: a `type: 'file'` member that landed truncated still serves a 200 page, and an asset left over from a previous deploy is a 200 by definition. So the script fetches `/AppStream/<name>/` and each of the three files back and compares them to the bytes it deployed, failing on any difference and naming the size and hash of both copies. A 401 from the page is reported as deployed-but-gated rather than as a failure, because on a server-mode estate that is the gate doing its job.
Two things worth knowing about that route:
- **`type: 'file'` members are written base64-decoded; `type: 'service'` members are plain text.** A static app is therefore sent encoded (`deploy.mjs` does this). Sending plain text writes a truncated, unreadable file rather than failing.
- **A redeploy does not remove files the previous version had.** The folder is written over. Deleting `<appLoc>` first, or deploying to a fresh app-loc, is the way to avoid carrying a stale asset.
The AppStream gate is `authenticateAccessToken` plus a per-app grant on `/AppStream/<name>`, so on a server-mode estate the page answers 401 until the user is signed in - which is also what makes the embedded app work, since both are served from the same origin and the session cookie is same-site.
## The other harness, and where the contract lives
There are two things in this repo that stand in for a VA report, and they are not interchangeable:
- `client/cypress/e2e/va-ddc-host.cy.ts` drives sasjs/server's `/ddc-host` page with a single message at `timing: 'early'`, and asserts through the filter the mock backend stored. It is a fixture that runs in CI: no UI, one parameter, one delivery mode.
- `va-host/` is this page: a prompt bar, `mode` refresh/prompt, `timing` early/load/both, several parameters and a log, for a person driving the embed by hand.
Neither is the contract of record. The contract is what DC reads and when: the message shape and the `window.__vaLastMessage` buffer in `client/src/app/services/va-messaging.service.ts`, and the rule that a message must be captured before the app bootstraps, which is `client/src/assets/va-early.js`. The URL shape both harnesses build (`/AppStream/<app>/#/editor/<table>?embed=va`) comes from the app's own routing and `parseEmbedParam`.
So a change to any of those three - the message shape, the capture timing, the embed parameters - has to be read against both harnesses; a change to one harness's plumbing is local to it. In particular, `host.js` reads `FRAME.contentWindow.__vaLastMessage` to tell whether the message was captured, and Cypress deliberately does not (it proxies the framed document, so `contentWindow` is not trustworthy there). That difference is the environments', not a defect in either file.
## Why same origin
On the deployments this emulates, the VA report and the DC app share a domain. That is the topology to reproduce: a host on a *different* origin would be blocked by the server's `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`, and the `SameSite=Strict` session cookie would not travel into the frame. DC's own trust rules agree - `VaMessagingService.isTrustedEarlyOrigin` accepts the embedding window's own origin, and the live path accepts a message whose `event.source` is the parent frame.
## 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.
- `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 AND SOME_NUM = 42 ))
```
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.
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
/*
* Deploy the VA report emulation to a SASjs Server as an AppStream app.
*
* This is the same route `sasjs cbd` uses: POST /SASjsApi/drive/deploy with a
* file tree, plus `streamWebFolder` so the server registers the folder as an
* app and serves it at /AppStream/<name>. No services are compiled - the app is
* static, and the server's AppStream feature is exactly the "usual way" to
* publish one.
*
* The local `report.json` is rewritten for the target, so the deployed page
* points at the server and app it was deployed beside, rather than at the
* localhost defaults the repo carries for local runs.
*
* usage:
* SASJS_USER=<user> SASJS_PASS=<pass> node deploy.mjs \
* --server https://sas.4gl.io --app dc --table DC996664.MPE_X_TEST \
* [--name va-host] [--apploc /Public/app/va-host]
*
* node deploy.mjs --verify-only [...same targeting arguments]
*
* Credentials come from SASJS_USER/SASJS_PASS, or from stdin with
* `--pass-stdin` (so the secret never appears in argv, where `ps` and the shell
* history would hold it). In desktop mode the server needs none.
*
* The deploy is verified after it is sent, because the deploy route answering
* 2xx is not the same as the app being served: the script fetches the page and
* every asset back and compares them to the bytes it deployed. `--verify-only`
* runs just that half, against whatever is already deployed.
*/
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const arg = (name, fallback) => {
const index = args.indexOf(`--${name}`)
return index >= 0 && args[index + 1] ? args[index + 1] : fallback
}
const server = arg('server', 'http://localhost:5000').replace(/\/$/, '')
const name = arg('name', 'va-host')
const appLoc = arg('apploc', `/Public/app/${name}`)
const user = process.env.SASJS_USER
const passStdin = args.includes('--pass-stdin')
const verifyOnly = args.includes('--verify-only')
/* A password on argv is readable by every user on the host (`ps`) and lands in
the shell history, so the flag is a request to read one line from stdin
instead. SASJS_PASS is the documented route; this is for scripts that would
otherwise have to put the secret in the environment file. */
const readSecret = async () => {
if (!passStdin) return process.env.SASJS_PASS
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '')
}
const jar = new Map()
const rememberCookies = (response) => {
// 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(';')
const index = pair.indexOf('=')
if (index < 0) continue
const key = pair.slice(0, index).trim()
const value = pair.slice(index + 1).trim()
if (value === '') jar.delete(key)
else jar.set(key, value)
}
}
const cookieHeader = () =>
Array.from(jar.entries())
.map(([key, value]) => `${key}=${value}`)
.join('; ')
const xsrf = () => jar.get('XSRF-TOKEN') || ''
const request = async (url, options = {}) => {
const response = await fetch(url, {
...options,
headers: {
...(options.headers || {}),
cookie: cookieHeader(),
...(xsrf() ? { 'X-XSRF-TOKEN': xsrf() } : {})
}
})
rememberCookies(response)
return response
}
const login = async (credentials) => {
await request(`${server}/`)
// Desktop mode refuses a login ("Not Allowed while in Desktop Mode") and does
// not gate /AppStream either, so ask the server which mode it is in rather
// than guessing from the presence of credentials in the environment.
const info = await request(`${server}/SASjsApi/info`)
const mode = info.ok ? (await info.json()).mode : undefined
if (mode === 'desktop') {
console.log('desktop mode - no login needed')
return
}
if (!credentials.user || !credentials.pass) {
throw new Error(
`server is in ${mode || 'unknown'} mode and no credentials were given - ` +
'set SASJS_USER and SASJS_PASS, or pass the password on stdin'
)
}
const response = await request(`${server}/SASLogon/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: credentials.user,
password: credentials.pass
})
})
if (!response.ok) {
throw new Error(`login failed: ${response.status} ${await response.text()}`)
}
console.log(`logged in as ${credentials.user}`)
}
/** The deployed config: the repo's report.json, aimed at this target. */
const report = () => {
const base = JSON.parse(fs.readFileSync(path.join(here, 'report.json'), 'utf8'))
return {
...base,
server,
app: arg('app', base.app),
table: arg('table', base.table),
mode: arg('mode', base.mode),
timing: arg('timing', base.timing)
}
}
// `type: 'file'` members are written with base64 decoding (services are plain
// text), so anything that is not SAS code goes over the wire encoded. See the
// README: this is the one sharp edge of the app-deploy route.
const file = (name, content) => ({
name,
type: 'file',
code: Buffer.from(content, 'utf8').toString('base64')
})
/* What the deploy sends, keyed by the name it lands under, so the verification
can compare the served bytes with these rather than with a fresh read. */
const served = (config) => ({
'index.html': fs.readFileSync(path.join(here, 'index.html'), 'utf8'),
'host.js': fs.readFileSync(path.join(here, 'host.js'), 'utf8'),
'report.json': JSON.stringify(config, null, 2)
})
const fileTree = (config) => ({
members: [
{
name: 'services',
type: 'folder',
members: [
{
name: 'web',
type: 'folder',
members: Object.entries(served(config)).map(([name, content]) =>
file(name, content)
)
}
]
}
]
})
const digest = (buffer) =>
createHash('sha256').update(buffer).digest('hex').slice(0, 12)
/*
* The deploy route answering 2xx means the tree was accepted, not that the app
* is being served: a `type: 'file'` member that landed truncated still serves a
* 200 page, and a file left over from a previous deploy is by definition a 200.
* Both are only visible by asking for the app back, so the page and each of its
* three assets are fetched and compared with the bytes that were sent.
*
* A server-mode estate can gate /AppStream for a session that has not logged in;
* that is reported as deployed-but-gated rather than as a failure, because the
* deploy itself did succeed and the gate is the estate's configuration.
*/
const verify = async (config) => {
const page = await request(`${server}/AppStream/${name}/`)
if (page.status === 401 || page.status === 403) {
console.log(
`app: ${server}/AppStream/${name}/ - deployed, but the server gates it ` +
`(${page.status}), so it cannot be read back`
)
return
}
if (!page.ok)
throw new Error(
`deployed, but /AppStream/${name}/ answered ${page.status} - the app is not being served`
)
for (const [asset, content] of Object.entries(served(config))) {
const response = await request(`${server}/AppStream/${name}/${asset}`)
if (!response.ok)
throw new Error(
`deployed, but /AppStream/${name}/${asset} answered ${response.status}`
)
const sent = Buffer.from(content, 'utf8')
const servedBytes = Buffer.from(await response.arrayBuffer())
if (!servedBytes.equals(sent))
throw new Error(
`deployed, but /AppStream/${name}/${asset} serves ${servedBytes.length} ` +
`bytes (sha256 ${digest(servedBytes)}) where ${sent.length} were sent ` +
`(sha256 ${digest(sent)}) - the copy being served is truncated or stale`
)
}
console.log(
`verified: the page and all ${Object.keys(served(config)).length} assets are served as deployed`
)
}
const deploy = async (config) => {
const response = await request(`${server}/SASjsApi/drive/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appLoc,
// The folder the server streams; must be one path segment.
streamWebFolder: 'web',
streamServiceName: name,
fileTree: fileTree(config)
})
})
const body = await response.text()
if (!response.ok) throw new Error(`deploy failed: ${response.status} ${body}`)
console.log(body)
}
const main = async () => {
await login({ user, pass: await readSecret() })
const config = report()
if (!verifyOnly) await deploy(config)
await verify(config)
console.log(`app: ${server}/AppStream/${name}/`)
console.log(`child: ${config.server}/AppStream/${config.app}/`)
}
main().catch((error) => {
console.error(error.message)
process.exit(1)
})
+426
View File
@@ -0,0 +1,426 @@
/*
* A parent report page that emulates a SAS Visual Analytics report embedding
* Data Controller as data-driven content.
*
* Why this exists: on a real deployment the VA report and the DC app share a
* domain, and the report passes its prompts to the embedded object as a DDC
* message. There is no way to open that report without VA, so this page stands
* in for it: the dropdowns are the report's prompts, and the iframe is framed
* with VA's sandbox attribute set and fed the same message shape.
*
* What it deliberately does NOT reproduce: VA's own UI, the report's layout, and
* the way VA computes a prompt's value. It reproduces the contract that DC sees.
*
* Nothing here is part of the DC build. The host is not the child: shipping it
* in DC's deployment would put a test harness on customer servers and let DC be
* opened top-level as a page that frames itself.
*
* Kept to ES5-style script (no modules, no build step) because it is served as a
* plain file by SASjs Server, and because the server's CSP allows scripts from
* 'self' only - no inline script, no inline handlers.
*/
;(function () {
'use strict'
var FRAME = document.getElementById('frame')
var LOG = document.getElementById('log')
var PROMPTS = document.getElementById('prompts')
var heading = document.getElementById('heading')
var modeSelect = document.getElementById('mode')
var timingSelect = document.getElementById('timing')
// VA's own attribute set, overridden by report.json when it says otherwise.
var defaultSandbox = FRAME.getAttribute('sandbox')
var report = null
var frameLoaded = false
var earlyTimer = null
var attempts = 0
var pendingPayload = null
var pendingTiming = 'both'
// the origin the frame is allowed to receive a message on, and the document it
// had when the current replay started (see startEarlyReplay)
var targetOrigin = null
var mountDocument = null
function log(message) {
var time = new Date().toISOString().slice(11, 23)
LOG.textContent += '[' + time + '] ' + message + '\n'
LOG.scrollTop = LOG.scrollHeight
}
function promptNames() {
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 {
version: '1',
resultName: 'dd1',
rowCount: 0,
availableRowCount: 0,
data: [],
columns: [],
parameters: promptNames().map(function (parameter) {
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: 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.
*
* `embed=va` arms DC's earliest listener (it is inert without it), and each
* parameter with a `column` becomes a `va_parmap` entry - `<label>-<column>`,
* split at the last hyphen, matched case-insensitively against the parameter
* label.
*/
function childUrl() {
var params = promptNames().filter(function (parameter) {
return parameter.column
})
var query = ['embed=va'].concat(
params.map(function (parameter) {
return (
'va_parmap=' +
encodeURIComponent(parameter.label + '-' + parameter.column)
)
})
)
return (
report.server.replace(/\/$/, '') +
'/AppStream/' +
report.app +
'/#/editor/' +
encodeURIComponent(report.table) +
'?' +
query.join('&')
)
}
function postToFrame(payload) {
var target = FRAME.contentWindow
if (!target) return
// The frame is the app on report.server, so that origin is the only one that
// should receive the filter clause. '*' would deliver into whatever document
// occupies the frame, including one that navigated elsewhere, and a
// misconfigured report.server would widen delivery instead of failing.
if (!targetOrigin) {
log(
'cannot post: report.server is not a URL, so the frame origin is unknown - ' +
JSON.stringify(report.server)
)
return
}
target.postMessage(payload, targetOrigin)
}
/** The frame's origin, from the config; null when report.server is not a URL. */
function frameOrigin() {
try {
return new URL(report.server).origin
} catch (error) {
return null
}
}
/** True once DC has published what its earliest listener captured. */
function frameBuffered() {
try {
return !!(FRAME.contentWindow && FRAME.contentWindow.__vaLastMessage)
} catch (error) {
return false
}
}
/**
* 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)
earlyTimer = null
}
/**
* Deliver the message the way VA does on a report refresh: repeatedly, from
* the moment the frame is created, so one copy lands after the app's earliest
* listener exists and before the app bootstraps. That window is the whole
* point - a single post on load reaches the metadata path instead.
*/
function startEarlyReplay(payload) {
var interval = report.intervalMs || 50
var deadline = Date.now() + (report.replayTimeoutMs || 30000)
attempts = 0
// The document on screen when this mount started. A remount to the same URL
// reloads the frame, and until the reload lands the OLD document is still
// there with the __vaLastMessage it captured on the previous mount - reading
// that would log a capture this mount never made. A capture only counts once
// the document has been replaced.
mountDocument = FRAME.contentDocument || null
log('early replay: posting every ' + interval + 'ms until the app captures it')
earlyTimer = setInterval(function () {
// The capture and the deadline are both tested before the frame guard: a
// detached or mid-navigation frame leaves contentWindow null, and a bare
// return there would skip them and keep this timer alive for the life of
// the page. A capture only counts once something has been posted in this
// replay, so a buffer left by the previous load of the same frame cannot
// be read as one that belongs to this mount.
if (attempts > 0 && frameBuffered() && FRAME.contentDocument !== mountDocument) {
log('early replay: the app captured the message after ' + attempts + ' post(s)')
stopEarlyReplay()
return
}
if (Date.now() > deadline) {
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'
)
if (attempts === 0) {
log('early replay: the frame was never postable, so nothing was sent')
}
stopEarlyReplay()
return
}
if (!FRAME.contentWindow) return
attempts++
postToFrame(payload)
}, interval)
}
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 ' + url)
log('sandbox: ' + (sandbox || '(none)'))
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')
}
}
/** Post once into the frame that is already up - a prompt change. */
function postOnce(payload) {
if (!frameLoaded) {
log('the frame has not loaded yet, so a prompt change has nowhere to land')
return
}
postToFrame(payload)
log('posted one message: ' + JSON.stringify(payload).slice(0, 200))
}
function apply() {
if (!report) return
var payload = message()
var timing = timingSelect.value
log('prompts: ' + JSON.stringify(payload.parameters))
if (modeSelect.value === 'prompt') postOnce(payload)
else mount(payload, timing)
}
function renderPrompts() {
PROMPTS.textContent = ''
promptNames().forEach(function (parameter) {
var label = document.createElement('label')
label.textContent = parameter.label
var select = document.createElement('select')
select.setAttribute('data-name', parameter.name)
;(parameter.options || []).forEach(function (option) {
var element = document.createElement('option')
element.value = option
element.textContent = option
if (option === parameter.default) element.selected = true
select.appendChild(element)
})
label.appendChild(select)
PROMPTS.appendChild(label)
})
}
FRAME.addEventListener('load', function () {
frameLoaded = true
// A capture that lands in the last few milliseconds before load would never
// be seen by the interval, and the log would read as though the early path
// failed when it had not. Check once here, and log the capture BEFORE the
// load line so the ordering in the log is the ordering that happened.
if (earlyTimer !== null && frameBuffered() && FRAME.contentDocument !== mountDocument) {
log(
'early replay: the app captured the message after ' + attempts + ' post(s)'
)
}
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) {
// Taken, not read: a later load event on the same frame - the app
// navigating, or a self-reload - must not re-post a filter that belonged
// to the mount which has already had its delivery.
var delivered = pendingPayload
var deliveredTiming = pendingTiming
pendingPayload = null
pendingTiming = null
postToFrame(delivered)
log(
'posted on load (timing=' +
deliveredTiming +
'): ' +
JSON.stringify(delivered).slice(0, 200)
)
}
})
window.addEventListener('message', function (event) {
if (event.source !== FRAME.contentWindow) return
log(
'from frame: ' +
(typeof event.data === 'object'
? JSON.stringify(event.data)
: String(event.data)
).slice(0, 300)
)
})
document.getElementById('apply').addEventListener('click', apply)
document.getElementById('clear').addEventListener('click', function () {
LOG.textContent = ''
})
fetch('report.json')
.then(function (response) {
if (!response.ok) throw new Error('report.json answered ' + response.status)
return response.json()
})
.then(function (config) {
report = config
targetOrigin = frameOrigin()
if (!targetOrigin) {
log('report.server is not a URL, so nothing can be posted: ' + JSON.stringify(report.server))
} else if (window.location.origin !== targetOrigin) {
// the probe reads the frame's own global, which a cross-origin frame
// does not expose - say so rather than logging a capture that never was
log('warning: this page is on ' + window.location.origin + ' and the frame on ' + targetOrigin + ' - the capture probe cannot read across origins')
}
heading.textContent = report.title || 'VA report emulation'
document.title = report.title || 'VA report emulation'
timingSelect.value = report.timing || 'both'
modeSelect.value = report.mode || 'refresh'
renderPrompts()
log('report: ' + (report.title || '(untitled)') + ' -> ' + report.app)
// A page that renders a report should show one without a click, the way
// opening the report does.
apply()
})
.catch(function (error) {
log('could not load report.json: ' + error.message)
})
})()
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>VA report emulation</title>
<!--
The report config lives next to this page. It is fetched rather than
inlined so a report can be changed without touching the page, and because
the SASjs Server CSP allows scripts from 'self' only (an inline script, or
an onclick attribute, is refused by the browser).
-->
<script src="host.js" defer></script>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 16px 20px 40px;
color: #1c1c1c;
background: #f6f7f9;
}
h1 {
font-size: 18px;
margin: 0 0 4px;
}
.hint {
margin: 0 0 12px;
color: #555;
font-size: 12px;
}
#prompts {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
padding: 12px;
background: #fff;
border: 1px solid #dfe3e8;
border-radius: 6px;
}
#prompts label {
display: flex;
flex-direction: column;
font-size: 12px;
gap: 4px;
}
select,
button {
font: inherit;
font-size: 13px;
padding: 4px 6px;
}
#controls {
margin: 10px 0;
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
}
#frame {
width: 100%;
height: 620px;
border: 1px solid #dfe3e8;
border-radius: 6px;
background: #fff;
}
#log {
margin-top: 10px;
max-height: 160px;
overflow: auto;
padding: 8px;
font: 12px/1.45 ui-monospace, monospace;
background: #101418;
color: #d6e2ea;
border-radius: 6px;
}
</style>
</head>
<body>
<h1 id="heading">VA report emulation</h1>
<p class="hint">
Parent prompts stand in for the report's dropdowns; the embedded object is
the Data Controller app, framed the way SAS Visual Analytics frames it.
</p>
<div id="prompts"></div>
<div id="controls">
<label>
delivery
<select id="mode">
<option value="refresh">report refresh (reload the frame)</option>
<option value="prompt">prompt change (post into the frame)</option>
</select>
</label>
<label>
timing
<select id="timing">
<option value="both">both</option>
<option value="early">early</option>
<option value="load">load</option>
</select>
</label>
<button id="apply" type="button">Apply prompts</button>
<button id="clear" type="button">Clear log</button>
</div>
<iframe
id="frame"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
title="Data Controller, embedded"
></iframe>
<pre id="log"></pre>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"title": "VA report emulation - Data Controller embed",
"server": "http://localhost:5000",
"app": "clickme",
"table": "DC_JSLIB.MPE_X_TEST",
"mode": "refresh",
"timing": "both",
"sandbox": "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals",
"intervalMs": 50,
"replayTimeoutMs": 30000,
"parameters": [
{
"name": "pr1",
"label": "Some dropdown",
"dataType": "string",
"column": "SOME_DROPDOWN",
"options": ["Option 1", "Option 2", "Option 3"],
"default": "Option 2"
},
{
"name": "pr2",
"label": "Some date",
"dataType": "string",
"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"
}
]
}