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
Reviewed-on: #452
This commit was merged in pull request #452.
This commit is contained in:
commit
1a31bcf244
5 files changed
+969
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})()
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user