mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: record and replay raw app sessions step by step (#10318)
* feat: record and replay raw app sessions step by step * fix: address review findings on raw app session recorder * fix: stamp replay target before pruning the snapshot clone * fix: redact step metadata, lock down replayed frames, fix control pre-state * feat: add a checkpoint timeline to the app recording player * fix: parser-based replay CSP, fold label clicks, drop stale frame indices * fix: scrub redacted attributes, keep scroll, neutralize replay navigation * fix: bound replay payloads, strip namespaced nav links, keep control pre-frames * fix: strip SMIL navigation, redact metadata sources, capture pre-edit on beforeinput * fix: redact template content, drop shadow templates, make replays inert * test: pin snapshot redaction and replay sanitization with DOM tests * fix: allow-list no-record attributes and cover a marked document root * fix: classify input types positively so pickers get pre-change frames * fix: one step per control interaction and bound step metadata * fix: keep button inputs recordable and coalesce only continuous controls * fix: no frames for coalesced repeats and drop inline styles when redacting * fix: fold only the label's own click and keep marked stylesheets out * fix: keep label-forwarded and radio-group pre-frames, fold submitter clicks * fix: bound key pre-frames to their gesture and clear ancestor pointer frames * fix: age-bound pre-frames and treat a radio group as one target * fix: consume pre-frames per interaction and coalesce on the browser repeat flag * fix: spend only the pre-frame a step actually used * fix: settle a step from its successor's pre-state and drop stale pointer frames * fix: bound remote frame payloads and snapshot stylesheets as rendered * fix: let a control change spend its own frame and dedupe Enter activations * fix: record Escape on controls and drop disabled stylesheets * feat: collapse the replay step list by default behind a toggle * fix: neutralize disabled sheets in place and fold Enter submissions * fix: withhold redacted control state, fold key repeats, validate remote metadata * fix: drop noscript markup and fold implicit form submissions * fix: mask a select whose chosen option is redacted * fix: mask redacted select choices before the clone diverges * fix: run clone-paired passes before removals and fold only Enter submissions * feat: record a raw app demo from the publish flow instead of the viewer * fix: wait for in-flight runnable jobs before settling a step * feat: record from the editor menu and replay publicly at /replay * feat: export the app recording player and its loader for the hub * feat: publish from folders only, drop iframe sharing * fix: observe runnable responses where they land and mount the hub recording route * fix: respect the app's sandbox opt-in when recording a session * fix: let stop wait for the runnable the last step is still running * fix: filter redacted class/id to styled tokens and gate publish on admin * fix: drop marked sheets from the token vocabulary and bound the replay error * test: pin the remote app-recording validator * fix: carry in-flight runnables across a reload and fold held keys into one step * fix: bind runnable responses off the request and honor base in the replay handoff * fix: close the settling step when a new fill starts and always re-read stylesheets * fix: empty the no-record marker so it carries nothing of its own * fix: decode css escapes so utility classes survive redaction * fix: read keyDriven from the frame the change starts from * docs: condense recorder comments to the invariant each protects * fix: rewrite only real url() tokens and accept leading css escapes * feat: play flow, script and pipeline recordings on the public /replay page (#10327) * feat: play flow, script and pipeline recordings on the public /replay page * fix: render a recorded approval result inert while replaying * fix: bound an asset sample's cell product and validate recording headers * fix: make a replayed approval step inert and bound nested recording structures * fix: stop recorded markup from fetching and bound flow/script render trees * fix: gate recorded markdown at its renderer and close remaining render-budget gaps * fix: replace per-key render caps with one structural budget per recorded value * fix: bound component fan-out and text alongside the structural budget * fix: make component fan-out cumulative and cap the parsed data-test checklist * fix: bound the whole recording, graph contents, metadata strings and timer bursts * fix: keep the published loader path, charge object keys, refuse huge serialized fan-out * fix: cap flat maps a renderer turns into rows (args, schema properties) * fix: refuse structure hidden past the depth ceiling and bound errored samples * fix: count array-shaped argument collections against the row cap * feat: paint canvas pixels into the snapshot * fix: budget canvas encoding per snapshot and bound the unknown-kind error * fix: cap flow graph overlay fan-out and condense budget comments * docs: teach the raw-app prompt about data-wm-no-record
This commit is contained in:
@@ -23318,6 +23318,40 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/raw_apps/{id}/recording:
|
||||
post:
|
||||
summary: attach a recorded session to a hub raw app
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Forwards the request to the
|
||||
configured Hub scoped to the `{workspace}:{folder}` source and returns
|
||||
the Hub's status code and raw response body.
|
||||
operationId: publishHubRawAppRecording
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: hub id of the raw app
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecordingBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/scripts/{ask_id}/recording:
|
||||
post:
|
||||
summary: attach a recording to a hub script
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/apps", post(publish_app))
|
||||
.route("/raw_apps", post(publish_raw_app))
|
||||
.route("/raw_apps/{id}/embed", post(publish_raw_app_embed))
|
||||
.route("/raw_apps/{id}/recording", post(publish_raw_app_recording))
|
||||
.route(
|
||||
"/scripts/{ask_id}/recording",
|
||||
post(publish_script_recording),
|
||||
@@ -327,6 +328,15 @@ struct RecordingBody {
|
||||
project_slug: ProjectSlug,
|
||||
}
|
||||
|
||||
async fn publish_raw_app_recording(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, id)): Path<(String, i64)>,
|
||||
Json(body): Json<RecordingBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
ctx.post(&format!("/raw_apps/{}/recording", id), &body)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish_script_recording(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, ask_id)): Path<(String, i64)>,
|
||||
|
||||
@@ -5719,6 +5719,18 @@ const user = await backend.get_user({ user_id: '123' });
|
||||
|
||||
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.<key>(args)\` for everything server-side.
|
||||
|
||||
### Keeping data out of recorded demos
|
||||
|
||||
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
|
||||
|
||||
\`\`\`tsx
|
||||
<label data-wm-no-record>
|
||||
Customer SSN <input value={ssn} onChange={onSsn} />
|
||||
</label>
|
||||
\`\`\`
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
@@ -5821,6 +5833,7 @@ def main(user_id: str):
|
||||
3. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
4. **Use descriptive keys** — \`get_user\`, not \`a\`.
|
||||
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
|
||||
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
`,
|
||||
"triggers": `---
|
||||
name: triggers
|
||||
|
||||
Generated
+589
-52
@@ -133,6 +133,7 @@
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"json-refs": "^3.0.15",
|
||||
"json-schema-to-zod": "^2.7.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
@@ -231,6 +232,173 @@
|
||||
"url": "https://github.com/sponsors/philsturgeon"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@aws-crypto/sha256-js": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-4.0.0.tgz",
|
||||
@@ -329,6 +497,40 @@
|
||||
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity/node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity/node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
@@ -806,6 +1008,26 @@
|
||||
"@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz",
|
||||
@@ -880,7 +1102,6 @@
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -892,7 +1113,6 @@
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -903,7 +1123,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1010,6 +1229,24 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
@@ -1419,7 +1656,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1568,7 +1804,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1585,7 +1820,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1602,7 +1836,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1619,7 +1852,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1636,7 +1868,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1653,7 +1884,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1670,7 +1900,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1687,7 +1916,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1704,7 +1932,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1721,7 +1948,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1738,7 +1964,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1755,7 +1980,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1772,7 +1996,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1791,7 +2014,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1808,7 +2030,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2114,7 +2335,6 @@
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -3427,6 +3647,16 @@
|
||||
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -4915,6 +5145,58 @@
|
||||
"lodash-es": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
@@ -5008,6 +5290,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
@@ -6942,6 +7231,19 @@
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-tags": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
|
||||
@@ -7267,6 +7569,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
@@ -7350,7 +7659,7 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
@@ -7392,6 +7701,167 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/jsdom/node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -7885,7 +8355,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7906,7 +8375,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7927,7 +8395,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7948,7 +8415,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7969,7 +8435,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7990,7 +8455,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8011,7 +8475,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8032,7 +8495,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8053,7 +8515,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8074,7 +8535,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8095,7 +8555,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8229,10 +8688,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
|
||||
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
|
||||
"license": "ISC",
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
@@ -11847,6 +12306,19 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -12789,21 +13261,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-eslint-parser": {
|
||||
"version": "0.43.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
|
||||
@@ -13072,6 +13529,13 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
@@ -13382,6 +13846,26 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.9",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
|
||||
"integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.9"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.9",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
|
||||
"integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -13405,6 +13889,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
@@ -13543,7 +14040,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -14257,6 +14754,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
@@ -14279,6 +14789,16 @@
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
@@ -14551,12 +15071,29 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"json-refs": "^3.0.15",
|
||||
"json-schema-to-zod": "^2.7.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
@@ -309,10 +310,23 @@
|
||||
"svelte": "./package/components/recording/PipelineRecordingReplay.svelte",
|
||||
"default": "./package/components/recording/PipelineRecordingReplay.svelte"
|
||||
},
|
||||
"./components/RawAppRecordingReplay.svelte": {
|
||||
"types": "./package/components/recording/RawAppRecordingReplay.svelte.d.ts",
|
||||
"svelte": "./package/components/recording/RawAppRecordingReplay.svelte",
|
||||
"default": "./package/components/recording/RawAppRecordingReplay.svelte"
|
||||
},
|
||||
"./components/recording/types": {
|
||||
"types": "./package/components/recording/types.d.ts",
|
||||
"default": "./package/components/recording/types.js"
|
||||
},
|
||||
"./components/recording/rawAppRecordingLoad": {
|
||||
"types": "./package/components/recording/rawAppRecordingLoad.d.ts",
|
||||
"default": "./package/components/recording/rawAppRecordingLoad.js"
|
||||
},
|
||||
"./components/recording/rawAppSnapshot": {
|
||||
"types": "./package/components/recording/rawAppSnapshot.d.ts",
|
||||
"default": "./package/components/recording/rawAppSnapshot.js"
|
||||
},
|
||||
"./components/FlowWrapper.svelte": {
|
||||
"types": "./package/components/FlowWrapper.svelte.d.ts",
|
||||
"svelte": "./package/components/FlowWrapper.svelte",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
import { getContext, hasContext, createEventDispatcher, onDestroy, untrack } from 'svelte'
|
||||
import { toJsonStr } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { isOfflineReplay, isReplaying } from './recording/offlineReplay.svelte'
|
||||
import ResultStreamDisplay from './ResultStreamDisplay.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import DOMPurify from 'dompurify'
|
||||
@@ -83,6 +84,16 @@
|
||||
| 'pdf'
|
||||
| undefined
|
||||
let resultKind: ResultKind = $state()
|
||||
/** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or
|
||||
* table, and `approval` renders buttons that `fetch` URLs carried in the result.
|
||||
* A recording is caller-supplied, so on the public page those would aim a
|
||||
* credentialed request at an arbitrary origin. */
|
||||
const REPLAY_INERT_KINDS: ResultKind[] = ['s3object', 's3object-list', 'materialized', 'approval']
|
||||
/** Kinds whose markup pulls subresources: DOMPurify stops scripting but keeps
|
||||
* `<img src>` and SVG `<image href>`, and `map` tiles are requests by
|
||||
* construction. Kinds absent here carry their bytes as `data:` and reach nothing.
|
||||
* Inert only on the public page, which promises to issue no requests. */
|
||||
const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map']
|
||||
let length = $state(1)
|
||||
|
||||
let hasBigInt = $state(false)
|
||||
@@ -350,7 +361,10 @@
|
||||
keys.includes('filename') &&
|
||||
keys.includes('autodownload')
|
||||
) {
|
||||
if (result.autodownload) {
|
||||
// Not via REPLAY_INERT_KINDS: this download is a side effect *inside* kind
|
||||
// inference, already done by the time a caller could reclassify. Replaying
|
||||
// must never write caller-chosen bytes into the viewer's downloads.
|
||||
if (result.autodownload && !isReplaying()) {
|
||||
const a = document.createElement('a')
|
||||
|
||||
a.href = 'data:application/octet-stream;base64,' + result.file
|
||||
@@ -583,8 +597,19 @@
|
||||
|
||||
$effect(() => {
|
||||
;[result]
|
||||
const replaying = isReplaying()
|
||||
const offlineReplay = isOfflineReplay()
|
||||
untrack(() => {
|
||||
resultKind = inferResultKind(result)
|
||||
// A recording carries the result JSON, nothing the result points at, and a
|
||||
// replay has no session to go get it: show the recorded value instead.
|
||||
const inert =
|
||||
(replaying && REPLAY_INERT_KINDS.includes(resultKind)) ||
|
||||
(offlineReplay && OFFLINE_INERT_KINDS.includes(resultKind))
|
||||
if (inert) {
|
||||
resultKind = 'json'
|
||||
largeObject = false
|
||||
}
|
||||
})
|
||||
})
|
||||
$effect(() => {
|
||||
@@ -600,19 +625,27 @@
|
||||
)
|
||||
})
|
||||
|
||||
// Per-test breakdown of a managed `// materialize` run, rendered as a
|
||||
// checklist above the raw result. On success it rides the result
|
||||
// (`data_tests: [{ test, violating }]`, a one-row array). On failure the job
|
||||
// result is the error, whose message is the worker's breakdown text — parsed
|
||||
// back into the same shape so the checklist shows on both outcomes. Both
|
||||
// formats are produced by this repo's worker (see duckdb_executor.rs); the
|
||||
// derivation is inert (undefined) for every other DisplayResult use.
|
||||
// Per-test breakdown of a managed `// materialize` run. On success it rides the
|
||||
// result as `data_tests`; on failure the job result is the error, whose message
|
||||
// is the worker's breakdown text, parsed back into the same shape so the
|
||||
// checklist shows either way. Inert for every other DisplayResult use.
|
||||
let dataTests = $derived.by(() => {
|
||||
// Both structured shapes carry `[{ test, violating, sample? }]`; the
|
||||
// sample (bounded violating-row rows) may arrive as a JSON string (the
|
||||
// worker keeps it string-typed through the summary row) and is optional
|
||||
// by contract — anything malformed degrades to no sample, never to a
|
||||
// dropped checklist.
|
||||
// `DataTestsResult` renders an item per entry, and the message-derived branch
|
||||
// below builds them from *lines of text*, so no bound on the result's structure
|
||||
// can see them. A run with this many tests is unreadable anyway, and the cap has
|
||||
// to live where the parse happens rather than be predicted from the payload.
|
||||
const MAX_RENDERED = 1000
|
||||
// A per-test `sample` arrives as its own JSON string, so nothing that measures
|
||||
// the enclosing result's structure can see inside it — parsing an 8 MB string of
|
||||
// `{}` would allocate millions of objects before any row cap applied. Bound the
|
||||
// text first, then the rows.
|
||||
const MAX_SAMPLE_CHARS = 256 * 1024
|
||||
const MAX_SAMPLE_ROWS = 1000
|
||||
const capped = <T,>(tests: T[]): T[] =>
|
||||
tests.length > MAX_RENDERED ? tests.slice(0, MAX_RENDERED) : tests
|
||||
// Both shapes carry `[{ test, violating, sample? }]`. The sample may arrive as
|
||||
// a JSON string and is optional by contract, so anything malformed degrades to
|
||||
// no sample — never to a dropped checklist.
|
||||
const normalize = (
|
||||
dt: any
|
||||
): Array<{ test: string; violating: number; sample?: Record<string, any>[] }> | undefined => {
|
||||
@@ -634,13 +667,15 @@
|
||||
let sample = x.sample
|
||||
if (typeof sample === 'string') {
|
||||
try {
|
||||
sample = JSON.parse(sample)
|
||||
sample = sample.length > MAX_SAMPLE_CHARS ? undefined : JSON.parse(sample)
|
||||
} catch {
|
||||
sample = undefined
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(sample) || !sample.every((r) => r && typeof r === 'object')) {
|
||||
sample = undefined
|
||||
} else if (sample.length > MAX_SAMPLE_ROWS) {
|
||||
sample = sample.slice(0, MAX_SAMPLE_ROWS)
|
||||
}
|
||||
return { test: x.test, violating: x.violating, sample }
|
||||
})
|
||||
@@ -648,17 +683,18 @@
|
||||
// Success: structured column on the summary row.
|
||||
const row = Array.isArray(result) ? (result as any)?.[0] : (result as any)
|
||||
const fromRow = normalize(row?.data_tests)
|
||||
if (fromRow) return fromRow
|
||||
if (fromRow) return capped(fromRow)
|
||||
// Failure: the worker attaches the same structured breakdown (plus
|
||||
// per-failed-test samples) to the error payload.
|
||||
const fromError = normalize((result as any)?.error?.data_tests)
|
||||
if (fromError) return fromError
|
||||
if (fromError) return capped(fromError)
|
||||
// Failure fallback for results predating the structured error payload:
|
||||
// parse the worker's breakdown out of the error message.
|
||||
const msg = (result as any)?.error?.message
|
||||
if (typeof msg === 'string' && msg.includes('data tests failed on')) {
|
||||
const out: Array<{ test: string; violating: number }> = []
|
||||
for (const line of msg.split('\n')) {
|
||||
if (out.length >= MAX_RENDERED) break
|
||||
const fail = line.match(/^\s*✗\s*(.+?)\s*—\s*(\d+)\s+violating/)
|
||||
const pass = line.match(/^\s*✓\s*(.+?)\s*$/)
|
||||
if (fail) out.push({ test: fail[1], violating: parseInt(fail[2], 10) })
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SchemaForm from './SchemaForm.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { untrack } from 'svelte'
|
||||
import { isReplaying } from './recording/offlineReplay.svelte'
|
||||
|
||||
interface Props {
|
||||
isOwner: boolean
|
||||
@@ -82,68 +83,80 @@
|
||||
}
|
||||
}
|
||||
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
|
||||
// Everything this panel shows (description, resume form enums, approval page) is
|
||||
// fetched from the suspended job — a recording carries none of it — and Resume /
|
||||
// Cancel act on a job that only exists in the recording. So a replay states the
|
||||
// recorded fact and offers nothing to click.
|
||||
let replaying = $derived(isReplaying())
|
||||
$effect(() => {
|
||||
job && untrack(() => getDefaultArgs())
|
||||
if (!replaying) {
|
||||
job && untrack(() => getDefaultArgs())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full text-xs text-primary">
|
||||
{#if description != undefined}
|
||||
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
|
||||
<div class="mt-2"></div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
{#if replaying}
|
||||
<div class="w-full text-xs text-secondary">This step was waiting for approval.</div>
|
||||
{:else}
|
||||
<div class="w-full h-full text-xs text-primary">
|
||||
{#if description != undefined}
|
||||
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
|
||||
<div class="mt-2"></div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
<div>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
disabled={loading || actionTaken}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
variant="accent"
|
||||
onClick={() => continu(true)}
|
||||
disabled={loading || actionTaken}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
>
|
||||
Resume
|
||||
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => continu(true)}
|
||||
disabled={loading || actionTaken}
|
||||
unifiedSize="md"
|
||||
>
|
||||
Resume
|
||||
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
|
||||
</Button>
|
||||
|
||||
{#if approvalPageUrl}
|
||||
<a
|
||||
href={approvalPageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-accent flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
Approval page <ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume`
|
||||
variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if approvalPageUrl}
|
||||
<a
|
||||
href={approvalPageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-accent flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
Approval page <ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume` variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Markdown } from 'svelte-exmarkdown'
|
||||
import { markdownPlugins as plugins } from './markdownPlugins'
|
||||
import { isOfflineReplay } from './recording/offlineReplay.svelte'
|
||||
interface Props {
|
||||
md: string
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
let { md, noPadding }: Props = $props()
|
||||
|
||||
// Rendering markdown turns `` into a real `<img>`, i.e. a request. On the
|
||||
// public replay page the source is a recording from an arbitrary origin and the
|
||||
// page promises to issue none, so show the text instead. Gated here rather than
|
||||
// at each call site because every recorded annotation (flow notes, group notes,
|
||||
// step descriptions) reaches markdown through this one component.
|
||||
let asPlainText = $derived(isOfflineReplay())
|
||||
</script>
|
||||
|
||||
<div class="!prose-xs {noPadding ? '' : 'pgap'}">
|
||||
<Markdown {md} {plugins} />
|
||||
{#if asPlainText}
|
||||
<p class="whitespace-pre-wrap">{md}</p>
|
||||
{:else}
|
||||
<Markdown {md} {plugins} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style global>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
|
||||
import { JobService } from '$lib/gen'
|
||||
import { WM_LOGS_SKIPPED } from '$lib/consts'
|
||||
import { isReplaying } from './recording/offlineReplay.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import QueuePosition from './QueuePosition.svelte'
|
||||
@@ -87,6 +88,11 @@
|
||||
|
||||
let loadedFromObjectStore = $state('')
|
||||
|
||||
// A replayed job only exists inside the recording: its logs are whatever was
|
||||
// captured, so every path that would go ask the backend for more has to stay
|
||||
// shut. `jobId` still comes through for the reset-on-change bookkeeping.
|
||||
let replaying = $derived(isReplaying())
|
||||
|
||||
// `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with
|
||||
// no_logs=true. If an older in-memory value accidentally has real bytes
|
||||
// concatenated after the sentinel, treat that as skipped too and refetch.
|
||||
@@ -94,10 +100,12 @@
|
||||
let resolvedSkippedLogs: string | undefined = $state(undefined)
|
||||
let fetchedSkippedJobId: string | undefined = $state(undefined)
|
||||
let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content)
|
||||
let resolvingSkippedLogs = $derived(isLogsSkipped && !!jobId && resolvedSkippedLogs === undefined)
|
||||
let resolvingSkippedLogs = $derived(
|
||||
isLogsSkipped && !!jobId && !replaying && resolvedSkippedLogs === undefined
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (!isLogsSkipped || !jobId || fetchedSkippedJobId === jobId) {
|
||||
if (!isLogsSkipped || !jobId || replaying || fetchedSkippedJobId === jobId) {
|
||||
return
|
||||
}
|
||||
const id = jobId
|
||||
@@ -248,7 +256,10 @@
|
||||
let truncatedContent = $derived(
|
||||
truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
|
||||
)
|
||||
let prefixInfo = $derived(findPrefixInfo(truncatedContent))
|
||||
// The "Show more..." button behind this reads the rest of the logs out of the
|
||||
// instance's object store, which a replay has no access to; leaving `prefixInfo`
|
||||
// unset renders the `[windmill]` line as the plain log line it is.
|
||||
let prefixInfo = $derived(replaying ? undefined : findPrefixInfo(truncatedContent))
|
||||
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
|
||||
$effect.pre(() => {
|
||||
truncatedContent && scrollToBottom()
|
||||
@@ -363,7 +374,7 @@
|
||||
<NoWorkerWithTagWarning {tagLabel} {tag} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if jobId}
|
||||
{#if jobId && !replaying}
|
||||
<QueuePosition {jobId} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import { base } from '$lib/base'
|
||||
import RawAppRecordSession from '$lib/components/workspaceSettings/RawAppRecordSession.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { isMac, userPathPrefix } from '$lib/utils'
|
||||
import { editPathFor } from '$lib/components/workspacePicker'
|
||||
@@ -10,17 +12,13 @@
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
Bug,
|
||||
DiffIcon,
|
||||
EllipsisVertical,
|
||||
FileJson,
|
||||
Circle,
|
||||
History,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
@@ -290,6 +288,7 @@
|
||||
|
||||
let saveDrawerOpen = $state(false)
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let recordDrawer = $state<Drawer | undefined>(undefined)
|
||||
let deploymentMsg: string | undefined = $state(undefined)
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
@@ -577,6 +576,14 @@
|
||||
displayName: 'Edit in YAML',
|
||||
icon: FileJson,
|
||||
action: () => onOpenYamlEditor?.()
|
||||
},
|
||||
{
|
||||
displayName: 'Record demo',
|
||||
icon: Circle,
|
||||
action: () => {
|
||||
recordDrawer?.openDrawer()
|
||||
},
|
||||
disabled: !savedApp
|
||||
}
|
||||
])
|
||||
|
||||
@@ -699,6 +706,41 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Full screen: the demo is recorded at the size it replays at. -->
|
||||
<Drawer bind:this={recordDrawer} size="100vw">
|
||||
<DrawerContent
|
||||
title="Record a demo — {savedApp?.path ?? appPath}"
|
||||
on:close={() => recordDrawer?.closeDrawer()}
|
||||
>
|
||||
<div class="flex flex-col h-full min-h-0 gap-2">
|
||||
<div class="text-xs text-secondary flex flex-col gap-1">
|
||||
<span>
|
||||
Use the app the way someone else would — each interaction becomes a step, and the
|
||||
recording captures the page as they would see it. Passwords are masked; add
|
||||
<span class="font-mono">data-wm-no-record</span> to anything else that should stay out.
|
||||
</span>
|
||||
<span>
|
||||
Stop recording, then <b>Download</b> the JSON. It is self-contained: open it on
|
||||
<a href="{base}/replay" target="_blank" rel="noreferrer" class="text-blue-500 underline">
|
||||
{base}/replay
|
||||
</a>
|
||||
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
|
||||
it can be fetched (S3, GitHub raw, your docs site) and link
|
||||
<span class="font-mono">{base}/replay?src=<url></span> to have it load itself. Windmill
|
||||
keeps no copy.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
{#if recordDrawer}
|
||||
<!-- opWorkspace, not the selected one: a session edits an app that may
|
||||
live in another workspace, and recording must load and run it there. -->
|
||||
<RawAppRecordSession workspace={opWorkspace ?? ''} path={savedApp?.path ?? appPath} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AppJobsDrawer
|
||||
bind:open={jobsDrawerOpen}
|
||||
on:clear={() => {
|
||||
|
||||
@@ -11,9 +11,21 @@
|
||||
secret: string | undefined
|
||||
path: string
|
||||
runnables: Record<string, Runnable>
|
||||
/** Called with the bundle's iframe once it is mounted. The session recorder
|
||||
* (publish flow) needs it to read the app's DOM, which is only possible on
|
||||
* the unsandboxed path. */
|
||||
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
|
||||
}
|
||||
|
||||
let { workspace, user, secret, path, runnables }: Props = $props()
|
||||
let { workspace, user, secret, path, runnables, oniframe }: Props = $props()
|
||||
|
||||
$effect(() => {
|
||||
const el = unsandboxed ? iframe : undefined
|
||||
oniframe?.(el)
|
||||
// Withdraw it on teardown: a consumer that keeps the reference (the session
|
||||
// recorder) would otherwise go on addressing a document that is gone.
|
||||
return () => oniframe?.(undefined)
|
||||
})
|
||||
|
||||
let iframe = $state() as HTMLIFrameElement | undefined
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Player for a raw-app session recording: walks the captured interactions one
|
||||
* step at a time, rendering each step's DOM snapshot in a scripting-disabled
|
||||
* iframe with the element the user acted on highlighted.
|
||||
*/
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
CircleDot,
|
||||
InfoIcon,
|
||||
Keyboard,
|
||||
ListChecks,
|
||||
MousePointerClick,
|
||||
Navigation,
|
||||
Pause,
|
||||
Play,
|
||||
SendHorizonal,
|
||||
TextCursorInput,
|
||||
ToggleLeft,
|
||||
TriangleAlert
|
||||
} from 'lucide-svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { withHighlightStyles, type RawAppInteractionKind } from './rawAppSnapshot'
|
||||
import type { RawAppRecording } from './types'
|
||||
|
||||
let { recording }: { recording: RawAppRecording } = $props()
|
||||
|
||||
/** 0 = the app as it was when recording started; 1..n = after step n-1 fired. */
|
||||
let stepIndex = $state(0)
|
||||
/** Within a step: the DOM the user acted on, then the DOM the app settled into. */
|
||||
let phase = $state<'before' | 'after'>('before')
|
||||
let playing = $state(false)
|
||||
let paneWidth = $state(0)
|
||||
/** The step list is navigation; the app is the content. Closed by default so a
|
||||
* replay opens at nearly the recorded width, with the timeline and the step bar
|
||||
* still saying where you are. */
|
||||
let listOpen = $state(false)
|
||||
let stepList: HTMLElement | undefined = $state(undefined)
|
||||
|
||||
const KIND_ICONS: Record<RawAppInteractionKind, any> = {
|
||||
click: MousePointerClick,
|
||||
fill: TextCursorInput,
|
||||
select: ListChecks,
|
||||
toggle: ToggleLeft,
|
||||
submit: SendHorizonal,
|
||||
key: Keyboard,
|
||||
navigate: Navigation
|
||||
}
|
||||
|
||||
let steps = $derived(recording.steps ?? [])
|
||||
let step = $derived(stepIndex > 0 ? steps[stepIndex - 1] : undefined)
|
||||
let frameIdx = $derived(
|
||||
step ? (phase === 'after' ? (step.after ?? step.before) : (step.before ?? step.after)) : 0
|
||||
)
|
||||
let html = $derived(frameIdx !== undefined ? recording.frames?.[frameIdx] : undefined)
|
||||
let srcdoc = $derived(html !== undefined ? withHighlightStyles(html) : undefined)
|
||||
|
||||
// Belt and braces with the loader's validation: these numbers land in a `style`
|
||||
// string, and the player also renders recordings handed to it directly.
|
||||
function size(v: unknown, fallback: number): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 20000
|
||||
? Math.round(v)
|
||||
: fallback
|
||||
}
|
||||
let frameWidth = $derived(size(recording.viewport?.width, 1280))
|
||||
let frameHeight = $derived(size(recording.viewport?.height, 800))
|
||||
|
||||
let scale = $derived(paneWidth ? Math.min(1, paneWidth / frameWidth) : 1)
|
||||
|
||||
/** Left offset (%) of each step's checkpoint on the timeline: its recorded
|
||||
* time, then a spreading pass so bursts of fast interactions stay clickable. */
|
||||
let marks = $derived.by(() => {
|
||||
const total = recording.total_duration_ms || steps[steps.length - 1]?.t || 1
|
||||
const min = steps.length > 1 ? Math.min(6, 92 / (steps.length - 1)) : 0
|
||||
let last = -Infinity
|
||||
return steps.map((s) => {
|
||||
const at = Math.max(0, Math.min(96, (s.t / total) * 92 + 4))
|
||||
const pos = Math.max(at, last + min)
|
||||
last = pos
|
||||
return Math.min(99, pos)
|
||||
})
|
||||
})
|
||||
|
||||
// Keep the current step visible: during playback the list scrolls past the
|
||||
// viewport within a few steps, and a list reopened after navigating elsewhere
|
||||
// would otherwise show the top. Reading `listOpen` re-runs this on reopen,
|
||||
// when scrolling a hidden element would have done nothing.
|
||||
$effect(() => {
|
||||
if (!listOpen) return
|
||||
const row = stepList?.querySelector(`[data-step="${stepIndex}"]`)
|
||||
row?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
function goto(index: number, at: 'before' | 'after' = 'before') {
|
||||
stepIndex = Math.max(0, Math.min(steps.length, index))
|
||||
phase = stepIndex === 0 ? 'before' : at
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
/** Auto-advance: hold on the interaction, then on its outcome for as long as
|
||||
* the user actually paused before the next one (clamped so a long think-time
|
||||
* doesn't stall the playback). */
|
||||
function schedule() {
|
||||
clearTimer()
|
||||
if (!playing) return
|
||||
if (stepIndex === 0) {
|
||||
timer = setTimeout(() => {
|
||||
goto(1)
|
||||
schedule()
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
if (phase === 'before') {
|
||||
timer = setTimeout(() => {
|
||||
phase = 'after'
|
||||
schedule()
|
||||
}, 900)
|
||||
return
|
||||
}
|
||||
if (stepIndex >= steps.length) {
|
||||
playing = false
|
||||
return
|
||||
}
|
||||
const gap = Math.min(2000, Math.max(400, steps[stepIndex].t - steps[stepIndex - 1].t))
|
||||
timer = setTimeout(() => {
|
||||
goto(stepIndex + 1)
|
||||
schedule()
|
||||
}, gap)
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (playing) {
|
||||
playing = false
|
||||
clearTimer()
|
||||
return
|
||||
}
|
||||
if (stepIndex >= steps.length && phase === 'after') goto(0)
|
||||
playing = true
|
||||
schedule()
|
||||
}
|
||||
|
||||
function step_(index: number) {
|
||||
playing = false
|
||||
clearTimer()
|
||||
goto(index)
|
||||
}
|
||||
|
||||
onDestroy(clearTimer)
|
||||
|
||||
function fmtTime(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-0 gap-2">
|
||||
<div class="flex items-center justify-between gap-2 shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-lg font-semibold text-emphasis truncate">
|
||||
{recording.app_path || 'Untitled app'}
|
||||
</h2>
|
||||
<span class="text-xs text-secondary px-2 py-0.5 bg-surface-secondary rounded shrink-0">
|
||||
{steps.length} step{steps.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Tooltip placement="bottom">
|
||||
<InfoIcon size={16} class="text-tertiary" />
|
||||
{#snippet text()}
|
||||
<span class="text-2xs">
|
||||
{recording.workspace ? `${recording.workspace} · ` : ''}Recorded {new Date(
|
||||
recording.recorded_at
|
||||
).toLocaleString()} ·
|
||||
{fmtTime(recording.total_duration_ms)} ·
|
||||
{recording.viewport?.width}×{recording.viewport?.height}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{#if recording.truncated}
|
||||
<span class="flex items-center gap-1 text-2xs text-yellow-600 dark:text-yellow-400">
|
||||
<TriangleAlert size={14} /> recording truncated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
iconOnly
|
||||
title={listOpen ? 'Hide steps' : 'Show steps'}
|
||||
startIcon={{ icon: listOpen ? PanelLeftClose : PanelLeftOpen }}
|
||||
onclick={() => (listOpen = !listOpen)}
|
||||
/>
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: ChevronLeft }}
|
||||
disabled={stepIndex === 0}
|
||||
onclick={() => step_(stepIndex - 1)}
|
||||
/>
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
startIcon={{ icon: playing ? Pause : Play }}
|
||||
disabled={steps.length === 0}
|
||||
onclick={togglePlay}
|
||||
>
|
||||
{playing ? 'Pause' : 'Play'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: ChevronRight }}
|
||||
disabled={stepIndex >= steps.length}
|
||||
onclick={() => step_(stepIndex + 1)}
|
||||
/>
|
||||
{#if step}
|
||||
<div class="ml-1">
|
||||
<ToggleButtonGroup
|
||||
selected={phase}
|
||||
on:selected={(e) => {
|
||||
// The group also fires when playback advances the phase itself;
|
||||
// only a real click (a phase we are not already on) pauses.
|
||||
if (e.detail === phase) return
|
||||
playing = false
|
||||
clearTimer()
|
||||
phase = e.detail
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="before" label="Interaction" {item} />
|
||||
<ToggleButton size="sm" value="after" label="Result" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline: one checkpoint per interaction, placed at the time it happened.
|
||||
Clicking a checkpoint jumps the player to that step. -->
|
||||
<div class="shrink-0 px-2 pt-1 pb-4">
|
||||
<div class="relative h-6">
|
||||
<div class="absolute inset-x-0 top-3 h-px bg-surface-selected"></div>
|
||||
<div
|
||||
class="absolute left-0 top-3 h-px bg-blue-500 transition-all"
|
||||
style="width: {stepIndex === 0 ? 0 : (marks[stepIndex - 1] ?? 0)}%"
|
||||
></div>
|
||||
<button
|
||||
class="absolute top-1.5 -translate-x-1/2 size-3 rounded-full border-2 {stepIndex === 0
|
||||
? 'bg-blue-500 border-blue-500'
|
||||
: 'bg-surface border-surface-selected hover:border-blue-400'}"
|
||||
style="left: 0%"
|
||||
title="Initial state"
|
||||
aria-label="Initial state"
|
||||
onclick={() => step_(0)}
|
||||
></button>
|
||||
{#each steps as s, i (i)}
|
||||
{@const active = stepIndex === i + 1}
|
||||
<button
|
||||
class="absolute -translate-x-1/2 rounded-full border-2 {active
|
||||
? 'top-1 size-4 bg-blue-500 border-blue-500'
|
||||
: 'top-1.5 size-3 border-surface-selected hover:border-blue-400 ' +
|
||||
(stepIndex > i + 1 ? 'bg-blue-200 dark:bg-blue-900' : 'bg-surface')}"
|
||||
style="left: {marks[i]}%"
|
||||
title="{i + 1}. {s.label} (+{fmtTime(s.t)})"
|
||||
aria-label="Step {i + 1}: {s.label}"
|
||||
onclick={() => step_(i + 1)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-between text-2xs text-tertiary">
|
||||
<span>0s</span>
|
||||
<span>{fmtTime(recording.total_duration_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 min-h-0 gap-2">
|
||||
<div
|
||||
bind:this={stepList}
|
||||
class="w-72 shrink-0 overflow-auto border rounded-md bg-surface-secondary {listOpen
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<button
|
||||
data-step="0"
|
||||
class="w-full text-left px-3 py-2 border-b border-l-2 flex items-center gap-2 {stepIndex ===
|
||||
0
|
||||
? 'bg-surface border-l-blue-500'
|
||||
: 'border-l-transparent hover:bg-surface-hover'}"
|
||||
onclick={() => step_(0)}
|
||||
>
|
||||
<CircleDot size={14} class="text-tertiary shrink-0" />
|
||||
<span class="text-xs text-primary">Initial state</span>
|
||||
</button>
|
||||
{#each steps as s, i (i)}
|
||||
{@const Icon = KIND_ICONS[s.kind] ?? MousePointerClick}
|
||||
<button
|
||||
data-step={i + 1}
|
||||
class="w-full text-left px-3 py-2 border-b border-l-2 flex items-start gap-2 {stepIndex ===
|
||||
i + 1
|
||||
? 'bg-surface border-l-blue-500'
|
||||
: 'border-l-transparent hover:bg-surface-hover'}"
|
||||
onclick={() => step_(i + 1)}
|
||||
>
|
||||
<span class="text-2xs text-tertiary w-5 shrink-0 pt-0.5">{i + 1}</span>
|
||||
<Icon size={14} class="text-tertiary shrink-0 mt-0.5" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-xs text-primary break-words">{s.label}</span>
|
||||
<span class="block text-2xs text-tertiary">+{fmtTime(s.t)}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 min-w-0 overflow-auto border rounded-md bg-surface"
|
||||
bind:clientWidth={paneWidth}
|
||||
>
|
||||
{#if srcdoc !== undefined}
|
||||
<!-- A recording is untrusted markup, and `?src=` loads one from any URL,
|
||||
so the snapshot renders under the empty sandbox: no scripting, and an
|
||||
opaque origin that can't reach the viewer's Windmill session. -->
|
||||
<!-- The wrapper carries the scaled-down box; the iframe keeps the recorded
|
||||
viewport size so the app lays out exactly as it did. -->
|
||||
<div style="width: {frameWidth * scale}px; height: {frameHeight * scale}px;">
|
||||
<iframe
|
||||
title="app-snapshot"
|
||||
{srcdoc}
|
||||
sandbox=""
|
||||
class="bg-white border-none block"
|
||||
style="width: {frameWidth}px; height: {frameHeight}px; transform: scale({scale}); transform-origin: top left;"
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-sm text-secondary p-4 text-center">
|
||||
No snapshot was captured for this step.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if step}
|
||||
<div class="shrink-0 text-xs text-secondary border rounded-md px-3 py-2 bg-surface-secondary">
|
||||
<span class="font-semibold text-primary">Step {stepIndex}/{steps.length}:</span>
|
||||
{step.label}
|
||||
{#if step.selector}
|
||||
<span class="text-2xs text-tertiary font-mono ml-2">{step.selector}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Mounts a validated recording in the player its kind needs. Shared by the
|
||||
* in-workspace page and the public one so both agree on the dispatch, the
|
||||
* layout each kind wants and the render-failure fallback.
|
||||
*/
|
||||
import FlowRecordingReplay from './FlowRecordingReplay.svelte'
|
||||
import ScriptRecordingReplay from './ScriptRecordingReplay.svelte'
|
||||
import PipelineRecordingReplay from './PipelineRecordingReplay.svelte'
|
||||
import RawAppRecordingReplay from './RawAppRecordingReplay.svelte'
|
||||
import { setActiveReplay } from './flowRecording.svelte'
|
||||
import type { LoadedRecording } from './rawAppRecordingLoad'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { TriangleAlert, Upload } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
loaded: LoadedRecording
|
||||
/** How to get back to the recording picker. */
|
||||
onreset?: () => void
|
||||
/** Drop the header row that hosts the reset control, so the public page stays
|
||||
* chrome-less. The failure card still offers `onreset` — a recording that dies
|
||||
* on render must not dead-end a visitor whose URL refetches the same file. */
|
||||
hideHeader?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { loaded, onreset, hideHeader = false, class: className = '' }: Props = $props()
|
||||
|
||||
// The pipeline and app players fill the viewport (steps left, detail right,
|
||||
// like their editors); the flow/script players keep a centered scrolling page.
|
||||
let fillsViewport = $derived(loaded.kind === 'pipeline' || loaded.kind === 'app')
|
||||
|
||||
function reset() {
|
||||
setActiveReplay(undefined)
|
||||
onreset?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full',
|
||||
fillsViewport ? 'flex flex-col h-full min-h-0' : 'max-w-7xl mx-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if onreset && !hideHeader}
|
||||
<div class="flex justify-end mb-2 shrink-0">
|
||||
<Button variant="border" size="xs" onclick={reset} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={fillsViewport ? 'flex-1 min-h-0' : ''}>
|
||||
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
|
||||
{#if loaded.kind === 'app'}
|
||||
<RawAppRecordingReplay recording={loaded.recording} />
|
||||
{:else if loaded.kind === 'pipeline'}
|
||||
<PipelineRecordingReplay recording={loaded.recording} />
|
||||
{:else if loaded.kind === 'script'}
|
||||
<ScriptRecordingReplay recording={loaded.recording} />
|
||||
{:else}
|
||||
<FlowRecordingReplay recording={loaded.recording} />
|
||||
{/if}
|
||||
<!-- Shown when a malformed recording crashes on render or in an effect;
|
||||
load-time validation and the JobLoader guards cover the rest. -->
|
||||
{#snippet failed()}
|
||||
<div class="flex flex-col items-center justify-center h-full gap-2 text-center">
|
||||
<TriangleAlert class="text-red-500" size={28} />
|
||||
<p class="max-w-md text-sm text-secondary">
|
||||
This recording could not be replayed — it may be malformed or from an incompatible
|
||||
version.
|
||||
</p>
|
||||
{#if onreset}
|
||||
<Button variant="border" size="xs" onclick={reset} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Offline mode for the public replay page: the recording JSON is the whole data
|
||||
* source, so nothing may reach the backend — the visitor has no session and the
|
||||
* page may not even be served by the instance the recording came from.
|
||||
*
|
||||
* Two layers, because the player tree reaches far (LogViewer, DisplayResult,
|
||||
* FlowStatusViewer and everything they open):
|
||||
* - components call {@link isReplaying} before fetching, or before rendering
|
||||
* something whose `src`/`href` points at `/api`, so the UI degrades to the
|
||||
* recorded data instead of showing a broken state;
|
||||
* - {@link setOfflineReplay} additionally rejects every generated `*Service`
|
||||
* call at the API client, so a path nobody thought to gate still issues no
|
||||
* request. `EventSource` bypasses that client, but the only ones in the tree
|
||||
* are JobLoader's (short-circuited by `getActiveReplay`) and the recorder's.
|
||||
*/
|
||||
import { OpenAPI } from '$lib/gen'
|
||||
import { getActiveReplay } from './flowRecording.svelte'
|
||||
|
||||
let offline = $state(false)
|
||||
|
||||
function rejectRequest(): never {
|
||||
throw new Error('Offline replay: this page renders a recording and cannot call the API')
|
||||
}
|
||||
|
||||
/** True on the public page only. Use this for recorded *markup* whose rendering
|
||||
* would fetch subresources (`<img src>`, map tiles): the threat is content from an
|
||||
* arbitrary `?src=` origin on a page that promises to touch nothing, so the answer
|
||||
* is to not render it there. In-workspace the recording is one the user opened
|
||||
* themselves and the page makes no such promise, so it still renders. */
|
||||
export function isOfflineReplay(): boolean {
|
||||
return offline
|
||||
}
|
||||
|
||||
/** True whenever the UI shows recorded data rather than a live job: the whole
|
||||
* public page, or an in-workspace player while it replays a job stream. Use this
|
||||
* for anything about *staleness or side effects* — both cases want the recorded
|
||||
* value, not a fresh read (re-querying a ducklake table would answer for *now*
|
||||
* while the player replays a past run), and neither should let recorded data act. */
|
||||
export function isReplaying(): boolean {
|
||||
return offline || getActiveReplay() != undefined
|
||||
}
|
||||
|
||||
export function setOfflineReplay(on: boolean) {
|
||||
if (on === offline) return
|
||||
offline = on
|
||||
if (on) OpenAPI.interceptors.request.use(rejectRequest)
|
||||
else OpenAPI.interceptors.request.eject(rejectRequest)
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
/**
|
||||
* Raw-app session recorder: watches a same-origin app iframe and turns what the
|
||||
* user does into a step-by-step recording — one step per interaction (click,
|
||||
* fill, select, toggle, submit, key), each carrying the DOM before it and the
|
||||
* DOM once the app settled after it.
|
||||
*
|
||||
* The bundle only runs same-origin when the app is not sandbox-isolated (see
|
||||
* RawAppPreview): `start` returns false when the document can't be read, and the
|
||||
* caller surfaces that instead of silently recording nothing.
|
||||
*/
|
||||
import type { RawAppRecording, RawAppStep } from './types'
|
||||
import {
|
||||
cssSelectorFor,
|
||||
describeElement,
|
||||
isElementNode,
|
||||
isRedacted,
|
||||
isTag,
|
||||
REC_TARGET_ATTR,
|
||||
redactedDescription,
|
||||
MAX_RECORDED_STEPS,
|
||||
MAX_TOTAL_FRAME_CHARS,
|
||||
maskValue,
|
||||
serializeDocument,
|
||||
stepLabel,
|
||||
textWithoutRedacted,
|
||||
type RawAppInteractionKind
|
||||
} from './rawAppSnapshot'
|
||||
|
||||
/** A step's "after" frame is taken once mutations stop for this long… */
|
||||
const SETTLE_QUIET_MS = 400
|
||||
/** …but never later than this after the interaction (an app that animates or
|
||||
* polls forever would otherwise keep the frame pending). */
|
||||
const SETTLE_MAX_MS = 3000
|
||||
/** A step that launched a backend job waits for it instead: the DOM goes quiet
|
||||
* while the job runs, so the ordinary settle would capture the spinner as the
|
||||
* outcome. Still bounded — a job can outlive anyone's patience. */
|
||||
const SETTLE_JOB_MAX_MS = 60000
|
||||
/** Typing is one step per field, committed after this much inactivity. */
|
||||
const FILL_DEBOUNCE_MS = 800
|
||||
/** `<input>` types that act as buttons: they report a click and never a change. */
|
||||
const BUTTON_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'image'])
|
||||
/** `<input>` types whose value moves continuously, so repeats are one gesture. */
|
||||
const CONTINUOUS_INPUT_TYPES = new Set([
|
||||
'range',
|
||||
'color',
|
||||
'date',
|
||||
'time',
|
||||
'datetime-local',
|
||||
'month',
|
||||
'week'
|
||||
])
|
||||
/** `<input>` types a user types into. An input with no `type` is one of them. */
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
'',
|
||||
'text',
|
||||
'search',
|
||||
'url',
|
||||
'tel',
|
||||
'email',
|
||||
'password',
|
||||
'number'
|
||||
])
|
||||
/** A step's value is shown in a one-line label; a pasted novel is not. */
|
||||
const MAX_STEP_VALUE_CHARS = 200
|
||||
/** Repeats of the same interaction on the same control (a held arrow key, a
|
||||
* drag along a slider) are one step, not one per event. */
|
||||
const CONTROL_COALESCE_MS = 250
|
||||
/** A form submit this soon after a click inside it is that click's consequence. */
|
||||
const SUBMIT_FOLD_MS = 500
|
||||
|
||||
type PendingFill = {
|
||||
el: Element
|
||||
before: string | undefined
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export type RawAppRecordingStore = {
|
||||
readonly active: boolean
|
||||
readonly stepCount: number
|
||||
/** Stop is waiting on a runnable the last step kicked off. */
|
||||
readonly stopping: boolean
|
||||
/** Attach to a same-origin app iframe. False when its document is unreachable. */
|
||||
start(iframe: HTMLIFrameElement, opts: { appPath: string; workspace?: string }): boolean
|
||||
/** Async because a step still waiting on a backend job has to land before its
|
||||
* outcome can be captured. */
|
||||
stop(): Promise<RawAppRecording>
|
||||
download(recording: RawAppRecording): void
|
||||
}
|
||||
|
||||
export function createRawAppRecording(): RawAppRecordingStore {
|
||||
let active = $state(false)
|
||||
let stepCount = $state(0)
|
||||
|
||||
let startTime = 0
|
||||
let appPath = ''
|
||||
let workspace: string | undefined = undefined
|
||||
let iframeEl: HTMLIFrameElement | undefined = undefined
|
||||
let steps: RawAppStep[] = []
|
||||
let frames: string[] = []
|
||||
let frameIndexes = new Map<string, number>()
|
||||
let framesBytes = 0
|
||||
let truncated = false
|
||||
/** Set once an interaction was refused: from then on the recording can only
|
||||
* grow, so the expensive snapshotting stops — but the last accepted step still
|
||||
* gets its outcome. */
|
||||
let capped = false
|
||||
let viewport = { width: 0, height: 0 }
|
||||
let baseHref = ''
|
||||
|
||||
let detachers: (() => void)[] = []
|
||||
let pendingFill: PendingFill | undefined = undefined
|
||||
/** Element and time of the last recorded step, for coalescing repeats of one
|
||||
* interaction. The time is refreshed on every repeat, so a sustained gesture
|
||||
* does not split once it outlives the window. */
|
||||
let lastStepEl: Element | undefined = undefined
|
||||
let lastStepAt = 0
|
||||
/** Pre-interaction snapshot taken on pointerdown, before a click handler runs.
|
||||
* Lives until a step spends it or focus leaves what it describes. */
|
||||
let pendingPointer: { el: Element; html: string | undefined } | undefined = undefined
|
||||
/** Same, taken on keydown before the key changes the focused field or control.
|
||||
* `repeat` marks it as opening an auto-repeating gesture (a held arrow), whose
|
||||
* changes are one step rather than one per event. */
|
||||
let pendingKey: { el: Element; html: string | undefined; repeat: boolean } | undefined = undefined
|
||||
type Settle = {
|
||||
step: RawAppStep
|
||||
observer: MutationObserver
|
||||
startedAt: number
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
cap: ReturnType<typeof setTimeout>
|
||||
}
|
||||
let settle: Settle | undefined = undefined
|
||||
/** Request ids the app is waiting on. Outlives a bridge rebind: a reload's
|
||||
* bootstrap requests are seen by the outgoing watch and answered after the next
|
||||
* one binds, so clearing it there would strand them. */
|
||||
const inFlight = new Set<unknown>()
|
||||
/** Document the response listener is currently bound to, so a navigation is
|
||||
* noticed the moment the new document speaks rather than only at `load`. */
|
||||
let boundDoc: Document | undefined = undefined
|
||||
/** Runnable calls the app is waiting on right now (`inFlight.size`, as state). */
|
||||
let pendingJobs = 0
|
||||
let unwatchBridge: (() => void) | undefined = undefined
|
||||
let stopping = $state(false)
|
||||
|
||||
/** Resolve once nothing is in flight, the step's job budget is spent, or the
|
||||
* document is gone. Polled rather than driven off the bridge listener so a
|
||||
* response that never arrives still ends the wait. */
|
||||
function drainPendingJobs(startedAt: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const tick = () => {
|
||||
if (pendingJobs === 0 || Date.now() - startedAt >= SETTLE_JOB_MAX_MS || !doc()) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
setTimeout(tick, SETTLE_QUIET_MS)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
function doc(): Document | undefined {
|
||||
try {
|
||||
return iframeEl?.contentDocument ?? undefined
|
||||
} catch (_) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Store a snapshot and return its index. Only frames a step actually
|
||||
* references get here: a pending snapshot is carried as HTML until the step
|
||||
* that needs it exists, so nothing has to be garbage-collected or renumbered
|
||||
* later (and stale indices can't outlive a compaction). */
|
||||
function frameIndex(html: string | undefined): number | undefined {
|
||||
if (html === undefined) return undefined
|
||||
const existing = frameIndexes.get(html)
|
||||
if (existing !== undefined) return existing
|
||||
if (framesBytes + html.length > MAX_TOTAL_FRAME_CHARS) {
|
||||
// Nothing more can be stored, so nothing more should be serialized either.
|
||||
truncated = true
|
||||
capped = true
|
||||
return undefined
|
||||
}
|
||||
const index = frames.length
|
||||
frames.push(html)
|
||||
frameIndexes.set(html, index)
|
||||
framesBytes += html.length
|
||||
return index
|
||||
}
|
||||
|
||||
/** Serialize the app document. The result is plain HTML — it becomes a frame
|
||||
* only once a step claims it (see {@link frameIndex}). Serializing is the
|
||||
* expensive part and runs on the app's own event path, so a recording that can
|
||||
* no longer accept steps must stop doing it. */
|
||||
function capture(target?: Element | null): string | undefined {
|
||||
if (capped) return undefined
|
||||
const d = doc()
|
||||
if (!d) return undefined
|
||||
try {
|
||||
return serializeDocument(d, { target, baseHref })
|
||||
} catch (e) {
|
||||
console.warn('raw app recorder: snapshot failed', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** A pre-interaction frame reused as the previous step's outcome must not carry
|
||||
* the incoming target's highlight. */
|
||||
function unstamp(html: string): string {
|
||||
return html.replace(` ${REC_TARGET_ATTR}=""`, '')
|
||||
}
|
||||
|
||||
function clearSettle() {
|
||||
if (!settle) return
|
||||
settle.observer.disconnect()
|
||||
clearTimeout(settle.timer)
|
||||
clearTimeout(settle.cap)
|
||||
settle = undefined
|
||||
}
|
||||
|
||||
/** Snapshot the app once it stops mutating, as the step's outcome. */
|
||||
function scheduleSettle(step: RawAppStep) {
|
||||
clearSettle()
|
||||
const d = doc()
|
||||
if (!d) return
|
||||
const finish = () => {
|
||||
// A backend call is in flight: the app is waiting, not done. Settling now
|
||||
// would record the spinner as this interaction's result — the quiet period
|
||||
// cannot see the difference, because a document waiting on a job is quiet.
|
||||
if (pendingJobs > 0 && settle && Date.now() - settle.startedAt < SETTLE_JOB_MAX_MS) {
|
||||
clearTimeout(settle.timer)
|
||||
settle.timer = setTimeout(finish, SETTLE_QUIET_MS)
|
||||
return
|
||||
}
|
||||
clearSettle()
|
||||
step.after = frameIndex(capture())
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!settle) return
|
||||
clearTimeout(settle.timer)
|
||||
settle.timer = setTimeout(finish, SETTLE_QUIET_MS)
|
||||
})
|
||||
observer.observe(d, { subtree: true, childList: true, attributes: true, characterData: true })
|
||||
settle = {
|
||||
step,
|
||||
observer,
|
||||
startedAt: Date.now(),
|
||||
timer: setTimeout(finish, SETTLE_QUIET_MS),
|
||||
// The hard cap only bounds a *mutating* app; one waiting on a job is
|
||||
// bounded by SETTLE_JOB_MAX_MS in `finish` instead.
|
||||
cap: setTimeout(finish, SETTLE_MAX_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Close out the settling step: the DOM the next interaction starts from IS its
|
||||
* outcome. Must use that interaction's own pre-frame, unstamped — `capture()`
|
||||
* here would already include the new effect, and an older frame predates the
|
||||
* step being settled. */
|
||||
function settlePendingStep(before: string | undefined) {
|
||||
if (!settle) return
|
||||
const pending = settle.step
|
||||
clearSettle()
|
||||
const fresh =
|
||||
before !== undefined && (pendingPointer?.html === before || pendingKey?.html === before)
|
||||
pending.after = frameIndex(fresh ? unstamp(before) : capture())
|
||||
}
|
||||
|
||||
function pushStep(
|
||||
kind: RawAppInteractionKind,
|
||||
el: Element | undefined,
|
||||
before: string | undefined,
|
||||
value?: string,
|
||||
/** This change came from a key the browser is repeating, so it continues the
|
||||
* gesture already recorded rather than starting a new step. */
|
||||
keyDriven = false
|
||||
) {
|
||||
if (!active) return
|
||||
const t = Date.now() - startTime
|
||||
const last = steps[steps.length - 1]
|
||||
// A repeat already means "same key, still held", so it folds untimed; gating
|
||||
// it would split every held key at the OS repeat delay. The window is only for
|
||||
// a continuous control, where nothing else separates a drag from the next
|
||||
// press.
|
||||
const coalesces =
|
||||
!!last &&
|
||||
!!el &&
|
||||
sameTarget(lastStepEl, el) &&
|
||||
last.kind === kind &&
|
||||
(keyDriven || (isContinuousControl(el) && t - lastStepAt < CONTROL_COALESCE_MS))
|
||||
// Before the cap check, so the last accepted step still gets its outcome when
|
||||
// the next interaction is the one refused. Skipped mid-gesture: each repeat's
|
||||
// outcome would be indexed and immediately superseded.
|
||||
if (!coalesces) settlePendingStep(before)
|
||||
if (steps.length >= MAX_RECORDED_STEPS && !coalesces) {
|
||||
truncated = true
|
||||
capped = true
|
||||
return
|
||||
}
|
||||
// A no-record subtree opted out of the recording entirely: its text is what
|
||||
// names the element and what a select/file step carries as a value, so the
|
||||
// step metadata has to be redacted here too — snapshot scrubbing can't
|
||||
// reach into `steps`.
|
||||
const redacted = !!el && isRedacted(el)
|
||||
const target =
|
||||
bound(el ? (redacted ? redactedDescription(el) : describeElement(el)) : 'the app') ??
|
||||
'the app'
|
||||
// Metadata is stored and rendered like a frame is; an unbounded paste would
|
||||
// otherwise slip past the snapshot budget in `value` and `label`.
|
||||
const bounded =
|
||||
value && value.length > MAX_STEP_VALUE_CHARS
|
||||
? `${value.slice(0, MAX_STEP_VALUE_CHARS)}…`
|
||||
: value
|
||||
// Whether a marked control is ticked is withheld from the snapshot, so the
|
||||
// step must not answer it either. A masked "checked" would read back as
|
||||
// Unchecked, so a redacted toggle carries no value and gets a neutral label.
|
||||
const shown =
|
||||
!redacted || !bounded ? bounded : kind === 'toggle' ? undefined : maskValue(bounded)
|
||||
const label = stepLabel(kind, target, shown)
|
||||
if (coalesces && last) {
|
||||
// One gesture, one step: updated in place, indexing nothing for a repeat, and
|
||||
// the window runs from here so a sustained hold never splits.
|
||||
last.value = shown
|
||||
last.label = label
|
||||
lastStepAt = t
|
||||
scheduleSettle(last)
|
||||
return
|
||||
}
|
||||
const step: RawAppStep = {
|
||||
t,
|
||||
kind,
|
||||
label,
|
||||
target,
|
||||
selector: el && !redacted ? bound(cssSelectorFor(el)) : undefined,
|
||||
value: shown,
|
||||
// A key repeat skips its snapshot because it expects to fold into the step
|
||||
// already recorded; when it turns out to start one instead, it still needs
|
||||
// a state to open on. Only for keys: for a `change`, a capture taken now
|
||||
// would be the outcome, not the interaction.
|
||||
before: frameIndex(before ?? (kind === 'key' ? capture(el) : undefined))
|
||||
}
|
||||
steps.push(step)
|
||||
// Spend only the frame this step used: reusing it later rewinds the replay,
|
||||
// while clearing both would take one belonging to an interaction still in
|
||||
// flight (a control clicked mid-debounce commits the fill first).
|
||||
if (before !== undefined && pendingPointer?.html === before) pendingPointer = undefined
|
||||
if (before !== undefined && pendingKey?.html === before) pendingKey = undefined
|
||||
lastStepEl = el
|
||||
lastStepAt = t
|
||||
stepCount = steps.length
|
||||
scheduleSettle(step)
|
||||
}
|
||||
|
||||
/** A label's own click duplicates the control's report of the same interaction.
|
||||
* Only the label's: the forwarded click must not fold, or a button-shaped
|
||||
* control (which never fires `change`) would vanish. */
|
||||
function labelDrivesControl(el: Element): boolean {
|
||||
const label = el.closest('label') as HTMLLabelElement | null
|
||||
const control = label?.control
|
||||
if (!control || el === control || control.contains(el)) return false
|
||||
return !el.closest('a, button, input, select, textarea')
|
||||
}
|
||||
|
||||
/** Whether this key can change a control: activation, option picking, or the
|
||||
* first letter of a `<select>` typeahead. */
|
||||
function mutatingKey(e: KeyboardEvent): boolean {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return false
|
||||
return (
|
||||
e.key.length === 1 ||
|
||||
[' ', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(
|
||||
e.key
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** A control whose value `change` reports rather than typing: a select, or any
|
||||
* input that is not a text field — a checkbox, a range, a date picker. They
|
||||
* change on keys that produce no `beforeinput`, so their pre-change frame has
|
||||
* to come from `keydown`. */
|
||||
function isControl(el: Element): boolean {
|
||||
if (isTag(el, 'SELECT')) return true
|
||||
if (!isTag(el, 'INPUT')) return false
|
||||
const type = (el as HTMLInputElement).type
|
||||
return !isTextEntry(el) && !BUTTON_INPUT_TYPES.has(type)
|
||||
}
|
||||
|
||||
/** The control a form submits through. */
|
||||
function isSubmitter(el: Element): boolean {
|
||||
if (isTag(el, 'BUTTON')) return ((el as HTMLButtonElement).type || 'submit') === 'submit'
|
||||
return isTag(el, 'INPUT') && ['submit', 'image'].includes((el as HTMLInputElement).type)
|
||||
}
|
||||
|
||||
/** Whether the step just recorded was an Enter inside this form — the only key
|
||||
* that submits, so an Escape before a submission must not swallow it. */
|
||||
function justPressedKeyInside(form: Element | null | undefined): boolean {
|
||||
const last = steps[steps.length - 1]
|
||||
return (
|
||||
last?.kind === 'key' &&
|
||||
last.value === 'Enter' &&
|
||||
!!form &&
|
||||
!!lastStepEl &&
|
||||
form.contains(lastStepEl) &&
|
||||
Date.now() - startTime - lastStepAt < SUBMIT_FOLD_MS
|
||||
)
|
||||
}
|
||||
|
||||
/** A field where Enter inserts a newline rather than committing anything. */
|
||||
function isMultiline(el: Element): boolean {
|
||||
return isTag(el, 'TEXTAREA') || (el as HTMLElement).isContentEditable
|
||||
}
|
||||
|
||||
/** An element the browser activates with Enter or Space by dispatching a click,
|
||||
* which is the event the interaction is recorded from. */
|
||||
function isActivatable(el: Element): boolean {
|
||||
if (isTag(el, 'BUTTON') || isTag(el, 'SUMMARY')) return true
|
||||
if (isTag(el, 'A') && el.hasAttribute('href')) return true
|
||||
return isTag(el, 'INPUT') && BUTTON_INPUT_TYPES.has((el as HTMLInputElement).type)
|
||||
}
|
||||
|
||||
/** A control the user sweeps rather than sets once: repeats within a burst are
|
||||
* one interaction. A checkbox is not one — two quick clicks are two toggles. */
|
||||
function isContinuousControl(el: Element): boolean {
|
||||
return isTag(el, 'INPUT') && CONTINUOUS_INPUT_TYPES.has((el as HTMLInputElement).type)
|
||||
}
|
||||
|
||||
/** Metadata is stored and rendered like a frame is; an unbounded paste (or a
|
||||
* pathological selector) would otherwise slip past the snapshot budget. */
|
||||
function bound(text: string | undefined): string | undefined {
|
||||
if (text === undefined) return undefined
|
||||
return text.length > MAX_STEP_VALUE_CHARS ? `${text.slice(0, MAX_STEP_VALUE_CHARS)}…` : text
|
||||
}
|
||||
|
||||
/** A field whose value the user types into, character by character. Listed
|
||||
* positively: everything else an `<input>` can be (a range, a colour, a date,
|
||||
* a checkbox) is a control the browser mutates on keys that produce no
|
||||
* `beforeinput`, and must take the control path instead. */
|
||||
function isTextEntry(el: Element): boolean {
|
||||
if (isTag(el, 'TEXTAREA')) return true
|
||||
if ((el as HTMLElement).isContentEditable) return true
|
||||
return isTag(el, 'INPUT') && TEXT_INPUT_TYPES.has((el as HTMLInputElement).type)
|
||||
}
|
||||
|
||||
function currentValue(el: Element): string {
|
||||
// A contenteditable host can be recordable while a node inside it is not.
|
||||
const raw = isTag(el, 'INPUT')
|
||||
? (el as HTMLInputElement).value
|
||||
: isTag(el, 'TEXTAREA')
|
||||
? (el as HTMLTextAreaElement).value
|
||||
: textWithoutRedacted(el)
|
||||
const secret =
|
||||
(isTag(el, 'INPUT') && (el as HTMLInputElement).type === 'password') || isRedacted(el)
|
||||
return secret ? maskValue(raw) : raw
|
||||
}
|
||||
|
||||
/** The pre-interaction frame for `el`, if the pointerdown that started this
|
||||
* interaction landed on it — or on its label / an ancestor, which is what a
|
||||
* click on `<label>Urgent</label>` looks like. */
|
||||
function pointerFrameFor(el: Element): string | undefined {
|
||||
const from = pendingPointer?.el
|
||||
if (!from) return undefined
|
||||
if (from === el || from.contains(el) || el.contains(from)) return pendingPointer?.html
|
||||
const labels = (el as HTMLInputElement).labels
|
||||
if (labels && Array.from(labels).some((l) => l === from || l.contains(from)))
|
||||
return pendingPointer?.html
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** One interaction target: the same element, or another radio of the same
|
||||
* group — arrows move the selection between them, so `keydown`, `change` and
|
||||
* the step already recorded can each land on a different member. */
|
||||
function sameTarget(a: Element | undefined, b: Element | undefined): boolean {
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
const x = a as HTMLInputElement
|
||||
const y = b as HTMLInputElement
|
||||
return (
|
||||
x.type === 'radio' && y.type === 'radio' && !!x.name && x.name === y.name && x.form === y.form
|
||||
)
|
||||
}
|
||||
|
||||
/** The pre-key snapshot, when the key landed on this interaction target. */
|
||||
function keyFrameFor(el: Element): string | undefined {
|
||||
if (!pendingKey) return undefined
|
||||
return sameTarget(pendingKey.el, el) ? pendingKey.html : undefined
|
||||
}
|
||||
|
||||
function commitFill() {
|
||||
if (!pendingFill) return
|
||||
const { el, before } = pendingFill
|
||||
clearTimeout(pendingFill.timer)
|
||||
pendingFill = undefined
|
||||
// Spent, so a later burst in this field snapshots itself instead of rewinding.
|
||||
// A pointerdown on something else is that control's own pre-change frame and
|
||||
// must survive, which is why `pointerFrameFor` decides what is cleared.
|
||||
if (pointerFrameFor(el) !== undefined) pendingPointer = undefined
|
||||
if (pendingKey?.el === el) pendingKey = undefined
|
||||
pushStep('fill', el, before, currentValue(el))
|
||||
}
|
||||
|
||||
function attach(d: Document) {
|
||||
const on = (type: string, fn: (e: any) => void) => {
|
||||
d.addEventListener(type, fn, true)
|
||||
detachers.push(() => d.removeEventListener(type, fn, true))
|
||||
}
|
||||
|
||||
on('pointerdown', (e: PointerEvent) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
if (!el) return
|
||||
pendingPointer = { el, html: capture(el) }
|
||||
})
|
||||
|
||||
on('click', (e: MouseEvent) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
if (!el) return
|
||||
// Before any early return: a fill still inside its debounce belongs before
|
||||
// whatever this click records, and the control paths below never commit it.
|
||||
if (pendingFill && pendingFill.el !== el) commitFill()
|
||||
// Controls report their own semantic step on `change`; a click on a text
|
||||
// field is just focus. Recording those too would double every step. Derived
|
||||
// from `isControl` so a type moving between the two can't double-record.
|
||||
if (isTextEntry(el) || isControl(el) || isTag(el, 'OPTION')) return
|
||||
// Enter in a field is already recorded; the browser then synthesises this
|
||||
// click on the form's default submitter to carry it out.
|
||||
if (e.detail === 0 && isSubmitter(el) && justPressedKeyInside(el.closest('form'))) return
|
||||
// A click on a <label> is also delivered to its control, which reports the
|
||||
// real step on `change`. Recording the label click too would double one
|
||||
// physical action, with the second step appearing to rewind the state.
|
||||
if (labelDrivesControl(el)) return
|
||||
// `pendingPointer` is NOT cleared here: a click on a <label> is followed by
|
||||
// the control's own `change`, which needs the same pre-click frame. The
|
||||
// next pointerdown replaces it.
|
||||
pushStep('click', el, pointerFrameFor(el) ?? capture(el))
|
||||
})
|
||||
|
||||
on('focusin', (e: FocusEvent) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
// Focus moved somewhere the pending pointerdown does not describe (Tab to
|
||||
// another field), so that frame no longer states what was just before.
|
||||
if (pendingPointer && (!el || pointerFrameFor(el) === undefined)) pendingPointer = undefined
|
||||
})
|
||||
|
||||
// Fires before the DOM changes for every edit, including the ones no keydown
|
||||
// describes: paste, cut, undo, drag-and-drop, IME composition.
|
||||
on('beforeinput', (e: Event) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
if (!el || !isTextEntry(el) || pendingFill?.el === el) return
|
||||
if (pointerFrameFor(el) === undefined) pendingKey = { el, html: capture(el), repeat: false }
|
||||
})
|
||||
|
||||
on('input', (e: Event) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
if (!el || !isTextEntry(el)) return
|
||||
if (pendingFill && pendingFill.el !== el) commitFill()
|
||||
if (!pendingFill) {
|
||||
// The pre-keystroke DOM is gone by the time `input` fires: use the frame
|
||||
// taken on the pointerdown that focused the field, or on the keydown
|
||||
// that produced this character.
|
||||
const pointerBefore = pointerFrameFor(el)
|
||||
const keyBefore = keyFrameFor(el)
|
||||
const before = pointerBefore ?? keyBefore ?? capture(el)
|
||||
// Arming a fill starts an interaction before any step exists. Typing moves
|
||||
// the `value` *property*, invisible to the observer, so the previous
|
||||
// settle would otherwise capture this field mid-edit as its outcome.
|
||||
settlePendingStep(before)
|
||||
pendingFill = { el, before, timer: setTimeout(commitFill, FILL_DEBOUNCE_MS) }
|
||||
} else {
|
||||
clearTimeout(pendingFill.timer)
|
||||
pendingFill.timer = setTimeout(commitFill, FILL_DEBOUNCE_MS)
|
||||
}
|
||||
})
|
||||
|
||||
on('change', (e: Event) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
if (!el) return
|
||||
if (isTextEntry(el)) {
|
||||
commitFill()
|
||||
return
|
||||
}
|
||||
if (pendingFill) commitFill()
|
||||
// `change` fires after the control already holds its new value, so a
|
||||
// snapshot taken here is the outcome, not the interaction. Only a frame
|
||||
// taken before the key or pointer that caused it will do.
|
||||
const pointerBefore = pointerFrameFor(el)
|
||||
const keyBefore = keyFrameFor(el)
|
||||
const before = pointerBefore ?? keyBefore
|
||||
// Left pending: `pushStep` spends the frame it used, and needs this one to
|
||||
// recognise the pre-state when settling the step before it. `repeat` is read
|
||||
// from the frame this change starts from — a held key leaves the flag set,
|
||||
// so a later pointer takeover would otherwise fold into a finished gesture.
|
||||
const keyDriven =
|
||||
pointerBefore === undefined && keyBefore !== undefined && !!pendingKey?.repeat
|
||||
if (isTag(el, 'SELECT')) {
|
||||
const options = Array.from((el as HTMLSelectElement).selectedOptions)
|
||||
const selected = options.map((o) => o.label || o.value).join(', ')
|
||||
// The <select> itself can be recordable while the option picked is not;
|
||||
// `pushStep` only looks at the event target, so mask it here.
|
||||
const secret = options.some((o) => isRedacted(o))
|
||||
pushStep('select', el, before, secret ? maskValue(selected) : selected, keyDriven)
|
||||
} else if (isTag(el, 'INPUT')) {
|
||||
const input = el as HTMLInputElement
|
||||
if (['checkbox', 'radio'].includes(input.type)) {
|
||||
pushStep('toggle', el, before, input.checked ? 'checked' : 'unchecked', keyDriven)
|
||||
} else if (input.type === 'file') {
|
||||
pushStep(
|
||||
'fill',
|
||||
el,
|
||||
before,
|
||||
Array.from(input.files ?? [])
|
||||
.map((f) => f.name)
|
||||
.join(', ')
|
||||
)
|
||||
} else {
|
||||
// Range, date, color…: a value the user picked rather than typed.
|
||||
pushStep('fill', el, before, currentValue(el), keyDriven)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
on('submit', (e: Event) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
commitFill()
|
||||
// Clicking a submit button — or pressing Enter in a field — records that
|
||||
// interaction; the submit that follows is the same action, so it only
|
||||
// becomes its own step when nothing in this form just acted (a
|
||||
// programmatic submit, or one triggered from outside the form).
|
||||
const last = steps[steps.length - 1]
|
||||
const justActedInside =
|
||||
(last?.kind === 'click' || (last?.kind === 'key' && last.value === 'Enter')) &&
|
||||
!!lastStepEl &&
|
||||
!!el &&
|
||||
el.contains(lastStepEl) &&
|
||||
Date.now() - startTime - lastStepAt < SUBMIT_FOLD_MS
|
||||
if (justActedInside) return
|
||||
pushStep('submit', el, capture(el))
|
||||
})
|
||||
|
||||
on('keydown', (e: KeyboardEvent) => {
|
||||
const el = isElementNode(e.target) ? e.target : undefined
|
||||
// Last moment the pre-change DOM exists for a control key (space, arrows).
|
||||
// Text fields go through `beforeinput`, which also sees paste and undo.
|
||||
// Snapshotting runs on the app's event path, so inert keys must not trigger it.
|
||||
if (el && isControl(el) && mutatingKey(e)) {
|
||||
// An auto-repeat continues the gesture the first press opened: keep that
|
||||
// frame (re-serializing per repeat would clone the whole document at the
|
||||
// key-repeat rate) and only note that the gesture is now repeating.
|
||||
if (e.repeat && pendingKey && keyFrameFor(el) !== undefined) pendingKey.repeat = true
|
||||
else pendingKey = { el, html: capture(el), repeat: e.repeat }
|
||||
}
|
||||
if (e.key !== 'Enter' && e.key !== 'Escape') return
|
||||
// Enter is already reported as a change, a click, or the fill's own newline,
|
||||
// so recording it again would double the interaction. Escape becomes none of
|
||||
// those and is only ever recorded here.
|
||||
if (e.key === 'Enter' && el && (isControl(el) || isActivatable(el) || isMultiline(el))) return
|
||||
// Enter in a field ends the edit: the fill step must land before the key.
|
||||
commitFill()
|
||||
// A held key is one interaction: `pushStep` folds the repeats into the step
|
||||
// the first press opened rather than serializing per repeat.
|
||||
pushStep('key', el, e.repeat ? undefined : capture(el), e.key, e.repeat)
|
||||
})
|
||||
}
|
||||
|
||||
function detach() {
|
||||
detachers.forEach((fn) => fn())
|
||||
detachers = []
|
||||
}
|
||||
|
||||
/** Outstanding `reqId`s on the bundle's bridge (RawAppBackgroundRunner) are the
|
||||
* only sign a step waits on the backend: the request leaves the iframe without
|
||||
* touching the DOM. By id, so an async dispatch awaited later still counts; on
|
||||
* both windows, since request and response travel opposite ways. */
|
||||
function watchRunnableBridge(iframe: HTMLIFrameElement) {
|
||||
const bundle = iframe.contentWindow
|
||||
const onResponse = (e: MessageEvent) => {
|
||||
const data = e.data
|
||||
if (!data || typeof data !== 'object' || e.source !== window) return
|
||||
const { type, reqId } = data as { type?: unknown; reqId?: unknown }
|
||||
if (typeof type !== 'string' || !type.endsWith('Res')) return
|
||||
inFlight.delete(reqId)
|
||||
pendingJobs = inFlight.size
|
||||
}
|
||||
// Bound off the request, not just `load`: a reloaded document can be answered
|
||||
// while a slow subresource still holds `load` open, and the request both
|
||||
// proves the document is live and always precedes its own response.
|
||||
const bindResponses = () => {
|
||||
const d = doc()
|
||||
if (!d || d === boundDoc) return
|
||||
// Same handler identity, so re-adding it to a window that kept the old
|
||||
// registration is a no-op.
|
||||
bundle?.addEventListener('message', onResponse)
|
||||
boundDoc = d
|
||||
}
|
||||
const onRequest = (e: MessageEvent) => {
|
||||
const data = e.data
|
||||
if (!data || typeof data !== 'object' || e.source !== bundle) return
|
||||
const { type, reqId } = data as { type?: unknown; reqId?: unknown }
|
||||
if (typeof type !== 'string' || type.endsWith('Res') || reqId === undefined) return
|
||||
bindResponses()
|
||||
inFlight.add(reqId)
|
||||
pendingJobs = inFlight.size
|
||||
}
|
||||
window.addEventListener('message', onRequest)
|
||||
bindResponses()
|
||||
// Only the listeners: `inFlight` outlives a rebind on purpose (see its
|
||||
// declaration), and stop() is what finally empties it.
|
||||
return () => {
|
||||
window.removeEventListener('message', onRequest)
|
||||
bundle?.removeEventListener('message', onResponse)
|
||||
boundDoc = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** A reload replaces the document the listeners are bound to. Anything the old
|
||||
* one had in flight (a debounced fill, a pending outcome) refers to detached
|
||||
* nodes and must be dropped, not carried into the new page's timeline. */
|
||||
function onIframeLoad() {
|
||||
detach()
|
||||
// The interaction that triggered the load never settled: its outcome is the
|
||||
// document that has just replaced the one it acted on.
|
||||
const reloadedFrom = settle?.step
|
||||
clearSettle()
|
||||
if (pendingFill) clearTimeout(pendingFill.timer)
|
||||
pendingFill = undefined
|
||||
pendingPointer = undefined
|
||||
pendingKey = undefined
|
||||
const d = doc()
|
||||
if (!d) return
|
||||
attach(d)
|
||||
// Half the runnable bridge is bound to the document's window, which the
|
||||
// reload replaced: rebind the listeners. What is in flight carries over —
|
||||
// the new document's bootstrap requests were seen by the outgoing watch.
|
||||
if (iframeEl) {
|
||||
unwatchBridge?.()
|
||||
unwatchBridge = watchRunnableBridge(iframeEl)
|
||||
}
|
||||
const loaded = capture()
|
||||
if (reloadedFrom) reloadedFrom.after = frameIndex(loaded)
|
||||
// Recording started before the app had loaded: this document IS the initial
|
||||
// state, not a navigation away from one.
|
||||
if (frames.length === 0) {
|
||||
frameIndex(loaded)
|
||||
return
|
||||
}
|
||||
// The wrapper is a blob: URL, so only the in-app hash is meaningful here.
|
||||
pushStep('navigate', undefined, loaded, d.location?.hash || undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return active
|
||||
},
|
||||
get stepCount() {
|
||||
return stepCount
|
||||
},
|
||||
/** Stop is waiting on a runnable the last step kicked off. */
|
||||
get stopping() {
|
||||
return stopping
|
||||
},
|
||||
start(iframe: HTMLIFrameElement, opts: { appPath: string; workspace?: string }): boolean {
|
||||
iframeEl = iframe
|
||||
const d = doc()
|
||||
if (!d?.documentElement) {
|
||||
iframeEl = undefined
|
||||
return false
|
||||
}
|
||||
active = true
|
||||
startTime = Date.now()
|
||||
appPath = opts.appPath
|
||||
workspace = opts.workspace
|
||||
steps = []
|
||||
lastStepEl = undefined
|
||||
lastStepAt = 0
|
||||
stepCount = 0
|
||||
frames = []
|
||||
frameIndexes = new Map()
|
||||
framesBytes = 0
|
||||
truncated = false
|
||||
capped = false
|
||||
baseHref = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
viewport = {
|
||||
width: iframe.clientWidth || d.documentElement.clientWidth,
|
||||
height: iframe.clientHeight || d.documentElement.clientHeight
|
||||
}
|
||||
// frames[0] is the app as recording started, the one frame no step claims.
|
||||
// Capturing a not-yet-loaded `about:blank` would open the replay empty and
|
||||
// make the real initial DOM look like a navigation, so defer to `load`.
|
||||
if (d.readyState === 'complete' && d.location?.href !== 'about:blank') {
|
||||
frameIndex(capture())
|
||||
}
|
||||
attach(d)
|
||||
// NOT in `detachers`: onIframeLoad calls detach(), which would otherwise
|
||||
// remove the very listener that rebinds the recorder on the next reload.
|
||||
iframe.addEventListener('load', onIframeLoad)
|
||||
inFlight.clear()
|
||||
pendingJobs = 0
|
||||
unwatchBridge = watchRunnableBridge(iframe)
|
||||
return true
|
||||
},
|
||||
async stop(): Promise<RawAppRecording> {
|
||||
commitFill()
|
||||
// Interactions stop counting the moment Stop is pressed, but the bridge
|
||||
// stays up through the drain below.
|
||||
detach()
|
||||
active = false
|
||||
// The last step is still waiting on the backend: capturing now would ship
|
||||
// its spinner as the outcome, and the bridge is torn down right after, so
|
||||
// nothing could correct it later. Same budget the scheduled settle spends.
|
||||
if (settle && pendingJobs > 0) {
|
||||
stopping = true
|
||||
const startedAt = settle.startedAt
|
||||
await drainPendingJobs(startedAt)
|
||||
// The response still has to render before it is the outcome.
|
||||
if (doc()) await wait(SETTLE_QUIET_MS)
|
||||
stopping = false
|
||||
}
|
||||
// The step the user just finished has no settled frame yet — take it now
|
||||
// rather than ship a step with no outcome.
|
||||
if (settle) {
|
||||
const step = settle.step
|
||||
clearSettle()
|
||||
step.after = frameIndex(capture())
|
||||
}
|
||||
unwatchBridge?.()
|
||||
unwatchBridge = undefined
|
||||
inFlight.clear()
|
||||
pendingJobs = 0
|
||||
iframeEl?.removeEventListener('load', onIframeLoad)
|
||||
pendingPointer = undefined
|
||||
pendingKey = undefined
|
||||
iframeEl = undefined
|
||||
const recording: RawAppRecording = {
|
||||
version: 1,
|
||||
type: 'app',
|
||||
recorded_at: new Date().toISOString(),
|
||||
app_path: appPath,
|
||||
workspace,
|
||||
total_duration_ms: Date.now() - startTime,
|
||||
viewport,
|
||||
frames,
|
||||
steps,
|
||||
truncated: truncated || undefined
|
||||
}
|
||||
// Multi-MB snapshots must not outlive the recording they were taken for.
|
||||
steps = []
|
||||
frames = []
|
||||
frameIndexes = new Map()
|
||||
framesBytes = 0
|
||||
return recording
|
||||
},
|
||||
download(recording: RawAppRecording) {
|
||||
const blob = new Blob([JSON.stringify(recording)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `app-recording-${(recording.app_path || 'untitled').replace(/\//g, '-')}-${Date.now()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* `parseRecording` and the per-kind validators are what stand between a `?src=` URL
|
||||
* anyone can point the public replay page at and a player that indexes into the
|
||||
* payload and renders it. Each rejection below is a way a caller-supplied recording
|
||||
* could otherwise reach the DOM or the render loop unbounded.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_COMPONENT_FANOUT,
|
||||
MAX_EVENTS_PER_JOB,
|
||||
MAX_FLOW_MODULES,
|
||||
MAX_RECORDED_JOBS,
|
||||
MAX_MAP_ROWS,
|
||||
MAX_SAMPLE_CELLS,
|
||||
MAX_SERIALIZED_FANOUT_CHARS,
|
||||
MAX_TIMELINE_FRAMES,
|
||||
MAX_SAMPLE_COLUMNS,
|
||||
MAX_VALUE_DEPTH,
|
||||
MAX_VALUE_NODES,
|
||||
MAX_RECORDING_NODES,
|
||||
MAX_VALUE_STRING_CHARS,
|
||||
isAppRecording,
|
||||
parseRecording
|
||||
} from './rawAppRecordingLoad'
|
||||
import { MAX_RECORDED_STEPS, MAX_STEP_TEXT_CHARS } from './rawAppSnapshot'
|
||||
|
||||
const valid = () => ({
|
||||
version: 1,
|
||||
type: 'app',
|
||||
recorded_at: '2026-07-25T00:00:00.000Z',
|
||||
app_path: 'f/demo/app',
|
||||
workspace: 'demo',
|
||||
total_duration_ms: 1200,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
frames: ['<html></html>', '<html><body>x</body></html>'],
|
||||
steps: [{ t: 100, kind: 'click', label: 'Clicked button', before: 0, after: 1 }]
|
||||
})
|
||||
|
||||
const rejects: [string, (r: ReturnType<typeof valid>) => unknown][] = [
|
||||
['a non-object payload', () => 42],
|
||||
['another recording type', (r) => ({ ...r, type: 'flow' })],
|
||||
['a future version', (r) => ({ ...r, version: 2 })],
|
||||
['a frame that is not a string', (r) => ({ ...r, frames: ['<html></html>', 7] })],
|
||||
['a step index past the last frame', (r) => ({ ...r, steps: [{ ...r.steps[0], after: 9 }] })],
|
||||
['a negative step index', (r) => ({ ...r, steps: [{ ...r.steps[0], before: -1 }] })],
|
||||
['an unknown interaction kind', (r) => ({ ...r, steps: [{ ...r.steps[0], kind: 'exec' }] })],
|
||||
['a NaN timestamp', (r) => ({ ...r, steps: [{ ...r.steps[0], t: NaN }] })],
|
||||
[
|
||||
'a label past the text budget',
|
||||
(r) => ({ ...r, steps: [{ ...r.steps[0], label: 'x'.repeat(MAX_STEP_TEXT_CHARS + 1) }] })
|
||||
],
|
||||
[
|
||||
'more steps than the recorder can produce',
|
||||
(r) => ({
|
||||
...r,
|
||||
steps: Array.from({ length: MAX_RECORDED_STEPS + 1 }, () => ({ ...r.steps[0] }))
|
||||
})
|
||||
],
|
||||
['a non-numeric viewport', (r) => ({ ...r, viewport: { width: '1280px', height: 720 } })],
|
||||
['a viewport beyond any screen', (r) => ({ ...r, viewport: { width: 1e6, height: 720 } })],
|
||||
['a negative duration', (r) => ({ ...r, total_duration_ms: -1 })],
|
||||
['a missing recorded_at', (r) => ({ ...r, recorded_at: undefined })]
|
||||
]
|
||||
|
||||
describe('isAppRecording', () => {
|
||||
it('accepts a well-formed recording', () => {
|
||||
expect(isAppRecording(valid())).toBe(true)
|
||||
// Steps may legitimately lack a snapshot the recorder had to skip.
|
||||
expect(isAppRecording({ ...valid(), steps: [{ t: 1, kind: 'click', label: 'x' }] })).toBe(true)
|
||||
})
|
||||
|
||||
it.each(rejects)('rejects %s', (_name, mutate) => {
|
||||
expect(isAppRecording(mutate(valid()))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const job = (events = 1) => ({
|
||||
initial_job: { id: 'j' },
|
||||
events: Array.from({ length: events }, (_, t) => ({ t, data: { completed: true } }))
|
||||
})
|
||||
|
||||
const header = { recorded_at: '2026-07-25T00:00:00.000Z', total_duration_ms: 1000 }
|
||||
|
||||
const pipeline = (extra: Record<string, unknown> = {}) => ({
|
||||
version: 1,
|
||||
type: 'pipeline',
|
||||
folder: 'orders',
|
||||
...header,
|
||||
graph: { runnables: [], assets: [], edges: [], triggers: [] },
|
||||
timeline: [],
|
||||
jobs: {},
|
||||
...extra
|
||||
})
|
||||
|
||||
const kindOf = (data: unknown) => {
|
||||
const res = parseRecording(data)
|
||||
return res.ok ? res.loaded.kind : `error: ${res.error}`
|
||||
}
|
||||
const rejected = (data: unknown) => parseRecording(data).ok === false
|
||||
|
||||
describe('parseRecording', () => {
|
||||
it('routes each kind to its player, defaulting a type-less payload to flow', () => {
|
||||
expect(kindOf({ version: 1, flow_path: 'f', ...header, jobs: { j: job() } })).toBe('flow')
|
||||
expect(
|
||||
kindOf({ version: 1, type: 'flow', flow_path: 'f', ...header, jobs: { j: job() } })
|
||||
).toBe('flow')
|
||||
expect(
|
||||
kindOf({
|
||||
version: 1,
|
||||
type: 'script',
|
||||
script_path: 's',
|
||||
...header,
|
||||
code: 'echo hi',
|
||||
language: 'bash',
|
||||
job: job()
|
||||
})
|
||||
).toBe('script')
|
||||
expect(kindOf(pipeline())).toBe('pipeline')
|
||||
expect(
|
||||
kindOf({
|
||||
version: 1,
|
||||
type: 'app',
|
||||
recorded_at: header.recorded_at,
|
||||
total_duration_ms: 0,
|
||||
viewport: { width: 800, height: 600 },
|
||||
frames: ['<html></html>'],
|
||||
steps: [{ t: 0, kind: 'click', label: 'Clicked button', before: 0 }]
|
||||
})
|
||||
).toBe('app')
|
||||
})
|
||||
|
||||
it('rejects payloads a player would choke on rather than falling through to flow', () => {
|
||||
// A `?src=` origin is arbitrary: a claimed kind must be validated as that
|
||||
// kind, never silently reinterpreted.
|
||||
expect(rejected({ version: 1, type: 'script', code: 'x', language: 'bash' })).toBe(true)
|
||||
expect(rejected({ version: 1, type: 'pipeline', timeline: [], jobs: {} })).toBe(true)
|
||||
expect(rejected({ version: 1, type: 'somethingelse', jobs: {} })).toBe(true)
|
||||
expect(rejected({ version: 2, jobs: {} })).toBe(true)
|
||||
expect(rejected(null)).toBe(true)
|
||||
expect(rejected([{ version: 1 }])).toBe(true)
|
||||
// Header fields are required by the types and rendered by every player.
|
||||
expect(rejected({ version: 1, jobs: { j: job() } })).toBe(true)
|
||||
expect(rejected({ version: 1, flow_path: 'f', recorded_at: 'x', jobs: { j: job() } })).toBe(
|
||||
true
|
||||
)
|
||||
// JobLoader replays each event in a timer, where a throw escapes every
|
||||
// boundary, so a non-object event has to be caught at load.
|
||||
expect(
|
||||
rejected({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: { j: { initial_job: {}, events: ['nope'] } }
|
||||
})
|
||||
).toBe(true)
|
||||
// Cardinality, not just shape: each job mounts a JobLoader.
|
||||
const tooManyJobs = Object.fromEntries(
|
||||
Array.from({ length: MAX_RECORDED_JOBS + 1 }, (_, i) => [`j${i}`, job(0)])
|
||||
)
|
||||
expect(rejected({ version: 1, flow_path: 'f', ...header, jobs: tooManyJobs })).toBe(true)
|
||||
})
|
||||
|
||||
it('holds every recorded value to one structural render budget', () => {
|
||||
// One rule, asserted over the shapes it has to hold for: a value a component
|
||||
// expands eagerly may not carry more than MAX_VALUE_NODES of structure, at any
|
||||
// depth and under any key.
|
||||
const wide = (n: number) => Array.from({ length: n }, () => 0)
|
||||
const overBudget = MAX_VALUE_NODES + 1
|
||||
|
||||
const flowJob = (initial: unknown, eventData?: unknown) => ({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: {
|
||||
j: {
|
||||
initial_job: initial,
|
||||
events: eventData ? [{ t: 0, data: eventData }] : []
|
||||
}
|
||||
}
|
||||
})
|
||||
// args (a JobArgs row per key) and flow_status.modules (a subtree per entry).
|
||||
expect(rejected(flowJob({ id: 'j', args: { a: wide(overBudget) } }))).toBe(true)
|
||||
expect(rejected(flowJob({ id: 'j', flow_status: { modules: wide(overBudget) } }))).toBe(true)
|
||||
// Same via an event rather than the initial job.
|
||||
expect(rejected(flowJob({ id: 'j' }, { flow_status: { modules: wide(overBudget) } }))).toBe(
|
||||
true
|
||||
)
|
||||
// render_all fans out into nested DisplayResults, and the renderer recurses —
|
||||
// so a nested budget, not the top-level array's length.
|
||||
const nested = Array.from({ length: 400 }, () => ({ render_all: wide(400) }))
|
||||
expect(
|
||||
rejected(
|
||||
flowJob({ id: 'j' }, { completed: true, job: { id: 'j', result: { render_all: nested } } })
|
||||
)
|
||||
).toBe(true)
|
||||
// data_tests is a sibling key whose renderer also fans out per entry; nobody
|
||||
// had to name it for the budget to cover it.
|
||||
expect(
|
||||
rejected(
|
||||
flowJob(
|
||||
{ id: 'j' },
|
||||
{ completed: true, job: { id: 'j', result: { data_tests: wide(overBudget) } } }
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
// Depth is its own hazard: a renderer recursing over this blows the stack long
|
||||
// before the node count would.
|
||||
let deep: unknown = 0
|
||||
for (let i = 0; i < MAX_VALUE_DEPTH + 2; i++) deep = { nest: deep }
|
||||
expect(rejected(flowJob({ id: 'j', args: { a: deep } }))).toBe(true)
|
||||
|
||||
expect(kindOf(flowJob({ id: 'j', args: { a: 1, b: 'two' } }))).toBe('flow')
|
||||
})
|
||||
|
||||
it('bounds what a script and a flow render immediately', () => {
|
||||
const wide = (n: number) => Array.from({ length: n }, () => 0)
|
||||
const script = (extra: Record<string, unknown>) => ({
|
||||
version: 1,
|
||||
type: 'script',
|
||||
script_path: 's',
|
||||
...header,
|
||||
code: 'x',
|
||||
language: 'bash',
|
||||
job: job(),
|
||||
...extra
|
||||
})
|
||||
// SchemaForm recurses into nested properties, so the cap cannot be top-level.
|
||||
expect(
|
||||
rejected(
|
||||
script({
|
||||
schema: { properties: { outer: { properties: { inner: wide(MAX_VALUE_NODES + 1) } } } }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(kindOf(script({ args: { a: 1 } }))).toBe('script')
|
||||
// `args` and `schema.properties` arrive as the root of the walk on this kind.
|
||||
const wideMap = Object.fromEntries(
|
||||
Array.from({ length: MAX_MAP_ROWS + 1 }, (_, i) => [`a${i}`, 1])
|
||||
)
|
||||
expect(rejected(script({ args: wideMap }))).toBe(true)
|
||||
expect(rejected(script({ args: Array.from({ length: MAX_MAP_ROWS + 1 }, () => 0) }))).toBe(true)
|
||||
expect(rejected(script({ schema: { properties: wideMap } }))).toBe(true)
|
||||
|
||||
const flowWith = (value: unknown) => ({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: { j: job() },
|
||||
flow: { value }
|
||||
})
|
||||
const modules = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `m${i}`, value: { type: 'identity' } }))
|
||||
expect(rejected(flowWith({ modules: modules(MAX_FLOW_MODULES + 1) }))).toBe(true)
|
||||
// The module count is the total across the nested tree, not the top-level
|
||||
// array's length — a branch or loop body renders nodes just the same.
|
||||
expect(
|
||||
rejected(
|
||||
flowWith({
|
||||
modules: [
|
||||
{ id: 'loop', value: { type: 'forloopflow', modules: modules(MAX_FLOW_MODULES + 1) } }
|
||||
]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
// A branch is a node and an edge even with no modules in it.
|
||||
expect(
|
||||
rejected(
|
||||
flowWith({
|
||||
modules: [
|
||||
{
|
||||
id: 'b',
|
||||
value: {
|
||||
type: 'branchall',
|
||||
branches: Array.from({ length: MAX_FLOW_MODULES + 1 }, () => ({ modules: [] }))
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
// input_transforms renders a table row per entry — a per-module sibling the
|
||||
// module count never saw, and the budget covers without naming it.
|
||||
expect(
|
||||
rejected(
|
||||
flowWith({
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: { type: 'rawscript', input_transforms: { x: wide(MAX_VALUE_NODES + 1) } }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
// Notes and groups each mount a graph node, so they are capped by component
|
||||
// fan-out and not merely by structure: minimal entries are cheap enough that
|
||||
// the node budget would admit tens of thousands of them.
|
||||
expect(rejected(flowWith({ modules: [], notes: wide(MAX_COMPONENT_FANOUT + 1) }))).toBe(true)
|
||||
expect(rejected(flowWith({ modules: [], groups: wide(MAX_COMPONENT_FANOUT + 1) }))).toBe(true)
|
||||
// Split across both, since one graph draws them together.
|
||||
expect(
|
||||
rejected(
|
||||
flowWith({
|
||||
modules: [],
|
||||
notes: wide(MAX_COMPONENT_FANOUT),
|
||||
groups: wide(MAX_COMPONENT_FANOUT)
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(kindOf(flowWith({ modules: [], notes: wide(10), groups: wide(10) }))).toBe('flow')
|
||||
expect(kindOf(flowWith({ modules: modules(3) }))).toBe('flow')
|
||||
})
|
||||
|
||||
it('bounds the three things that cost differently: structure, text, components', () => {
|
||||
const wide = (n: number) => Array.from({ length: n }, () => 0)
|
||||
const flowJob = (result: unknown) => ({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: {
|
||||
j: {
|
||||
initial_job: { id: 'j' },
|
||||
events: [{ t: 0, data: { completed: true, job: { id: 'j', result } } }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fan-out is cumulative across the value, not per array: nesting sidesteps a
|
||||
// per-array cap while mounting a component per leaf.
|
||||
const side = 40
|
||||
expect(side * side).toBeGreaterThan(MAX_COMPONENT_FANOUT)
|
||||
expect(
|
||||
rejected(
|
||||
flowJob({ render_all: Array.from({ length: side }, () => ({ render_all: wide(side) })) })
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Component fan-out: well inside the node budget, but one nested DisplayResult
|
||||
// per entry is orders of magnitude more expensive than a node.
|
||||
const fanout = MAX_COMPONENT_FANOUT + 1
|
||||
expect(fanout).toBeLessThan(MAX_VALUE_NODES)
|
||||
expect(rejected(flowJob({ render_all: wide(fanout) }))).toBe(true)
|
||||
expect(rejected(flowJob({ data_tests: wide(fanout) }))).toBe(true)
|
||||
// ...including the serialized form, since a string is one node however many
|
||||
// components the renderer parses out of it.
|
||||
expect(rejected(flowJob({ data_tests: JSON.stringify(wide(fanout)) }))).toBe(true)
|
||||
expect(rejected(flowJob({ error: { data_tests: JSON.stringify(wide(fanout)) } }))).toBe(true)
|
||||
expect(kindOf(flowJob({ render_all: wide(10) }))).toBe('flow')
|
||||
|
||||
// Text: a long string is one node, so only the character budget sees it.
|
||||
expect(rejected(flowJob({ big: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1) }))).toBe(true)
|
||||
// A flat map rendered one row per entry: cheap per node, so the node budget
|
||||
// alone lets ~100k rows through.
|
||||
const rows = (n: number) =>
|
||||
Object.fromEntries(Array.from({ length: n }, (_, i) => [`a${i}`, 1]))
|
||||
expect(MAX_MAP_ROWS + 1).toBeLessThan(MAX_VALUE_NODES)
|
||||
expect(rejected(flowJob({ args: rows(MAX_MAP_ROWS + 1) }))).toBe(true)
|
||||
// `args` is only conventionally a map: an array gets a row per entry too.
|
||||
expect(rejected(flowJob({ args: Array.from({ length: MAX_MAP_ROWS + 1 }, () => 0) }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(kindOf(flowJob({ args: rows(5) }))).toBe('flow')
|
||||
// An object *key* is rendered text too (JobArgs prints it), and charging only
|
||||
// values would let a huge key through.
|
||||
expect(rejected(flowJob({ ['k'.repeat(MAX_VALUE_STRING_CHARS + 1)]: 1 }))).toBe(true)
|
||||
// A serialized fan-out larger than validation is willing to decode is refused
|
||||
// rather than measured — decoding it to size it would be the DoS.
|
||||
expect(
|
||||
rejected(
|
||||
flowJob({
|
||||
data_tests: JSON.stringify([
|
||||
{ test: 't', violating: 1, pad: 'x'.repeat(MAX_SERIALIZED_FANOUT_CHARS) }
|
||||
])
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('budgets the whole flow object, not just its value', () => {
|
||||
// `schema` renders through SchemaViewer (Input Schema tab, Input node) exactly
|
||||
// as `value` renders through the graph, so the budget belongs one level up.
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: MAX_VALUE_NODES }, (_, i) => [`p${i}`, { type: 'string' }])
|
||||
)
|
||||
const res = parseRecording({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: { j: job() },
|
||||
flow: { schema: { properties }, value: { modules: [] } }
|
||||
})
|
||||
expect(res.ok).toBe(false)
|
||||
// A module's inline `content` is the flow kind's equivalent of a script's
|
||||
// `code`, and text is not structure.
|
||||
expect(
|
||||
rejected({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: { j: job() },
|
||||
flow: {
|
||||
value: {
|
||||
modules: [
|
||||
{
|
||||
id: 'a',
|
||||
value: { type: 'rawscript', content: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1) }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds graph contents, rendered metadata strings and scheduled timers', () => {
|
||||
// The canvas emits a node and edge per nested graph entry, so the four
|
||||
// top-level array lengths were never the bound.
|
||||
expect(
|
||||
rejected(
|
||||
pipeline({
|
||||
graph: {
|
||||
runnables: [
|
||||
{
|
||||
path: 'f/a/b',
|
||||
usage_kind: 'script',
|
||||
data_tests: Array.from({ length: MAX_VALUE_NODES }, (_, i) => ({ test: `t${i}` }))
|
||||
}
|
||||
],
|
||||
assets: [],
|
||||
edges: [],
|
||||
triggers: []
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Metadata strings land in a header, a highlighter class or an error panel, and
|
||||
// were only `typeof === 'string'`.
|
||||
const long = 'x'.repeat(5000)
|
||||
expect(
|
||||
rejected({
|
||||
version: 1,
|
||||
type: 'script',
|
||||
script_path: 's',
|
||||
...header,
|
||||
code: 'x',
|
||||
language: long,
|
||||
job: job()
|
||||
})
|
||||
).toBe(true)
|
||||
expect(rejected(pipeline({ codes: { 'f/a/b': { content: 'x', language: long } } }))).toBe(true)
|
||||
expect(
|
||||
rejected(
|
||||
pipeline({
|
||||
assetSamples: { 'ducklake:main/t': { kind: 'ducklake', path: 'main/t', error: long } }
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// A pipeline's timeline frames all become timers in a single pass too.
|
||||
expect(
|
||||
rejected(
|
||||
pipeline({
|
||||
timeline: Array.from({ length: MAX_TIMELINE_FRAMES + 1 }, () => ({ t: 0, statuses: {} }))
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// One job's events all become timers in a single pass.
|
||||
expect(
|
||||
rejected({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: {
|
||||
j: {
|
||||
initial_job: { id: 'j' },
|
||||
events: Array.from({ length: MAX_EVENTS_PER_JOB + 1 }, () => ({
|
||||
t: 0,
|
||||
data: { progress: 1 }
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a huge recording before it looks at what any field means', () => {
|
||||
// The backstop needs no key name, so a field no validator mentions is still
|
||||
// bounded.
|
||||
const filler = Array.from({ length: 5000 }, () => Array.from({ length: 500 }, () => 0))
|
||||
const res = parseRecording({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: { j: job() },
|
||||
some_future_field: filler
|
||||
})
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.ok ? '' : res.error).toMatch(new RegExp(`more than ${MAX_RECORDING_NODES} values`))
|
||||
|
||||
// Structure hidden behind wrappers deeper than the ceiling must be refused, not
|
||||
// silently uncounted — a backstop that gives up is not a backstop.
|
||||
let buried: unknown = filler
|
||||
for (let i = 0; i < MAX_VALUE_DEPTH + 2; i++) buried = { nest: buried }
|
||||
expect(rejected({ version: 1, flow_path: 'f', ...header, jobs: { j: job() }, buried })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds an errored asset sample, which still renders its own fields', () => {
|
||||
// The error branch shows the message instead of the table, but `uri` and
|
||||
// `rowCount` render either way.
|
||||
expect(
|
||||
rejected(
|
||||
pipeline({
|
||||
assetSamples: {
|
||||
'ducklake:main/t': {
|
||||
kind: 'ducklake',
|
||||
path: 'main/t',
|
||||
error: 'table missing',
|
||||
uri: 'x'.repeat(MAX_VALUE_STRING_CHARS + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
kindOf(
|
||||
pipeline({
|
||||
assetSamples: {
|
||||
'ducklake:main/t': {
|
||||
kind: 'ducklake',
|
||||
path: 'main/t',
|
||||
uri: 'ducklake://main/t',
|
||||
error: 'table missing'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBe('pipeline')
|
||||
})
|
||||
|
||||
it('tells an oversized recording apart from a corrupt one', () => {
|
||||
// A wide for-loop flow records a job per iteration, so a real capture can trip
|
||||
// this — it must not read as "your recorder wrote a broken file".
|
||||
const tooManyJobs = Object.fromEntries(
|
||||
Array.from({ length: MAX_RECORDED_JOBS + 1 }, (_, i) => [`j${i}`, job(0)])
|
||||
)
|
||||
const res = parseRecording({ version: 1, flow_path: 'f', ...header, jobs: tooManyJobs })
|
||||
expect(res.ok ? '' : res.error).toMatch(/holds \d+ jobs/)
|
||||
const corrupt = parseRecording({ version: 1, flow_path: 'f', ...header, jobs: 'nope' })
|
||||
expect(corrupt.ok ? '' : corrupt.error).toBe('Invalid flow recording format.')
|
||||
// The render budget is the cap a real capture is most likely to trip, so it
|
||||
// names the value and what about it was too big.
|
||||
const fat = parseRecording({
|
||||
version: 1,
|
||||
flow_path: 'f',
|
||||
...header,
|
||||
jobs: {
|
||||
j: {
|
||||
initial_job: { id: 'j' },
|
||||
events: [
|
||||
{
|
||||
t: 0,
|
||||
data: {
|
||||
completed: true,
|
||||
job: {
|
||||
id: 'j',
|
||||
result: { render_all: Array.from({ length: MAX_COMPONENT_FANOUT + 1 }, () => 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(fat.ok ? '' : fat.error).toMatch(/a recorded job carries more than \d+ `render_all`/)
|
||||
})
|
||||
|
||||
it('bounds an asset sample on its rendered cells, not just per axis', () => {
|
||||
const sample = (rows: number, columns: number) => ({
|
||||
assetSamples: {
|
||||
'ducklake:main/t': {
|
||||
kind: 'ducklake',
|
||||
path: 'main/t',
|
||||
uri: 'ducklake://main/t',
|
||||
rows: Array.from({ length: rows }, () => ({})),
|
||||
columns: Array.from({ length: columns }, (_, i) => ({ field: `c${i}` }))
|
||||
}
|
||||
}
|
||||
})
|
||||
// Each axis is individually under its cap, and rows of empty objects carry no
|
||||
// structure to count, so only the cell-product cap rejects this — which is why
|
||||
// the structural budget does not replace it.
|
||||
const overBudget = MAX_SAMPLE_CELLS / MAX_SAMPLE_COLUMNS + 1
|
||||
expect(rejected(pipeline(sample(overBudget, MAX_SAMPLE_COLUMNS)))).toBe(true)
|
||||
expect(kindOf(pipeline(sample(10, 20)))).toBe('pipeline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Validation for a recording arriving from outside — an uploaded file or a
|
||||
* `?src=` URL. Both replay pages go through this: a recording is caller-supplied
|
||||
* data that a player indexes into and renders per step, so its shape, its
|
||||
* cardinality and its sizes all have to hold before anything mounts.
|
||||
*
|
||||
* Covers every kind of recording, not only raw-app ones: the file name is fixed by
|
||||
* `package.json`'s `./components/recording/rawAppRecordingLoad` export, which the hub
|
||||
* imports, so renaming it means changing the hub in the same breath.
|
||||
*/
|
||||
import type {
|
||||
FlowRecording,
|
||||
PipelineRecording,
|
||||
RawAppRecording,
|
||||
RecordedJob,
|
||||
ScriptRecording
|
||||
} from './types'
|
||||
import {
|
||||
MAX_RECORDED_STEPS,
|
||||
MAX_STEP_TEXT_CHARS,
|
||||
MAX_TOTAL_FRAME_CHARS,
|
||||
RAW_APP_INTERACTION_KINDS,
|
||||
type RawAppInteractionKind
|
||||
} from './rawAppSnapshot'
|
||||
|
||||
/** Upper bound on a fetched recording (JSON of capped frames) so an arbitrary
|
||||
* origin can't exhaust the tab before validation runs. */
|
||||
export const MAX_RECORDING_BYTES = 100 * 1024 * 1024
|
||||
|
||||
/** Total structure in the whole recording, applied before any per-kind validator.
|
||||
* The per-value budgets below each cover only a field they name, so a field added
|
||||
* later is unbounded until someone names it; this one needs no field, which is what
|
||||
* makes the set closed. Far above any real capture, far below what a tab survives. */
|
||||
export const MAX_RECORDING_NODES = 2_000_000
|
||||
/* Caps on the job-stream recordings (flow/script/pipeline). Each recorded job
|
||||
* mounts a JobLoader and each of its events costs a `setTimeout` created up front,
|
||||
* so the counts — not just the byte size — decide whether the tab survives. */
|
||||
export const MAX_RECORDED_JOBS = 2000
|
||||
/** `JobLoader.watchJob` schedules every event of a job in one pass, so this is a
|
||||
* count of timers created at once; events at `t: 0` all fire in the same frame, each
|
||||
* one a reactive update. Generous against reality (a long streaming job records on
|
||||
* the order of thousands) and survivable when they all land together. */
|
||||
export const MAX_EVENTS_PER_JOB = 5000
|
||||
export const MAX_RECORDED_JOB_EVENTS = 20_000
|
||||
/** The backstop: structure one recorded value may expand into, where a value is
|
||||
* what a component renders eagerly. Deliberately generous — it is not the precise
|
||||
* bound but the one that catches keys nobody has named; anything mounting a
|
||||
* component per entry costs far more and gets {@link MAX_COMPONENT_FANOUT} on top. */
|
||||
export const MAX_VALUE_NODES = 100_000
|
||||
/** Total characters of text in one value. Strings are one node however long they
|
||||
* are, so this is the part of "how big is this value" the node count structurally
|
||||
* cannot see: a flow module's inline `content`/`lock` is syntax-highlighted in one
|
||||
* pass, exactly like the `code` that {@link MAX_CODE_CHARS} already covers. */
|
||||
export const MAX_VALUE_STRING_CHARS = 8 * 1024 * 1024
|
||||
/** Depth is its own hazard: a renderer recursing over a deeply nested value blows
|
||||
* the stack long before the node count matters. Well above real data (recursive
|
||||
* results nest tens deep) and well below what overflows a JS stack. */
|
||||
export const MAX_VALUE_DEPTH = 256
|
||||
/** Entries whose renderer mounts a *component* each rather than a cell or a row —
|
||||
* `render_all`, `data_tests`, and a flow's graph overlays. Two orders of magnitude
|
||||
* below the node budget, because that is roughly the cost ratio. Counted through the
|
||||
* serialized form too: a JSON string is one node whatever it decodes to. */
|
||||
export const MAX_COMPONENT_FANOUT = 1000
|
||||
/** How much serialized text a fan-out collection may be before validation stops
|
||||
* measuring it and just refuses. A real checklist is a few KB; anything near the text
|
||||
* budget would cost hundreds of MB to decode, and decoding it to find out how big it
|
||||
* is would be the denial of service. */
|
||||
export const MAX_SERIALIZED_FANOUT_CHARS = 256 * 1024
|
||||
/** Entries in one flat map a renderer turns into a row each. This and
|
||||
* {@link MAX_COMPONENT_FANOUT} are lists of keys, so they are inherently incomplete:
|
||||
* cost depends on the renderer, not the shape. Add the key of any new renderer that
|
||||
* iterates a recorded collection — {@link MAX_RECORDING_NODES} alone is too coarse. */
|
||||
export const MAX_MAP_ROWS = 2000
|
||||
/** `PipelineRecordingReplay.startReplay` schedules every frame in one pass, so this
|
||||
* counts timers created at once — frames at `t: 0` all land in the same tick, and
|
||||
* each reassigns the whole per-node status map and rebuilds the derived id/state maps
|
||||
* over its entire key set. Same bound as {@link MAX_EVENTS_PER_JOB}, same reason. */
|
||||
export const MAX_TIMELINE_FRAMES = 5000
|
||||
export const MAX_FRAME_STATUSES = 5000
|
||||
/** Graph elements each become a rendered canvas node or edge. */
|
||||
export const MAX_GRAPH_ELEMENTS = 2000
|
||||
/* An asset sample renders as a `rows × columns` table of plain `<td>`s, so the
|
||||
* product is what costs, and the per-axis caps alone would allow millions of
|
||||
* cells from a tiny payload of empty row objects. */
|
||||
export const MAX_SAMPLE_ROWS = 5000
|
||||
export const MAX_SAMPLE_COLUMNS = 500
|
||||
/** Not subsumed by {@link MAX_VALUE_NODES}: the table is the cross product of two
|
||||
* independent arrays, so rows of *empty* objects carry no structure to count yet
|
||||
* still render a cell each. Structure and cross products are different bounds. */
|
||||
export const MAX_SAMPLE_CELLS = 100_000
|
||||
/** Captured source is syntax-highlighted in one pass. */
|
||||
export const MAX_CODE_CHARS = 4 * 1024 * 1024
|
||||
/** `FlowGraphV2` builds and lays out a node per module, recursing into branches and
|
||||
* loops, plus one per note or group. Kept alongside the render budget because it
|
||||
* gives the flow-specific count a name; the budget is what makes it exhaustive. */
|
||||
export const MAX_FLOW_MODULES = 5000
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
|
||||
const isShortText = (v: unknown, required = false) =>
|
||||
required
|
||||
? typeof v === 'string' && v.length <= MAX_STEP_TEXT_CHARS
|
||||
: v === undefined || (typeof v === 'string' && v.length <= MAX_STEP_TEXT_CHARS)
|
||||
|
||||
const isSize = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 20000
|
||||
|
||||
const isBoundedCode = (v: unknown) => typeof v === 'string' && v.length <= MAX_CODE_CHARS
|
||||
|
||||
const isBoundedArray = (v: unknown, max: number): v is unknown[] =>
|
||||
Array.isArray(v) && v.length <= max
|
||||
|
||||
const isObjectArray = (v: unknown, max: number) => isBoundedArray(v, max) && v.every(isObject)
|
||||
|
||||
/** True when `data` is a well-formed app recording this build can replay. */
|
||||
export function isAppRecording(data: unknown): data is RawAppRecording {
|
||||
if (!isObject(data) || data.version !== 1 || data.type !== 'app') return false
|
||||
// Every frame is parsed and re-serialized before the iframe parses it again,
|
||||
// so a single huge frame would freeze the tab even under the download cap.
|
||||
const validFrames =
|
||||
Array.isArray(data.frames) &&
|
||||
data.frames.length <= 2 * MAX_RECORDED_STEPS + 1 &&
|
||||
data.frames.every((f) => typeof f === 'string') &&
|
||||
(data.frames as string[]).reduce((sum, f) => sum + f.length, 0) <= MAX_TOTAL_FRAME_CHARS
|
||||
if (!validFrames) return false
|
||||
const frameCount = (data.frames as string[]).length
|
||||
// An index must address a frame that exists; `undefined` stays legitimate for
|
||||
// a capture the recorder had to skip.
|
||||
const isIndex = (v: unknown) =>
|
||||
v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v < frameCount)
|
||||
const validSteps =
|
||||
Array.isArray(data.steps) &&
|
||||
data.steps.length <= MAX_RECORDED_STEPS &&
|
||||
data.steps.every(
|
||||
(s: unknown) =>
|
||||
isObject(s) &&
|
||||
// Finite, not merely numeric: the timeline positions each checkpoint at
|
||||
// `t / total_duration_ms`, and a NaN there places nothing.
|
||||
Number.isFinite(s.t) &&
|
||||
RAW_APP_INTERACTION_KINDS.includes(s.kind as RawAppInteractionKind) &&
|
||||
isShortText(s.label, true) &&
|
||||
isShortText(s.target) &&
|
||||
isShortText(s.selector) &&
|
||||
isShortText(s.value) &&
|
||||
isIndex(s.before) &&
|
||||
isIndex(s.after)
|
||||
)
|
||||
// The viewport lands in the snapshot iframe's `style`, and the duration is
|
||||
// divided into every step timestamp by the timeline.
|
||||
const validViewport =
|
||||
isObject(data.viewport) && isSize(data.viewport.width) && isSize(data.viewport.height)
|
||||
const validDuration =
|
||||
typeof data.total_duration_ms === 'number' &&
|
||||
Number.isFinite(data.total_duration_ms) &&
|
||||
data.total_duration_ms >= 0
|
||||
const validHeader =
|
||||
isShortText(data.app_path) && isShortText(data.workspace) && isShortText(data.recorded_at, true)
|
||||
return validSteps && validViewport && validDuration && validHeader
|
||||
}
|
||||
|
||||
/** Keys whose renderer mounts a component per entry. See {@link MAX_COMPONENT_FANOUT}. */
|
||||
const COMPONENT_FANOUT_KEYS = ['render_all', 'data_tests']
|
||||
|
||||
/** Keys holding a flat map rendered as one row per entry: `args` (`JobArgs` sorts the
|
||||
* keys and mounts a row each) and a schema's `properties` (`SchemaForm`/`SchemaViewer`
|
||||
* a field each). Capped per collection rather than cumulatively: a hundred small
|
||||
* schemas nested in a flow are fine, one map of 90k keys is not. */
|
||||
const MAP_ROW_KEYS = ['args', 'properties']
|
||||
|
||||
/** Entry count of a fan-out collection, decoding the serialized form renderers
|
||||
* accept — a JSON string is one node however many components it expands into. Only
|
||||
* decodes what could be a legitimate checklist: parsing megabytes here would commit
|
||||
* the very allocation this prevents, so anything larger is refused unmeasured. */
|
||||
function fanoutLength(v: unknown): number {
|
||||
if (Array.isArray(v)) return v.length
|
||||
if (typeof v === 'string') {
|
||||
if (v.length > MAX_SERIALIZED_FANOUT_CHARS) return Infinity
|
||||
try {
|
||||
const decoded = JSON.parse(v)
|
||||
return Array.isArray(decoded) ? decoded.length : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Entries a renderer would turn into rows, for {@link MAX_MAP_ROWS}. Counts arrays
|
||||
* as well as objects: `args` is only *conventionally* a map, and an array of 90k
|
||||
* primitives costs one node each and gets a row each just the same. */
|
||||
const rowCount = (v: unknown) =>
|
||||
Array.isArray(v) ? v.length : isObject(v) ? Object.keys(v).length : 0
|
||||
|
||||
/** Why one recorded value is too big to render, or `undefined`. Three bounds
|
||||
* because none subsumes the others: structure misses a 60MB string, text misses a
|
||||
* collection that mounts a component per entry, and fan-out misses everything
|
||||
* unnamed. Bails on the first blown bound so the walk is never itself the attack. */
|
||||
function describeValueOverflow(
|
||||
v: unknown,
|
||||
budget = { nodes: MAX_VALUE_NODES, chars: MAX_VALUE_STRING_CHARS, fanout: MAX_COMPONENT_FANOUT },
|
||||
depth = 0
|
||||
): string | undefined {
|
||||
if (depth > MAX_VALUE_DEPTH) return `nested more than ${MAX_VALUE_DEPTH} levels deep`
|
||||
if (typeof v === 'string') {
|
||||
budget.chars -= v.length
|
||||
return budget.chars < 0 ? `more than ${MAX_VALUE_STRING_CHARS} characters of text` : undefined
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
budget.nodes -= v.length
|
||||
if (budget.nodes < 0) return `more than ${MAX_VALUE_NODES} values to render`
|
||||
for (const item of v) {
|
||||
const over = describeValueOverflow(item, budget, depth + 1)
|
||||
if (over) return over
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (isObject(v)) {
|
||||
const keys = Object.keys(v)
|
||||
budget.nodes -= keys.length
|
||||
if (budget.nodes < 0) return `more than ${MAX_VALUE_NODES} values to render`
|
||||
for (const k of keys) {
|
||||
// A key is rendered text as much as a value is — `JobArgs` prints it — so it
|
||||
// is charged against the same budget.
|
||||
budget.chars -= k.length
|
||||
if (budget.chars < 0) return `more than ${MAX_VALUE_STRING_CHARS} characters of text`
|
||||
// Cumulative across the value, not per array: `render_all` nests, so 300
|
||||
// arrays of 300 are 90k components with no single array over the cap.
|
||||
if (COMPONENT_FANOUT_KEYS.includes(k)) {
|
||||
budget.fanout -= fanoutLength(v[k])
|
||||
if (budget.fanout < 0) {
|
||||
return `more than ${MAX_COMPONENT_FANOUT} \`${k}\`-style entries, each of which mounts its own component`
|
||||
}
|
||||
}
|
||||
if (MAP_ROW_KEYS.includes(k) && rowCount(v[k]) > MAX_MAP_ROWS) {
|
||||
return `a \`${k}\` of more than ${MAX_MAP_ROWS} entries, each of which renders a row`
|
||||
}
|
||||
const over = describeValueOverflow(v[k], budget, depth + 1)
|
||||
if (over) return over
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Structure in the whole recording, for the {@link MAX_RECORDING_NODES} backstop.
|
||||
* Separate from {@link describeValueOverflow} because it deliberately knows nothing
|
||||
* about keys or renderers — it just refuses to let an arbitrary payload be huge. */
|
||||
function countRecordingNodes(v: unknown, budget = { n: MAX_RECORDING_NODES + 1 }): number {
|
||||
let count = 0
|
||||
// Past the ceiling the answer is `Infinity`, not the partial count: giving up on
|
||||
// the walk and reporting what was seen so far would let any amount of structure
|
||||
// hide behind a few hundred wrappers, which is the opposite of a backstop.
|
||||
let tooDeep = false
|
||||
const walk = (x: unknown, depth: number) => {
|
||||
if (tooDeep || budget.n <= 0) return
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
tooDeep = true
|
||||
return
|
||||
}
|
||||
if (Array.isArray(x)) {
|
||||
count += x.length
|
||||
budget.n -= x.length
|
||||
for (const i of x) walk(i, depth + 1)
|
||||
} else if (isObject(x)) {
|
||||
const keys = Object.keys(x)
|
||||
count += keys.length
|
||||
budget.n -= keys.length
|
||||
for (const k of keys) walk(x[k], depth + 1)
|
||||
}
|
||||
}
|
||||
walk(v, 0)
|
||||
return tooDeep ? Infinity : count
|
||||
}
|
||||
|
||||
/** True when one recorded value is renderable. Apply this to each value a component
|
||||
* expands eagerly (a job's args/result/flow_status, a flow definition, an asset
|
||||
* sample); the *number* of such values is bounded separately. */
|
||||
const withinRenderBudget = (v: unknown) => describeValueOverflow(v) === undefined
|
||||
|
||||
/** JobLoader replays each `event.data` in a `setTimeout`, where a throw escapes
|
||||
* every Svelte boundary, so a malformed event must be refused at load. Each job
|
||||
* state is also held to the render budget, which covers everything hanging off it
|
||||
* at any depth. */
|
||||
function isRecordedJob(j: unknown): j is RecordedJob {
|
||||
return (
|
||||
isObject(j) &&
|
||||
isObject(j.initial_job) &&
|
||||
withinRenderBudget(j.initial_job) &&
|
||||
isBoundedArray(j.events, MAX_EVENTS_PER_JOB) &&
|
||||
j.events.every((e) => isObject(e) && isObject(e.data) && withinRenderBudget(e.data))
|
||||
)
|
||||
}
|
||||
|
||||
/** The `jobs` map every job-stream recording carries, bounded on both the number
|
||||
* of streams and the total number of events across them. */
|
||||
function isJobsMap(v: unknown): v is Record<string, RecordedJob> {
|
||||
if (!isObject(v)) return false
|
||||
const jobs = Object.values(v)
|
||||
if (jobs.length > MAX_RECORDED_JOBS) return false
|
||||
let events = 0
|
||||
for (const j of jobs) {
|
||||
if (!isRecordedJob(j)) return false
|
||||
events += j.events.length
|
||||
if (events > MAX_RECORDED_JOB_EVENTS) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** The header every recording renders: a title, a `recorded_at` each player parses
|
||||
* as a date, and a duration it divides by. Required by the types, so a payload
|
||||
* missing them shows `Invalid Date` and `NaN` instead. */
|
||||
function hasValidHeader(data: Record<string, unknown>, pathField: string): boolean {
|
||||
return (
|
||||
isShortText(data[pathField], true) &&
|
||||
isShortText(data.recorded_at, true) &&
|
||||
typeof data.total_duration_ms === 'number' &&
|
||||
Number.isFinite(data.total_duration_ms) &&
|
||||
data.total_duration_ms >= 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Total modules across a flow definition's nested structure (branches, loops),
|
||||
* stopping as soon as the budget is blown so a hostile tree can't make the walk
|
||||
* itself the denial of service. */
|
||||
function countFlowModules(modules: unknown, budget: number): number {
|
||||
if (!Array.isArray(modules)) return 0
|
||||
let n = 0
|
||||
for (const m of modules) {
|
||||
if (++n > budget) return n
|
||||
if (!isObject(m) || !isObject(m.value)) continue
|
||||
for (const key of ['modules', 'default'] as const) {
|
||||
n += countFlowModules((m.value as Record<string, unknown>)[key], budget - n)
|
||||
if (n > budget) return n
|
||||
}
|
||||
const branches = (m.value as Record<string, unknown>).branches
|
||||
if (Array.isArray(branches)) {
|
||||
for (const b of branches) {
|
||||
// The branch itself is a node and an edge even when it holds no modules,
|
||||
// so counting only its contents would let empty branches ride free.
|
||||
if (++n > budget) return n
|
||||
if (!isObject(b)) continue
|
||||
n += countFlowModules(b.modules, budget - n)
|
||||
if (n > budget) return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** True when `data` is a well-formed script recording. */
|
||||
export function isScriptRecording(data: unknown): data is ScriptRecording {
|
||||
if (!isObject(data) || data.version !== 1 || data.type !== 'script') return false
|
||||
// `code` is highlighted in one pass and `language` selects the grammar.
|
||||
return (
|
||||
hasValidHeader(data, 'script_path') &&
|
||||
isRecordedJob(data.job) &&
|
||||
isBoundedCode(data.code) &&
|
||||
// Selects a highlighter grammar and is rendered in the player's header.
|
||||
isShortText(data.language, true) &&
|
||||
// These arrive as the root of the walk, where there is no enclosing key for
|
||||
// MAP_ROW_KEYS to match, so their own row counts are checked here.
|
||||
rowCount(data.args) <= MAX_MAP_ROWS &&
|
||||
rowCount((data.schema as Record<string, unknown> | undefined)?.properties) <= MAX_MAP_ROWS &&
|
||||
withinRenderBudget(data.schema) &&
|
||||
withinRenderBudget(data.args)
|
||||
)
|
||||
}
|
||||
|
||||
/** True when `data` is a well-formed pipeline recording. */
|
||||
export function isPipelineRecording(data: unknown): data is PipelineRecording {
|
||||
if (!isObject(data) || data.version !== 1 || data.type !== 'pipeline') return false
|
||||
const g = data.graph
|
||||
const validGraph =
|
||||
isObject(g) &&
|
||||
// The canvas emits a node and an edge per nested entry too (a runnable's custom
|
||||
// `data_tests`, its column lineage), so the whole graph goes through the budget
|
||||
// rather than just the lengths of the four top-level arrays.
|
||||
withinRenderBudget(g) &&
|
||||
isObjectArray(g.runnables, MAX_GRAPH_ELEMENTS) &&
|
||||
isObjectArray(g.assets, MAX_GRAPH_ELEMENTS) &&
|
||||
isObjectArray(g.edges, MAX_GRAPH_ELEMENTS) &&
|
||||
isBoundedArray(g.triggers, MAX_GRAPH_ELEMENTS) &&
|
||||
g.triggers.every((t) => isObject(t) && typeof t.trigger_kind === 'string') &&
|
||||
(g.macro_edges === undefined || isObjectArray(g.macro_edges, MAX_GRAPH_ELEMENTS)) &&
|
||||
(g.test_edges === undefined || isObjectArray(g.test_edges, MAX_GRAPH_ELEMENTS))
|
||||
const validTimeline =
|
||||
isBoundedArray(data.timeline, MAX_TIMELINE_FRAMES) &&
|
||||
data.timeline.every(
|
||||
(f) =>
|
||||
isObject(f) &&
|
||||
isObject(f.statuses) &&
|
||||
Object.keys(f.statuses).length <= MAX_FRAME_STATUSES &&
|
||||
withinRenderBudget(f.statuses) &&
|
||||
Object.values(f.statuses).every(isObject)
|
||||
)
|
||||
// A sample renders `rows`/`columns` unless it carries a non-empty `error`.
|
||||
const validSamples =
|
||||
data.assetSamples === undefined ||
|
||||
(isObject(data.assetSamples) &&
|
||||
Object.values(data.assetSamples).every(
|
||||
(s) =>
|
||||
isObject(s) &&
|
||||
// Both branches render the sample's own fields (`uri`, `rowCount`), so the
|
||||
// budget applies either way; only the table is extra.
|
||||
withinRenderBudget(s) &&
|
||||
((isShortText(s.error, true) && s.error !== '') ||
|
||||
(isObjectArray(s.rows, MAX_SAMPLE_ROWS) &&
|
||||
isObjectArray(s.columns, MAX_SAMPLE_COLUMNS) &&
|
||||
(s.rows as unknown[]).length * (s.columns as unknown[]).length <= MAX_SAMPLE_CELLS))
|
||||
))
|
||||
const validCodes =
|
||||
data.codes === undefined ||
|
||||
(isObject(data.codes) &&
|
||||
Object.values(data.codes).every(
|
||||
(c) => isObject(c) && isBoundedCode(c.content) && isShortText(c.language, true)
|
||||
))
|
||||
return (
|
||||
hasValidHeader(data, 'folder') &&
|
||||
validGraph &&
|
||||
validTimeline &&
|
||||
isJobsMap(data.jobs) &&
|
||||
validSamples &&
|
||||
validCodes
|
||||
)
|
||||
}
|
||||
|
||||
/** True when `data` is a well-formed flow recording. `type` is absent on
|
||||
* recordings taken before the discriminator existed. */
|
||||
export function isFlowRecording(data: unknown): data is FlowRecording {
|
||||
if (!isObject(data) || data.version !== 1) return false
|
||||
if (data.type !== undefined && data.type !== 'flow') return false
|
||||
if (!hasValidHeader(data, 'flow_path') || !isJobsMap(data.jobs)) return false
|
||||
if (data.flow === undefined) return true
|
||||
// The player hands the whole `flow` to FlowViewer, so `schema` renders (Input
|
||||
// Schema tab, Input node) just like `value` does — budget one level up.
|
||||
if (!isObject(data.flow) || !withinRenderBudget(data.flow)) return false
|
||||
const value = data.flow.value
|
||||
if (value === undefined) return true
|
||||
if (!isObject(value)) return false
|
||||
// The graph mounts a node per note and per group alongside the modules, so they
|
||||
// fan out like `render_all` does — the structural budget alone would admit tens
|
||||
// of thousands of minimal entries and lock the tab before Play.
|
||||
if (fanoutLength(value.notes) + fanoutLength(value.groups) > MAX_COMPONENT_FANOUT) return false
|
||||
return countFlowModules(value.modules, MAX_FLOW_MODULES) <= MAX_FLOW_MODULES
|
||||
}
|
||||
|
||||
/** A recording that passed validation, tagged with the player it needs. */
|
||||
export type LoadedRecording =
|
||||
| { kind: 'app'; recording: RawAppRecording }
|
||||
| { kind: 'script'; recording: ScriptRecording }
|
||||
| { kind: 'pipeline'; recording: PipelineRecording }
|
||||
| { kind: 'flow'; recording: FlowRecording }
|
||||
|
||||
/** The cap a well-formed but oversized recording tripped, if any. A genuine
|
||||
* capture can hit these (a wide for-loop flow records a job per iteration), so it
|
||||
* must not be reported with the same message as a corrupt file. */
|
||||
function describeOverflow(data: Record<string, unknown>): string | undefined {
|
||||
const jobs = isObject(data.jobs) ? Object.values(data.jobs) : []
|
||||
if (jobs.length > MAX_RECORDED_JOBS) {
|
||||
return `This recording holds ${jobs.length} jobs, more than the ${MAX_RECORDED_JOBS} this player can replay.`
|
||||
}
|
||||
const events = jobs.reduce(
|
||||
(sum: number, j) => sum + (isObject(j) && Array.isArray(j.events) ? j.events.length : 0),
|
||||
0
|
||||
)
|
||||
if (events > MAX_RECORDED_JOB_EVENTS) {
|
||||
return `This recording holds ${events} job events, more than the ${MAX_RECORDED_JOB_EVENTS} this player can replay.`
|
||||
}
|
||||
if (Array.isArray(data.timeline) && data.timeline.length > MAX_TIMELINE_FRAMES) {
|
||||
return `This recording holds ${data.timeline.length} timeline frames, more than the ${MAX_TIMELINE_FRAMES} this player can animate.`
|
||||
}
|
||||
// The render budget is the cap a legitimate capture is most likely to trip (the
|
||||
// recorders stringify job results verbatim), so name the value that blew it and
|
||||
// what about it was too big instead of reporting a format error.
|
||||
const samples = isObject(data.assetSamples) ? Object.values(data.assetSamples) : []
|
||||
for (const [label, value] of [
|
||||
['a recorded job', jobs.find((j) => !withinRenderBudget(j))],
|
||||
['this flow definition', withinRenderBudget(data.flow) ? undefined : data.flow],
|
||||
["this script's inputs", withinRenderBudget(data.schema) ? undefined : data.schema],
|
||||
['a recorded asset sample', samples.find((s) => !withinRenderBudget(s))]
|
||||
] as const) {
|
||||
const over = value === undefined ? undefined : describeValueOverflow(value)
|
||||
if (over) return `Cannot replay: ${label} carries ${over}.`
|
||||
}
|
||||
// Checked at the root of the walk by `isScriptRecording`, where there is no
|
||||
// enclosing key for the walk itself to report on.
|
||||
for (const [label, size] of [
|
||||
["this script's arguments", rowCount(data.args)],
|
||||
[
|
||||
"this script's schema",
|
||||
rowCount((data.schema as Record<string, unknown> | undefined)?.properties)
|
||||
]
|
||||
] as const) {
|
||||
if (size > MAX_MAP_ROWS) {
|
||||
return `Cannot replay: ${label} holds ${size} entries, more than the ${MAX_MAP_ROWS} this player renders.`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Classify a parsed recording and validate it against the player that would
|
||||
* mount it. The `type` discriminator picks the validator, so a malformed payload
|
||||
* reports the kind it claimed to be instead of falling through to `flow`. */
|
||||
export function parseRecording(
|
||||
data: unknown
|
||||
): { ok: true; loaded: LoadedRecording } | { ok: false; error: string } {
|
||||
if (!isObject(data) || data.version !== 1) {
|
||||
return { ok: false, error: 'This file is not a Windmill recording.' }
|
||||
}
|
||||
// Before anything looks at what the fields mean: no recording, whatever it holds,
|
||||
// may carry more structure than a tab can render. This is what makes the bound
|
||||
// exhaustive rather than a list of the fields someone remembered.
|
||||
if (countRecordingNodes(data) > MAX_RECORDING_NODES) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `This recording carries more than ${MAX_RECORDING_NODES} values, more than this player can render.`
|
||||
}
|
||||
}
|
||||
const type = data.type === undefined ? 'flow' : data.type
|
||||
const invalid = (kind: string) => ({
|
||||
ok: false as const,
|
||||
error: describeOverflow(data) ?? `Invalid ${kind} recording format.`
|
||||
})
|
||||
switch (type) {
|
||||
case 'app':
|
||||
return isAppRecording(data)
|
||||
? { ok: true, loaded: { kind: 'app', recording: data } }
|
||||
: invalid('app')
|
||||
case 'script':
|
||||
return isScriptRecording(data)
|
||||
? { ok: true, loaded: { kind: 'script', recording: data } }
|
||||
: invalid('script')
|
||||
case 'pipeline':
|
||||
return isPipelineRecording(data)
|
||||
? { ok: true, loaded: { kind: 'pipeline', recording: data } }
|
||||
: invalid('pipeline')
|
||||
case 'flow':
|
||||
return isFlowRecording(data)
|
||||
? { ok: true, loaded: { kind: 'flow', recording: data } }
|
||||
: invalid('flow')
|
||||
default: {
|
||||
// `type` is caller-controlled and only structurally bounded: a payload can
|
||||
// carry megabytes in it and reach the page as text. Name it only when it is
|
||||
// short enough to be a kind rather than a payload.
|
||||
const named = typeof type === 'string' && type.length <= 32 ? ` (${type})` : ''
|
||||
return {
|
||||
ok: false,
|
||||
error: `This recording is of an unknown kind${named} — it may need a newer Windmill.`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a recording from `url`, enforcing the download cap while streaming. */
|
||||
export async function fetchRecording(
|
||||
url: string,
|
||||
onProgress?: (loaded: number, total: number) => void
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
|
||||
const total = Number(res.headers.get('content-length')) || 0
|
||||
if (total > MAX_RECORDING_BYTES) throw new Error(`Recording is too large (${total} bytes)`)
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
const text = await res.text()
|
||||
if (text.length > MAX_RECORDING_BYTES) throw new Error('Recording exceeded the size limit')
|
||||
return JSON.parse(text)
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let loaded = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (!value) continue
|
||||
chunks.push(value)
|
||||
loaded += value.length
|
||||
if (loaded > MAX_RECORDING_BYTES) {
|
||||
await reader.cancel()
|
||||
throw new Error('Recording exceeded the size limit')
|
||||
}
|
||||
onProgress?.(loaded, total)
|
||||
}
|
||||
return JSON.parse(await new Blob(chunks as BlobPart[]).text())
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* The two contracts of this module that are worth pinning: a snapshot never
|
||||
* carries what the app marked no-record, and a replayed frame can neither run
|
||||
* nor fetch nor navigate. Both are non-obvious against the DOM (a template's
|
||||
* content is serialized but not queryable, attributes match on local name, the
|
||||
* policy has to be structurally first) and both are where a later simplification
|
||||
* would silently reopen something.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { serializeDocument, withHighlightStyles } from './rawAppSnapshot'
|
||||
|
||||
function docFrom(body: string): Document {
|
||||
return new DOMParser().parseFromString(
|
||||
`<html><head></head><body>${body}</body></html>`,
|
||||
'text/html'
|
||||
)
|
||||
}
|
||||
|
||||
describe('serializeDocument redaction', () => {
|
||||
it('drops the content, attributes and template fragment of a no-record element', () => {
|
||||
const doc = docFrom(
|
||||
`<div data-wm-no-record="why-hidden-92000" title="salary 92000" data-token="sk-secret" aria-label="confidential">visible secret</div>` +
|
||||
`<select><option data-wm-no-record label="codename falcon">falcon</option></select>` +
|
||||
`<template data-wm-no-record><input value="template secret"></template>`
|
||||
)
|
||||
const svgLink = doc.createElementNS('http://www.w3.org/2000/svg', 'image')
|
||||
svgLink.setAttribute('data-wm-no-record', '')
|
||||
svgLink.setAttributeNS(
|
||||
'http://www.w3.org/1999/xlink',
|
||||
'xlink:href',
|
||||
'https://host/signed-secret'
|
||||
)
|
||||
doc.body.appendChild(svgLink)
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
for (const secret of [
|
||||
'visible secret',
|
||||
'salary 92000',
|
||||
'sk-secret',
|
||||
'confidential',
|
||||
'codename falcon',
|
||||
'falcon',
|
||||
'template secret',
|
||||
'signed-secret',
|
||||
// The marker itself is author-written free text; it survives so the element
|
||||
// can still be styled, but carries nothing of its own.
|
||||
'why-hidden-92000'
|
||||
]) {
|
||||
expect(html).not.toContain(secret)
|
||||
}
|
||||
expect(html).toContain('data-wm-no-record=""')
|
||||
})
|
||||
|
||||
it('redacts a marked document root down to its constrained attributes', () => {
|
||||
// Marking the root marks every stylesheet with it, so no CSS survives to
|
||||
// justify keeping a class: only attributes whose values cannot carry content
|
||||
// are left.
|
||||
const doc = docFrom(
|
||||
`<style>.theme-dark { color: white }</style><p>everything here is private</p>`
|
||||
)
|
||||
doc.documentElement.setAttribute('data-wm-no-record', '')
|
||||
doc.documentElement.setAttribute('class', 'theme-dark')
|
||||
doc.documentElement.setAttribute('hidden', '')
|
||||
doc.documentElement.setAttribute('cite', 'https://host/private-source')
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('everything here is private')
|
||||
expect(html).not.toContain('private-source')
|
||||
expect(html).not.toContain('theme-dark')
|
||||
expect(html).toContain('hidden')
|
||||
})
|
||||
|
||||
it('keeps no attribute that could carry content, listed or not', () => {
|
||||
const doc = docFrom(
|
||||
`<style>.frame { border: 1px solid }</style>` +
|
||||
`<iframe data-wm-no-record srcdoc="<p>embedded secret</p>" data-anything="future secret"` +
|
||||
` cite="/cited" style="--code: salary-92000" class="frame"></iframe>`
|
||||
)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('embedded secret')
|
||||
expect(html).not.toContain('future secret')
|
||||
expect(html).not.toContain('/cited')
|
||||
expect(html).not.toContain('salary-92000')
|
||||
// The layout-bearing class survives: the snapshot's own CSS selects on it.
|
||||
expect(html).toContain('class="frame"')
|
||||
})
|
||||
|
||||
it('does not let a marked stylesheet justify keeping the class it selects', () => {
|
||||
// The marked sheet is scrubbed, so its selectors are not part of the
|
||||
// snapshot's vocabulary: honouring them would launder the very token the
|
||||
// author marked the sheet to withhold.
|
||||
const doc = docFrom(
|
||||
`<style data-wm-no-record>.salary-92000 { color: red }</style>` +
|
||||
`<div data-wm-no-record class="salary-92000">x</div>`
|
||||
)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('salary-92000')
|
||||
})
|
||||
|
||||
it('keeps only the class and id tokens the snapshot styles', () => {
|
||||
// `class` and `id` stay on a redacted element so its box keeps its shape,
|
||||
// but the values are the app's to choose and can name what the marker hides.
|
||||
const doc = docFrom(
|
||||
`<style>.card { padding: 4px }</style>` +
|
||||
`<div data-wm-no-record class="card customer-acme-secret" id="salary-92000">x</div>`
|
||||
)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).toContain('class="card"')
|
||||
expect(html).not.toContain('customer-acme-secret')
|
||||
expect(html).not.toContain('salary-92000')
|
||||
})
|
||||
|
||||
it('withholds even the state of a redacted control', () => {
|
||||
// Whether a marked box is ticked is exactly what the marker exists to hide;
|
||||
// the step's value is masked to match, so label and snapshot agree.
|
||||
const doc = docFrom(`<input type="checkbox" data-wm-no-record aria-label="acquisition target">`)
|
||||
const box = doc.querySelector('input') as HTMLInputElement
|
||||
box.checked = true
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('checked')
|
||||
expect(html).not.toContain('acquisition target')
|
||||
})
|
||||
|
||||
it('does not launder a marked stylesheet into the snapshot by inlining it', () => {
|
||||
// The inliner exists for sheets whose rules live only in the CSSOM (an empty
|
||||
// `<style>` filled with `insertRule`, or a `<link>`); it must skip a marked
|
||||
// owner, or the replacement node would carry the CSS past redaction.
|
||||
// The live document, not a parsed one: only a document with a browsing
|
||||
// context exposes `style.sheet`, which is what the inliner reads.
|
||||
const style = document.createElement('style')
|
||||
style.setAttribute('data-wm-no-record', '')
|
||||
document.head.appendChild(style)
|
||||
try {
|
||||
style.sheet?.insertRule(`.a { content: "sentinel-92000"; }`, 0)
|
||||
expect(style.sheet?.cssRules.length).toBe(1)
|
||||
expect(style.textContent).toBe('')
|
||||
|
||||
expect(serializeDocument(document)).not.toContain('sentinel-92000')
|
||||
} finally {
|
||||
style.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshots a stylesheet as it renders, not as it was authored', () => {
|
||||
// `insertRule` and friends never touch the element's text, so a <style> that
|
||||
// shipped with CSS and was then mutated at runtime would replay stale.
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `.authored { color: red; }`
|
||||
document.head.appendChild(style)
|
||||
try {
|
||||
style.sheet?.insertRule(`.added-at-runtime { color: blue; }`, 0)
|
||||
const html = serializeDocument(document)
|
||||
expect(html).toContain('added-at-runtime')
|
||||
expect(html).toContain('authored')
|
||||
} finally {
|
||||
style.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a utility class whose selector starts with an escape', () => {
|
||||
// `2xl:block` compiles to `.\\32 xl\\:block` and `!flex` to `.\\!flex`; demanding an
|
||||
// ASCII first character drops both from the placeholder.
|
||||
const doc = docFrom(
|
||||
`<style>.\\32 xl\\:block { display: block } .\\!flex { display: flex }</style>` +
|
||||
`<div data-wm-no-record class="2xl:block !flex">x</div>`
|
||||
)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).toContain('class="2xl:block !flex"')
|
||||
})
|
||||
|
||||
it('keeps a utility class whose selector is escaped', () => {
|
||||
// A framework writes `md:flex` as `.md\\:flex`; reading the selector up to the
|
||||
// backslash would drop the real token and leave the placeholder unstyled.
|
||||
const doc = docFrom(
|
||||
`<style>.md\\:flex { display: flex } .w-1\\/2 { width: 50% }</style>` +
|
||||
`<div data-wm-no-record class="md:flex w-1/2 not-styled-92000">x</div>`
|
||||
)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).toContain('class="md:flex w-1/2"')
|
||||
expect(html).not.toContain('not-styled-92000')
|
||||
})
|
||||
|
||||
it('paints a canvas into the snapshot, but never a redacted one', () => {
|
||||
// A canvas keeps its picture in a bitmap `outerHTML` cannot see, so without
|
||||
// this the chart replays blank. A marked one must stay blank: the painted
|
||||
// background rides on `style`, which redaction strips.
|
||||
const doc = docFrom(
|
||||
`<canvas id="chart"></canvas><div data-wm-no-record><canvas id="secret"></canvas></div>`
|
||||
)
|
||||
for (const c of Array.from(doc.querySelectorAll('canvas'))) {
|
||||
Object.defineProperty(c, 'width', { value: 200 })
|
||||
Object.defineProperty(c, 'height', { value: 100 })
|
||||
Object.defineProperty(c, 'toDataURL', {
|
||||
value: () => `data:image/webp;base64,PIXELS-${c.id}`
|
||||
})
|
||||
Object.defineProperty(c, 'getBoundingClientRect', {
|
||||
value: () => ({ width: 200, height: 100 })
|
||||
})
|
||||
}
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).toContain('PIXELS-chart')
|
||||
expect(html).not.toContain('PIXELS-secret')
|
||||
})
|
||||
|
||||
it('stops encoding once a snapshot has spent its canvas budget', () => {
|
||||
// Encoding is synchronous and on the app's event path, so a wall of charts
|
||||
// must not each cost an encode — the per-canvas cap alone would allow it.
|
||||
const doc = docFrom(
|
||||
Array.from({ length: 6 }, (_, i) => `<canvas id="c${i}"></canvas>`).join('')
|
||||
)
|
||||
const encoded: string[] = []
|
||||
for (const c of Array.from(doc.querySelectorAll('canvas'))) {
|
||||
Object.defineProperty(c, 'width', { value: 2000 })
|
||||
Object.defineProperty(c, 'height', { value: 1500 }) // 3M pixels each
|
||||
Object.defineProperty(c, 'getBoundingClientRect', {
|
||||
value: () => ({ width: 200, height: 150 })
|
||||
})
|
||||
Object.defineProperty(c, 'toDataURL', {
|
||||
value: () => {
|
||||
encoded.push(c.id)
|
||||
return `data:image/webp;base64,PIXELS-${c.id}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
serializeDocument(doc)
|
||||
expect(encoded).toEqual(['c0', 'c1'])
|
||||
})
|
||||
|
||||
it('keeps a disabled sheet inert without shifting what follows it', () => {
|
||||
// Neutralizing a disabled sheet must not remove its node: every later path
|
||||
// resolution — other sheets, and the target stamp — is by sibling index.
|
||||
const off = document.createElement('style')
|
||||
off.textContent = `.disabled-rule { color: red; }`
|
||||
const on = document.createElement('style')
|
||||
document.head.append(off, on)
|
||||
try {
|
||||
off.sheet!.disabled = true
|
||||
on.sheet?.insertRule(`.live-rule { color: green; }`, 0)
|
||||
|
||||
const html = serializeDocument(document)
|
||||
expect(html).toContain('live-rule')
|
||||
expect(html).not.toContain('disabled-rule')
|
||||
expect(html).toContain('media="not all"')
|
||||
} finally {
|
||||
off.remove()
|
||||
on.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops <noscript> rather than trusting the redaction pass to see into it', () => {
|
||||
// With scripting on, a <noscript>'s markup is one text node — invisible to
|
||||
// the redaction pass, but `outerHTML` writes it back out. (A parser with
|
||||
// scripting off, like this one, exposes it as elements instead, so the
|
||||
// assertion that matters is that the element does not survive at all.)
|
||||
const doc = docFrom(`<noscript><div data-wm-no-record>noscript secret</div></noscript>`)
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('<noscript')
|
||||
expect(html).not.toContain('noscript secret')
|
||||
})
|
||||
|
||||
it('shows that a redacted option was chosen without saying which', () => {
|
||||
// The first select sits inside a marked container, whose subtree redaction
|
||||
// drops it from the clone entirely: the masking must already have run by
|
||||
// then, or pairing by index would silently skip every select in the document.
|
||||
const doc = docFrom(
|
||||
`<div data-wm-no-record><select><option>hidden</option></select></div>` +
|
||||
`<select><option>Public</option><option data-wm-no-record>Confidential case 92000</option></select>`
|
||||
)
|
||||
const select = doc.querySelectorAll('select')[1] as HTMLSelectElement
|
||||
select.selectedIndex = 1
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('Confidential case 92000')
|
||||
// Neither option is asserted as the choice; one masked entry stands in.
|
||||
expect(html).not.toContain('Public')
|
||||
expect(html).toContain('•••')
|
||||
})
|
||||
|
||||
it('masks a password without leaking its length, and keeps other values', () => {
|
||||
const doc = docFrom(`<input type="password"><input type="text">`)
|
||||
const [password, text] = Array.from(doc.querySelectorAll('input')) as HTMLInputElement[]
|
||||
password.value = 'hunter2-hunter2-hunter2'
|
||||
text.value = 'ordinary'
|
||||
|
||||
const html = serializeDocument(doc)
|
||||
expect(html).not.toContain('hunter2')
|
||||
expect(html).toContain('••••••••')
|
||||
expect(html).toContain('ordinary')
|
||||
})
|
||||
|
||||
it('stamps the interaction target even when a removed node precedes it', () => {
|
||||
// The stamp is resolved by child index, so it has to be applied before the
|
||||
// script/template removals shift later siblings.
|
||||
const doc = docFrom(`<script type="application/json">{}</script><button id="go">Go</button>`)
|
||||
const target = doc.getElementById('go')!
|
||||
|
||||
const html = serializeDocument(doc, { target })
|
||||
expect(html).toMatch(/<button id="go" data-wm-rec-target=""/)
|
||||
expect(html).not.toContain('<script')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withHighlightStyles', () => {
|
||||
it('puts the policy ahead of markup a <head> match would step over', () => {
|
||||
const out = withHighlightStyles(`<body><img src="/probe"><header>hi</header></body>`)
|
||||
expect(out.indexOf('Content-Security-Policy')).toBeLessThan(out.indexOf('/probe'))
|
||||
})
|
||||
|
||||
it('leaves nothing that can execute, fetch or navigate', () => {
|
||||
const out = withHighlightStyles(
|
||||
`<html><head><meta http-equiv="refresh" content="0;url=/x"></head><body>` +
|
||||
`<script>fetch('/x')</script><div onclick="fetch('/y')">hi</div>` +
|
||||
`<a href="/nav">link</a><form action="/post"><button formaction="/post2">go</button></form>` +
|
||||
`<svg><a xlink:href="/svg-nav"><set attributeName="href" to="/smil-nav"/></a></svg>` +
|
||||
`<div><template shadowrootmode="open"><a href="/shadow-nav">shadow</a></template></div>` +
|
||||
`<link rel="preload" href="/pre" as="script"></body></html>`
|
||||
)
|
||||
for (const gone of [
|
||||
'<script',
|
||||
'onclick',
|
||||
'refresh',
|
||||
'href=',
|
||||
'action=',
|
||||
'<set',
|
||||
'<template',
|
||||
'preload'
|
||||
]) {
|
||||
expect(out).not.toContain(gone)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rewriteCssUrls } from './rawAppSnapshot'
|
||||
|
||||
describe('rewriteCssUrls', () => {
|
||||
it('resolves relative references against the stylesheet, not the document', () => {
|
||||
const css = rewriteCssUrls(
|
||||
`.a { background: url(img/bg.png) } .b { background: url("../fonts/f.woff2") }`,
|
||||
'http://localhost:3000/api/w/demo/apps_u/get_data/v/abc.css'
|
||||
)
|
||||
expect(css).toBe(
|
||||
`.a { background: url(http://localhost:3000/api/w/demo/apps_u/get_data/v/img/bg.png) } ` +
|
||||
`.b { background: url("http://localhost:3000/api/w/demo/apps_u/get_data/fonts/f.woff2") }`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves url() inside a string or comment alone', () => {
|
||||
// A quoted `url(...)` is text the replay renders verbatim, not a reference.
|
||||
const css =
|
||||
`.hint::before { content: "url(icon.svg)" } ` +
|
||||
`/* url(commented.svg) */ ` +
|
||||
`.a { background: url(real.svg) }`
|
||||
expect(rewriteCssUrls(css, 'http://host/css/app.css')).toBe(
|
||||
`.hint::before { content: "url(icon.svg)" } ` +
|
||||
`/* url(commented.svg) */ ` +
|
||||
`.a { background: url(http://host/css/real.svg) }`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves references that are already resolvable alone', () => {
|
||||
const css = `.a { background: url(data:image/gif;base64,R0lGOD) } .b { background: url(https://cdn/x.png) }`
|
||||
expect(rewriteCssUrls(css, 'http://localhost:3000/app.css')).toBe(css)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* DOM snapshotting for raw-app session recordings: turns a live (same-origin)
|
||||
* app document into a self-contained HTML string that renders offline in a
|
||||
* script-less iframe, plus the small helpers that describe the element a user
|
||||
* interacted with.
|
||||
*/
|
||||
|
||||
/** Stamped on the element a step acted on, so the player can highlight it
|
||||
* without re-running a selector against a snapshot the app may have re-rendered. */
|
||||
export const REC_TARGET_ATTR = 'data-wm-rec-target'
|
||||
|
||||
/** App authors mark sensitive nodes with this attribute: their content is left
|
||||
* out of every snapshot and their values never reach a step. */
|
||||
export const NO_RECORD_ATTR = 'data-wm-no-record'
|
||||
|
||||
/** Hard cap on the steps a recording may hold. The player renders a row per
|
||||
* step, so the loader enforces it too on recordings it did not produce. */
|
||||
export const MAX_RECORDED_STEPS = 500
|
||||
|
||||
/** Upper bound on any one string a step carries (its label, value, selector).
|
||||
* The recorder truncates well below this; the loader refuses more. */
|
||||
export const MAX_STEP_TEXT_CHARS = 1000
|
||||
|
||||
/** Snapshots are whole documents; the recorder stops storing them past this, and
|
||||
* the loader refuses a recording that claims more — every frame is parsed and
|
||||
* re-serialized before it is handed to the iframe. */
|
||||
export const MAX_TOTAL_FRAME_CHARS = 40 * 1024 * 1024
|
||||
|
||||
export const RAW_APP_INTERACTION_KINDS = [
|
||||
'click',
|
||||
'fill',
|
||||
'select',
|
||||
'toggle',
|
||||
'submit',
|
||||
'key',
|
||||
'navigate'
|
||||
] as const
|
||||
|
||||
export type RawAppInteractionKind = (typeof RAW_APP_INTERACTION_KINDS)[number]
|
||||
|
||||
/** Resolve `url(...)` references of an inlined stylesheet against the sheet's
|
||||
* own URL: once its rules move into a `<style>` in the document, a relative
|
||||
* reference would otherwise resolve against the document instead of the sheet. */
|
||||
export function rewriteCssUrls(css: string, sheetHref: string): string {
|
||||
const resolve = (match: string, quote: string, raw: string) => {
|
||||
const url = raw.trim()
|
||||
if (!url || /^(data:|blob:|about:|https?:|\/\/|#)/i.test(url)) return match
|
||||
try {
|
||||
return `url(${quote}${new URL(url, sheetHref).href}${quote})`
|
||||
} catch (_) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
// Only real url() tokens: `content: "url(x.svg)"` is a string the replay must
|
||||
// render verbatim, so strings (and comments, which can hold anything) are
|
||||
// copied through and never scanned.
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i < css.length) {
|
||||
const ch = css[i]
|
||||
if (ch === '"' || ch === "'") {
|
||||
const end = skipString(css, i)
|
||||
out += css.slice(i, end)
|
||||
i = end
|
||||
} else if (ch === '/' && css[i + 1] === '*') {
|
||||
const end = css.indexOf('*/', i + 2)
|
||||
const stop = end === -1 ? css.length : end + 2
|
||||
out += css.slice(i, stop)
|
||||
i = stop
|
||||
} else {
|
||||
const m = /^url\(\s*(['"]?)([^'")]+)\1\s*\)/i.exec(css.slice(i))
|
||||
if (m) {
|
||||
out += resolve(m[0], m[1], m[2])
|
||||
i += m[0].length
|
||||
} else {
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Index just past the string starting at `start`, honouring CSS backslash
|
||||
* escapes so an escaped quote does not end it early. */
|
||||
function skipString(css: string, start: number): number {
|
||||
const quote = css[start]
|
||||
let i = start + 1
|
||||
while (i < css.length) {
|
||||
if (css[i] === '\\') i += 2
|
||||
else if (css[i] === quote) return i + 1
|
||||
else i++
|
||||
}
|
||||
return css.length
|
||||
}
|
||||
|
||||
/* The recorded document lives in another realm (the app's iframe), where
|
||||
* `instanceof Element` / `instanceof HTMLInputElement` are always false against
|
||||
* this window's constructors. Every node test here goes through nodeType/tagName
|
||||
* instead, and callers must do the same. */
|
||||
|
||||
/** Realm-agnostic `instanceof Element`. */
|
||||
export function isElementNode(node: unknown): node is Element {
|
||||
return !!node && typeof node === 'object' && (node as Node).nodeType === 1
|
||||
}
|
||||
|
||||
/** Realm-agnostic tag test, e.g. `isTag(el, 'INPUT')`. */
|
||||
export function isTag(el: Element, tagName: string): boolean {
|
||||
return el.tagName === tagName
|
||||
}
|
||||
|
||||
/** Never carry a typed secret into a recording that gets downloaded and shared.
|
||||
* Fixed width: the length of a masked value is itself information. */
|
||||
export function maskValue(value: string): string {
|
||||
return value ? '••••••••' : ''
|
||||
}
|
||||
|
||||
/** Index path from `root` down to `el` (element children only), so a node can be
|
||||
* located again in a structural clone of the same tree. */
|
||||
function nodePath(root: Element, el: Element): number[] | undefined {
|
||||
const path: number[] = []
|
||||
let cur: Element | null = el
|
||||
while (cur && cur !== root) {
|
||||
const parent: Element | null = cur.parentElement
|
||||
if (!parent) return undefined
|
||||
path.unshift(Array.prototype.indexOf.call(parent.children, cur))
|
||||
cur = parent
|
||||
}
|
||||
return cur === root ? path : undefined
|
||||
}
|
||||
|
||||
function resolvePath(root: Element, path: number[]): Element | undefined {
|
||||
let cur: Element | undefined = root
|
||||
for (const i of path) {
|
||||
cur = cur?.children[i] as Element | undefined
|
||||
if (!cur) return undefined
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
/** What may stay on a no-record element: enough to keep occupying the same
|
||||
* space, nothing that can carry content. An allow-list on purpose, since any
|
||||
* deny-list fails open. `class`/`id` are here by name only, their values
|
||||
* filtered by {@link styledTokens}; `style` and `checked` are deliberately not. */
|
||||
const REDACTION_KEEPS_ATTRS = new Set([
|
||||
'class',
|
||||
'colspan',
|
||||
'cols',
|
||||
'disabled',
|
||||
'height',
|
||||
'hidden',
|
||||
'id',
|
||||
'multiple',
|
||||
'open',
|
||||
'readonly',
|
||||
'rows',
|
||||
'rowspan',
|
||||
'size',
|
||||
'type',
|
||||
'width'
|
||||
])
|
||||
|
||||
/** True when the element sits under an app-declared no-record subtree. */
|
||||
export function isRedacted(el: Element): boolean {
|
||||
return !!el.closest(`[${NO_RECORD_ATTR}]`)
|
||||
}
|
||||
|
||||
/** Name a no-record element by its kind alone — its text, label and placeholder
|
||||
* are exactly what the app asked to keep out of the recording. */
|
||||
export function redactedDescription(el: Element): string {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
|
||||
const role = tag === 'input' ? `input[${type}]` : tag
|
||||
return `${role} (redacted)`
|
||||
}
|
||||
|
||||
/** A selector's identifier, escapes included and in any position: a utility
|
||||
* framework writes `md:flex` as `.md\:flex` and `2xl:block` as `.\32 xl\:block`,
|
||||
* so a matcher that stops at a backslash — or demands one before it — reads the
|
||||
* wrong token and costs a redacted placeholder the styling it is kept for. */
|
||||
const IDENT_CHAR = String.raw`(?:[-\w\u00a0-\uffff]|\\[0-9a-fA-F]{1,6}[ \t\r\n\f]?|\\[^\r\n\f0-9a-fA-F])`
|
||||
const CLASS_SELECTOR = new RegExp(String.raw`\.(${IDENT_CHAR}+)`, 'g')
|
||||
const ID_SELECTOR = new RegExp(String.raw`#(${IDENT_CHAR}+)`, 'g')
|
||||
|
||||
/** CSS escapes back to the literal text an attribute holds: `\:` is `:`, and a
|
||||
* hex escape (`\3a `) is its code point. */
|
||||
function unescapeCssIdent(ident: string): string {
|
||||
return ident.replace(/\\([0-9a-fA-F]{1,6})[ \t\r\n\f]?|\\([^])/g, (_, hex, ch) =>
|
||||
hex ? String.fromCodePoint(parseInt(hex, 16)) : ch
|
||||
)
|
||||
}
|
||||
|
||||
/** Tokens the snapshot's surviving stylesheets select on. A redacted element
|
||||
* keeps `class`/`id` for its shape, but the values are author-written and can
|
||||
* name what the marker withholds; a token the CSS selects on is styling
|
||||
* vocabulary, one it never mentions buys nothing. Errs towards dropping. */
|
||||
function styledTokens(clone: Element): { classes: Set<string>; ids: Set<string> } {
|
||||
const classes = new Set<string>()
|
||||
const ids = new Set<string>()
|
||||
for (const style of Array.from(clone.querySelectorAll('style'))) {
|
||||
// A marked stylesheet is scrubbed too, so its selectors are not vocabulary:
|
||||
// letting `<style data-wm-no-record>.salary-92000{}</style>` justify keeping
|
||||
// that class would launder the token the sheet was marked to withhold.
|
||||
if (isRedacted(style)) continue
|
||||
const css = style.textContent ?? ''
|
||||
for (const m of css.matchAll(CLASS_SELECTOR)) classes.add(unescapeCssIdent(m[1]))
|
||||
for (const m of css.matchAll(ID_SELECTOR)) ids.add(unescapeCssIdent(m[1]))
|
||||
}
|
||||
return { classes, ids }
|
||||
}
|
||||
|
||||
/** Strip everything the app marked no-record: the descendants of a marked
|
||||
* element, and every attribute outside {@link REDACTION_KEEPS_ATTRS} — content
|
||||
* hides in `title`, `data-*`, `label`, `srcdoc`, a namespaced `xlink:href`, so
|
||||
* only what the replay needs for layout and control state survives. */
|
||||
function redactMarkedSubtrees(doc: Document, root: Element) {
|
||||
const styled = styledTokens(root)
|
||||
// `querySelectorAll` skips the element it is called on, so a marked root
|
||||
// (`<html data-wm-no-record>`) has to be handled explicitly.
|
||||
const marked: Element[] = [
|
||||
...(root.hasAttribute(NO_RECORD_ATTR) ? [root] : []),
|
||||
...Array.from(root.querySelectorAll(`[${NO_RECORD_ATTR}]`))
|
||||
]
|
||||
for (const n of marked) {
|
||||
n.replaceChildren(doc.createTextNode('•••'))
|
||||
// The marker is kept (an app may style `[data-wm-no-record]`) but emptied: its
|
||||
// value is author-written free text that nothing downstream reads, so leaving
|
||||
// it verbatim would be the one way past the allow-list below.
|
||||
n.setAttribute(NO_RECORD_ATTR, '')
|
||||
for (const attr of Array.from(n.attributes)) {
|
||||
if (attr.name === NO_RECORD_ATTR) continue
|
||||
const name = attr.localName.toLowerCase()
|
||||
if (!REDACTION_KEEPS_ATTRS.has(name)) {
|
||||
n.removeAttributeNode(attr)
|
||||
} else if (name === 'class') {
|
||||
const kept = attr.value.split(/\s+/).filter((c) => c && styled.classes.has(c))
|
||||
if (kept.length) n.setAttribute('class', kept.join(' '))
|
||||
else n.removeAttributeNode(attr)
|
||||
} else if (name === 'id' && !styled.ids.has(attr.value)) {
|
||||
n.removeAttributeNode(attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A `<select>` whose chosen option is marked cannot be serialized honestly:
|
||||
* keeping `selected` says which one was picked, dropping it says the first one
|
||||
* was. Replace its options with a single masked, selected one — the replay then
|
||||
* shows that a choice was made without disclosing it. */
|
||||
/** Encoding runs synchronously on the app's own event path, several times per
|
||||
* interaction, so it is budgeted twice: no single canvas larger than this, and
|
||||
* no more than {@link MAX_SNAPSHOT_CANVAS_PIXELS} across one snapshot. A
|
||||
* dashboard of charts would otherwise stall every pointerdown, and the frame cap
|
||||
* cannot help — it is only checked once the work is already done. */
|
||||
const MAX_CANVAS_PIXELS = 4_000_000
|
||||
const MAX_SNAPSHOT_CANVAS_PIXELS = 8_000_000
|
||||
|
||||
/** A canvas holds its picture in a bitmap `outerHTML` knows nothing about, so a
|
||||
* cloned one replays blank. Paint it into the clone's own background instead of
|
||||
* swapping the element for an `<img>`, which would lose whatever the app's CSS
|
||||
* says about `canvas`. WebGL without `preserveDrawingBuffer` and cross-origin
|
||||
* tainted canvases cannot be read at all; both keep today's blank. */
|
||||
function paintCanvases(doc: Document, clone: Element) {
|
||||
const live = doc.querySelectorAll('canvas')
|
||||
const copies = clone.querySelectorAll('canvas')
|
||||
if (live.length !== copies.length) return
|
||||
let budget = MAX_SNAPSHOT_CANVAS_PIXELS
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
const source = live[i] as HTMLCanvasElement
|
||||
if (!source.width || !source.height) continue
|
||||
const pixels = source.width * source.height
|
||||
if (pixels > MAX_CANVAS_PIXELS || pixels > budget) continue
|
||||
budget -= pixels
|
||||
let url: string
|
||||
try {
|
||||
url = source.toDataURL('image/webp', 0.85)
|
||||
} catch (_) {
|
||||
continue // tainted by cross-origin pixels
|
||||
}
|
||||
if (!url.startsWith('data:image/')) continue
|
||||
// The replay runs without scripting, where a canvas renders its (empty)
|
||||
// fallback content: it is no longer a replaced element, so it has no intrinsic
|
||||
// size and — while it stays `display: inline` — ignores width and height
|
||||
// entirely. Carry over the box it actually rendered at, and a display that
|
||||
// accepts one.
|
||||
const rect = source.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) continue
|
||||
const display = doc.defaultView?.getComputedStyle(source).display
|
||||
const box = !display || display === 'inline' ? 'inline-block' : display
|
||||
const copy = copies[i]
|
||||
const style = copy.getAttribute('style')
|
||||
copy.setAttribute(
|
||||
'style',
|
||||
`${style ? style + ';' : ''}display:${box};box-sizing:border-box` +
|
||||
`;width:${rect.width}px;height:${rect.height}px` +
|
||||
`;background-image:url("${url}");background-size:100% 100%;background-repeat:no-repeat`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function maskSelectsWithRedactedChoice(doc: Document, clone: Element) {
|
||||
const live = doc.querySelectorAll('select')
|
||||
const copies = clone.querySelectorAll('select')
|
||||
if (live.length !== copies.length) return
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
const select = live[i] as HTMLSelectElement
|
||||
if (!Array.from(select.selectedOptions).some((o) => isRedacted(o))) continue
|
||||
const masked = doc.createElement('option')
|
||||
masked.setAttribute('selected', '')
|
||||
masked.textContent = '•••'
|
||||
copies[i].replaceChildren(masked)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy live form state (which lives in properties, not attributes, so
|
||||
* `outerHTML` would lose it) onto the clone. Passwords and anything the app
|
||||
* marked no-record are masked. */
|
||||
function freezeFormState(doc: Document, clone: Element) {
|
||||
const selector = 'input, textarea, select'
|
||||
const live = doc.querySelectorAll(selector)
|
||||
const copies = clone.querySelectorAll(selector)
|
||||
if (live.length !== copies.length) return
|
||||
for (let i = 0; i < live.length; i++) {
|
||||
const el = live[i]
|
||||
const copy = copies[i]
|
||||
if (el.tagName !== copy.tagName) return
|
||||
if (isTag(el, 'INPUT')) {
|
||||
const input = el as HTMLInputElement
|
||||
const copyInput = copy as HTMLInputElement
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
if (input.checked) copyInput.setAttribute('checked', '')
|
||||
else copyInput.removeAttribute('checked')
|
||||
} else if (input.type === 'password' || isRedacted(input)) {
|
||||
copyInput.setAttribute('value', maskValue(input.value))
|
||||
} else if (input.type !== 'file') {
|
||||
copyInput.setAttribute('value', input.value)
|
||||
}
|
||||
} else if (isTag(el, 'TEXTAREA')) {
|
||||
copy.textContent = (el as HTMLTextAreaElement).value
|
||||
} else if (isTag(el, 'SELECT')) {
|
||||
const select = el as HTMLSelectElement
|
||||
const copySelect = copy as HTMLSelectElement
|
||||
for (let j = 0; j < select.options.length; j++) {
|
||||
const option = copySelect.options[j]
|
||||
if (!option) continue
|
||||
if (select.options[j].selected) option.setAttribute('selected', '')
|
||||
else option.removeAttribute('selected')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Rule text, with `@import` replaced by the rules it pulls in: the replay CSP
|
||||
* refuses the fetch, so an unexpanded import is simply lost styling. An import
|
||||
* that carries a cascade layer replays as unlayered rules at the import's
|
||||
* position — closer to the live rendering than dropping it, but not identical. */
|
||||
function expandRules(rules: CSSRuleList): string {
|
||||
return Array.from(rules)
|
||||
.map((rule) => {
|
||||
const imported = (rule as CSSImportRule).styleSheet
|
||||
if (!imported) return rule.cssText
|
||||
try {
|
||||
const inner = expandRules(imported.cssRules)
|
||||
const media = imported.media?.mediaText
|
||||
return media ? `@media ${media} {\n${inner}\n}` : inner
|
||||
} catch (_) {
|
||||
return rule.cssText
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Re-read every time rather than cached against a sampled probe: an app that
|
||||
* restyles at runtime (a theme toggle rewriting a rule in place) leaves any
|
||||
* sample of the sheet unchanged, and the snapshot would then replay styling the
|
||||
* user never saw. A probe cheap enough to be worth caching cannot be exact,
|
||||
* because the cost being avoided *is* reading the rules. Measured at ~18ms for
|
||||
* 11k rules across 177 sheets, which is small next to the rest of a snapshot. */
|
||||
function sheetCss(sheet: CSSStyleSheet, rules: CSSRuleList): string {
|
||||
let css = expandRules(rules)
|
||||
if (sheet.href) css = rewriteCssUrls(css, sheet.href)
|
||||
// `cssRules` drops the sheet-level media condition the `<link media>` carried,
|
||||
// so an unwrapped inline copy would apply print-only CSS to every replay.
|
||||
const media = sheet.media?.mediaText
|
||||
if (media) css = `@media ${media} {\n${css}\n}`
|
||||
return css
|
||||
}
|
||||
|
||||
/** Inline what the browser has actually parsed: rules of linked stylesheets (so
|
||||
* the snapshot renders without the API being reachable) and of CSS-in-JS sheets
|
||||
* built with `insertRule` (whose `<style>` node clones out empty). Sheets we
|
||||
* can't read (cross-origin) keep their `<link>`, and ones with no owner node
|
||||
* (`adoptedStyleSheets`) are out of reach entirely — as is anything inside a
|
||||
* shadow root, which `outerHTML` does not serialize. */
|
||||
function inlineStyleSheets(doc: Document, root: Element, clone: Element) {
|
||||
for (const sheet of Array.from(doc.styleSheets)) {
|
||||
const owner = sheet.ownerNode
|
||||
if (!isElementNode(owner)) continue
|
||||
if (sheet.disabled) {
|
||||
// `disabled` is a property, not an attribute: cloned through, the sheet
|
||||
// would come back to life on replay. Neutralize it in place — removing the
|
||||
// node would shift the sibling indices that every later path resolution in
|
||||
// this function, and the target stamp after it, resolve against.
|
||||
const path = nodePath(root, owner)
|
||||
const target = path ? resolvePath(clone, path) : undefined
|
||||
if (target) {
|
||||
target.setAttribute('media', 'not all')
|
||||
if (target.tagName === 'STYLE') target.textContent = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Inlining replaces the node, which would leave the marker behind and carry
|
||||
// the sheet's text into the snapshot. Leave marked sheets for redaction.
|
||||
if (isRedacted(owner)) continue
|
||||
let rules: CSSRuleList
|
||||
try {
|
||||
const cssRules = (sheet as CSSStyleSheet).cssRules
|
||||
if (!cssRules) continue
|
||||
rules = cssRules
|
||||
} catch (_) {
|
||||
continue
|
||||
}
|
||||
const path = nodePath(root, owner)
|
||||
if (!path) continue
|
||||
const target = resolvePath(clone, path)
|
||||
if (!target) continue
|
||||
const css = sheetCss(sheet as CSSStyleSheet, rules)
|
||||
if (owner.tagName === 'LINK') {
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = css
|
||||
target.replaceWith(style)
|
||||
} else if (owner.tagName === 'STYLE') {
|
||||
// The parsed rules are the truth: `insertRule`/`deleteRule` and edits to a
|
||||
// rule's style never touch the element's source text, so copying the text
|
||||
// through would replay the sheet as it was authored, not as it renders.
|
||||
target.textContent = css
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SnapshotOptions = {
|
||||
/** Element to stamp with {@link REC_TARGET_ATTR} (the step's interaction target). */
|
||||
target?: Element | null
|
||||
/** Base URL for the snapshot's relative resources (the recording origin). */
|
||||
baseHref?: string
|
||||
}
|
||||
|
||||
/** Serialize a live document into standalone HTML: current form state frozen in,
|
||||
* stylesheets inlined, scripts and inline handlers dropped (the player renders
|
||||
* snapshots with scripting disabled). */
|
||||
export function serializeDocument(doc: Document, opts: SnapshotOptions = {}): string {
|
||||
const root = doc.documentElement
|
||||
const clone = root.cloneNode(true) as Element
|
||||
// These read the live document and write to the matching node in the clone,
|
||||
// pairing by position — so they must all run before anything is removed from
|
||||
// it. Freezing, inlining, painting and redacting only mutate nodes in place.
|
||||
freezeFormState(doc, clone)
|
||||
inlineStyleSheets(doc, root, clone)
|
||||
paintCanvases(doc, clone)
|
||||
maskSelectsWithRedactedChoice(doc, clone)
|
||||
redactMarkedSubtrees(doc, clone)
|
||||
// Stamp the target BEFORE anything is removed from the clone: the live tree is
|
||||
// what `nodePath` indexes against, so a single removed node (a data `<script>`
|
||||
// preceding the target, say) would shift every later sibling and stamp the
|
||||
// wrong element.
|
||||
if (opts.target) {
|
||||
const path = nodePath(root, opts.target)
|
||||
const target = path ? resolvePath(clone, path) : undefined
|
||||
target?.setAttribute(REC_TARGET_ATTR, '')
|
||||
}
|
||||
// Templates render nothing, and their content fragment is invisible to
|
||||
// `querySelectorAll` while `outerHTML` still serializes it — so the passes
|
||||
// below would miss scripts and handlers hiding inside one. `<noscript>` is the
|
||||
// same trap from the other side: with scripting on its markup is one text node,
|
||||
// so redaction cannot see into it, yet it would render on a script-less replay.
|
||||
clone.querySelectorAll('template, noscript').forEach((n) => n.remove())
|
||||
clone.querySelectorAll('script').forEach((n) => n.remove())
|
||||
clone.querySelectorAll('meta[http-equiv="refresh" i]').forEach((n) => n.remove())
|
||||
clone.querySelectorAll('*').forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
|
||||
}
|
||||
})
|
||||
// A snapshot clones out scrolled back to the top, which can leave the
|
||||
// interaction target off-screen on replay. Shifting the root reproduces the
|
||||
// scrolled view (and leaves `position: fixed` chrome where it belongs). Scroll
|
||||
// inside nested overflow containers has no static-CSS equivalent and is lost.
|
||||
const view = doc.defaultView
|
||||
const scrollY = Math.round(view?.scrollY ?? doc.documentElement.scrollTop ?? 0)
|
||||
const scrollX = Math.round(view?.scrollX ?? doc.documentElement.scrollLeft ?? 0)
|
||||
if (scrollY > 0 || scrollX > 0) {
|
||||
const scrolled = doc.createElement('style')
|
||||
scrolled.textContent = `html { margin-top: -${scrollY}px !important; margin-left: -${scrollX}px !important; }`
|
||||
clone.querySelector('head')?.appendChild(scrolled)
|
||||
}
|
||||
const head = clone.querySelector('head')
|
||||
if (opts.baseHref && head && !head.querySelector('base')) {
|
||||
const base = doc.createElement('base')
|
||||
base.setAttribute('href', opts.baseHref)
|
||||
head.prepend(base)
|
||||
}
|
||||
return `<!DOCTYPE html>${clone.outerHTML}`
|
||||
}
|
||||
|
||||
const NAVIGATION_ATTRS = new Set(['href', 'action', 'formaction', 'ping', 'target', 'download'])
|
||||
|
||||
/** Locked-down policy for a replayed snapshot. The player's empty sandbox stops
|
||||
* scripting, but not subresource loads: without this, markup inside a recording
|
||||
* fetched from an arbitrary `?src=` URL could still beacon the viewer or issue
|
||||
* same-site GETs against their Windmill session. The cost is that images and
|
||||
* fonts the recorder could not inline (remote URLs) do not render on replay.
|
||||
* Injected at the very top of <head> so it applies before anything is fetched. */
|
||||
const REPLAY_CSP = `default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:`
|
||||
|
||||
/** A replay is a picture of a past session, not a working app, so clicks are
|
||||
* turned off at the root. `pointer-events` inherits rather than cascades, so an
|
||||
* element that sets its own value (Tailwind's `pointer-events-auto`, overlay
|
||||
* CSS) still takes clicks — this is a broad default, NOT a guarantee, and the
|
||||
* attribute stripping below remains the actual defense. It also costs
|
||||
* mouse text-selection inside the replayed frame. */
|
||||
const INERT_CSS = `html { pointer-events: none !important; }`
|
||||
|
||||
const HIGHLIGHT_CSS = `[${REC_TARGET_ATTR}] {
|
||||
outline: 3px solid #ef4444 !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.25) !important;
|
||||
}`
|
||||
|
||||
/** Prepare a recorded frame for replay: policy first in `<head>`, target
|
||||
* highlight, nothing executable. Parsed rather than string-spliced because a
|
||||
* `?src=` recording can defeat a `<head>` regex (`<body><img src=/probe><header>`)
|
||||
* and land the policy after the request it must prevent. Not in the player: a
|
||||
* literal `<style>` there is parsed as the component's own style block. */
|
||||
export function withHighlightStyles(frame: string): string {
|
||||
let doc: Document
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(frame, 'text/html')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
const head = doc.head ?? doc.documentElement.insertBefore(doc.createElement('head'), doc.body)
|
||||
const csp = doc.createElement('meta')
|
||||
csp.setAttribute('http-equiv', 'Content-Security-Policy')
|
||||
csp.setAttribute('content', REPLAY_CSP)
|
||||
head.prepend(csp)
|
||||
// The recorder strips these at capture time; a hand-made or hostile recording
|
||||
// has not been through it.
|
||||
doc.querySelectorAll('script, meta[http-equiv="refresh" i]').forEach((n) => n.remove())
|
||||
doc.querySelectorAll('*').forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
|
||||
}
|
||||
})
|
||||
// A replay is a static picture, so nothing in it may navigate: the sandbox
|
||||
// stops a *top-level* navigation and the CSP governs fetches, but neither stops
|
||||
// a link from navigating the snapshot frame itself — which is a request.
|
||||
// Matched on the attribute's local name, so an SVG `xlink:href` goes too.
|
||||
doc.querySelectorAll('a, area, form, button, input').forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (NAVIGATION_ATTRS.has(attr.localName.toLowerCase())) el.removeAttributeNode(attr)
|
||||
}
|
||||
})
|
||||
// SVG animation is declarative — it runs without scripts — and `<set
|
||||
// attributeName="href">` would put back the link just stripped.
|
||||
doc.querySelectorAll('set, animate, animateTransform, animateMotion').forEach((n) => n.remove())
|
||||
// `querySelectorAll` cannot see into a template's content fragment, so markup
|
||||
// hidden there escapes every pass above and comes alive as a shadow root when
|
||||
// the frame is parsed. A snapshot has no use for templates: drop them.
|
||||
doc.querySelectorAll('template').forEach((n) => n.remove())
|
||||
// Resource hints exist only to fetch; the CSP already refuses them, so this is
|
||||
// about not asking rather than not getting.
|
||||
doc
|
||||
.querySelectorAll(
|
||||
'link[rel~="preload" i], link[rel~="prefetch" i], link[rel~="preconnect" i], link[rel~="dns-prefetch" i]'
|
||||
)
|
||||
.forEach((n) => n.remove())
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `${INERT_CSS}\n${HIGHLIGHT_CSS}`
|
||||
head.appendChild(style)
|
||||
return `<!DOCTYPE html>${doc.documentElement.outerHTML}`
|
||||
}
|
||||
|
||||
/** Text of an element with any no-record subtree left out: the element itself
|
||||
* may be recordable while something inside it is not. */
|
||||
export function textWithoutRedacted(el: Element | null | undefined): string {
|
||||
if (!el || isRedacted(el)) return ''
|
||||
let source: Element = el
|
||||
if (el.querySelector(`[${NO_RECORD_ATTR}]`)) {
|
||||
source = el.cloneNode(true) as Element
|
||||
source.querySelectorAll(`[${NO_RECORD_ATTR}]`).forEach((n) => n.remove())
|
||||
}
|
||||
return (source.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function textOf(el: Element | null | undefined, max = 40): string {
|
||||
const text = textWithoutRedacted(el)
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text
|
||||
}
|
||||
|
||||
/** Short human name of an element, preferring what a user would call it
|
||||
* (its label / accessible name) over its markup. */
|
||||
export function describeElement(el: Element): string {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const type = (el.getAttribute('type') ?? 'text').toLowerCase()
|
||||
const role = tag === 'input' ? `input[${type}]` : tag
|
||||
// An element can be recordable while its associated label is not (the label is
|
||||
// where a form usually puts the sensitive wording).
|
||||
const label = (el as HTMLInputElement).labels?.[0]
|
||||
const name =
|
||||
el.getAttribute('aria-label') ||
|
||||
(label && !isRedacted(label) ? textOf(label) : '') ||
|
||||
// A button-shaped <input> has no text content: its `value` is its caption.
|
||||
(tag === 'input' && ['button', 'submit', 'reset'].includes(type)
|
||||
? el.getAttribute('value')
|
||||
: '') ||
|
||||
el.getAttribute('placeholder') ||
|
||||
el.getAttribute('title') ||
|
||||
textOf(el) ||
|
||||
el.getAttribute('name') ||
|
||||
el.getAttribute('id') ||
|
||||
''
|
||||
return name ? `${role} "${name}"` : role
|
||||
}
|
||||
|
||||
/** Best-effort CSS selector for the element, recorded for reference (the player
|
||||
* highlights via {@link REC_TARGET_ATTR}, not this). */
|
||||
export function cssSelectorFor(el: Element): string {
|
||||
const parts: string[] = []
|
||||
let cur: Element | null = el
|
||||
let depth = 0
|
||||
while (cur && depth < 5) {
|
||||
const tag = cur.tagName.toLowerCase()
|
||||
if (cur.id) {
|
||||
parts.unshift(`#${cur.id}`)
|
||||
break
|
||||
}
|
||||
const cls =
|
||||
typeof cur.className === 'string'
|
||||
? cur.className.trim().split(/\s+/).filter(Boolean)[0]
|
||||
: undefined
|
||||
const parent: Element | null = cur.parentElement
|
||||
let part = cls ? `${tag}.${cls}` : tag
|
||||
if (parent) {
|
||||
const sameTag = Array.from(parent.children).filter((c) => c.tagName === cur!.tagName)
|
||||
if (sameTag.length > 1) part += `:nth-of-type(${sameTag.indexOf(cur) + 1})`
|
||||
}
|
||||
parts.unshift(part)
|
||||
cur = parent
|
||||
depth++
|
||||
}
|
||||
return parts.join(' > ')
|
||||
}
|
||||
|
||||
/** One-line description of a step, shown in the player's step list. */
|
||||
export function stepLabel(kind: RawAppInteractionKind, target: string, value?: string): string {
|
||||
switch (kind) {
|
||||
case 'click':
|
||||
return `Clicked ${target}`
|
||||
case 'fill':
|
||||
return `Filled ${target} with "${value ?? ''}"`
|
||||
case 'select':
|
||||
return `Selected "${value ?? ''}" in ${target}`
|
||||
case 'toggle':
|
||||
// A redacted control reports no state, and "Unchecked" would then be a
|
||||
// claim rather than an omission: say only that it was toggled.
|
||||
if (!value) return `Toggled ${target}`
|
||||
return `${value === 'checked' ? 'Checked' : 'Unchecked'} ${target}`
|
||||
case 'submit':
|
||||
return `Submitted ${target}`
|
||||
case 'key':
|
||||
return `Pressed ${value ?? 'key'} in ${target}`
|
||||
case 'navigate':
|
||||
return value ? `Navigated to ${value}` : 'Reloaded the app'
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AssetKind, Job, OpenFlow } from '$lib/gen'
|
||||
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
|
||||
import type { RawAppInteractionKind } from './rawAppSnapshot'
|
||||
|
||||
export type RecordedEvent = {
|
||||
t: number
|
||||
@@ -97,6 +98,39 @@ export type PipelineRecordedCode = {
|
||||
language: string
|
||||
}
|
||||
|
||||
/** One user interaction in a raw-app session recording. `before`/`after` index
|
||||
* into {@link RawAppRecording.frames}: the DOM the user acted on, and the DOM
|
||||
* once the app settled. Either may be absent when the snapshot budget ran out. */
|
||||
export type RawAppStep = {
|
||||
t: number
|
||||
kind: RawAppInteractionKind
|
||||
/** One-line description shown in the player, e.g. `Clicked button "Save"`. */
|
||||
label: string
|
||||
/** The element as a user would name it, e.g. `button "Save"`. */
|
||||
target: string
|
||||
selector?: string
|
||||
value?: string
|
||||
before?: number
|
||||
after?: number
|
||||
}
|
||||
|
||||
export type RawAppRecording = {
|
||||
version: 1
|
||||
type: 'app'
|
||||
recorded_at: string
|
||||
app_path: string
|
||||
workspace?: string
|
||||
total_duration_ms: number
|
||||
/** Size of the app viewport at record time, replayed at the same scale. */
|
||||
viewport: { width: number; height: number }
|
||||
/** Deduplicated, self-contained DOM snapshots referenced by the steps. */
|
||||
frames: string[]
|
||||
steps: RawAppStep[]
|
||||
/** Set when a limit cut the recording short: the snapshot budget (later steps
|
||||
* ship without frames) or the step cap (later interactions are absent). */
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
/** Minimal interface that both flow and script recording stores implement */
|
||||
export interface ActiveRecording {
|
||||
recordInitialJob(jobId: string, job: Job): void
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import WorkspaceDeployLayout from '$lib/components/WorkspaceDeployLayout.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
@@ -11,7 +10,7 @@
|
||||
import {
|
||||
useDeployToHubSession,
|
||||
canRecord,
|
||||
canShareAsIframe,
|
||||
canRecordSession,
|
||||
sanitizeSlug,
|
||||
isValidSlug,
|
||||
type DeployItem
|
||||
@@ -20,17 +19,16 @@
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import MigrationSqlEditor from './MigrationSqlEditor.svelte'
|
||||
import PipelineRecordingReplay from '$lib/components/recording/PipelineRecordingReplay.svelte'
|
||||
import RawAppRecordSession from './RawAppRecordSession.svelte'
|
||||
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
Code2,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
@@ -57,11 +55,12 @@
|
||||
})
|
||||
|
||||
let recordDrawer = $state<Drawer | undefined>()
|
||||
let appRecordDrawer = $state<Drawer | undefined>()
|
||||
let appRecordTarget = $state<DeployItem | undefined>(undefined)
|
||||
let pipelinePreviewDrawer = $state<Drawer | undefined>()
|
||||
// Inline pipeline graph above the item list, collapsed by default so the
|
||||
// selection list stays the first thing in view.
|
||||
let pipelineGraphOpen = $state(false)
|
||||
let publishDrawer = $state<Drawer | undefined>()
|
||||
let resourceDrawer = $state<Drawer | undefined>()
|
||||
let triggerDrawer = $state<Drawer | undefined>()
|
||||
let bundleDrawer = $state<Drawer | undefined>()
|
||||
@@ -81,6 +80,11 @@
|
||||
async function confirmBundle() {
|
||||
await deployHub.session?.publishBundle(() => bundleDrawer?.closeDrawer())
|
||||
}
|
||||
function openAppRecord(it: DeployItem) {
|
||||
appRecordTarget = it
|
||||
appRecordDrawer?.openDrawer()
|
||||
}
|
||||
|
||||
function openRecord(it: DeployItem) {
|
||||
recordDrawer?.openDrawer()
|
||||
void deployHub.session?.openRecord(it)
|
||||
@@ -91,15 +95,6 @@
|
||||
async function savePipelineRecording() {
|
||||
await deployHub.session?.savePipelineRecording()
|
||||
}
|
||||
function openPublish(it: DeployItem) {
|
||||
const s = deployHub.session
|
||||
if (!s) return
|
||||
s.publishTarget = it
|
||||
publishDrawer?.openDrawer()
|
||||
}
|
||||
async function confirmPublish() {
|
||||
if (await deployHub.session?.confirmPublish()) publishDrawer?.closeDrawer()
|
||||
}
|
||||
// Client-side mirror of the Hub's logo constraints (it re-validates server-side).
|
||||
const MAX_LOGO_BYTES = 512 * 1024
|
||||
let logoDragOver = $state(false)
|
||||
@@ -150,15 +145,6 @@
|
||||
await handleLogoFile(e.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
async function copyIframeSnippet(url: string) {
|
||||
const snippet = `<iframe src="${url}" width="100%" height="600" frameborder="0"></iframe>`
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet)
|
||||
sendUserToast('Iframe snippet copied to clipboard')
|
||||
} catch {
|
||||
sendUserToast('Failed to copy snippet', true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if deployHub.session}
|
||||
@@ -201,9 +187,9 @@
|
||||
class={stepNum === 2 ? 'text-primary' : stepNum > 2 ? 'opacity-60' : 'opacity-40'}
|
||||
>
|
||||
<span class="font-mono text-emphasis">{stepNum > 2 ? '✓' : '2.'}</span>
|
||||
<span class="font-semibold text-primary">Generate iframes & recordings</span> — share
|
||||
public apps as iframes, capture one execution per script/flow, and record the whole data-pipeline
|
||||
cascade as one interactive replay.
|
||||
<span class="font-semibold text-primary">Record demos</span> — capture one execution
|
||||
per script/flow, a session per raw app, and the whole data-pipeline cascade as one interactive
|
||||
replay.
|
||||
</li>
|
||||
<li
|
||||
class={stepNum === 3 ? 'text-primary' : stepNum > 3 ? 'opacity-60' : 'opacity-40'}
|
||||
@@ -219,9 +205,7 @@
|
||||
Step 1: Bundle your project
|
||||
</span>
|
||||
{:else if s.phase === 'draft'}
|
||||
<span class="text-sm font-semibold text-primary">
|
||||
Step 2: Generate iframes & recordings
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-primary"> Step 2: Record demos </span>
|
||||
{:else if s.phase === 'under_review'}
|
||||
<span class="text-sm font-semibold text-primary">Step 3: Awaiting review</span>
|
||||
{:else}
|
||||
@@ -366,9 +350,9 @@
|
||||
{#if s.phase === 'draft'}
|
||||
<div class="flex flex-col gap-1 pb-3">
|
||||
<span class="text-xs text-secondary">
|
||||
A recording captures one real run of a script or flow — inputs, logs, step outputs
|
||||
and result — replayable on the Hub so visitors see it work before forking. Public
|
||||
apps can also be shared as live iframes. Optional, but recommended.
|
||||
A recording captures one real run of a script or flow (inputs, logs, step outputs
|
||||
and result), or one session of someone using a raw app, replayable on the Hub so
|
||||
visitors see it work before forking. Optional, but recommended.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -631,52 +615,28 @@
|
||||
<Badge color="yellow" size="xs">No recording</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if s.phase !== 'predeploy' && canShareAsIframe(it)}
|
||||
{#if it.published}
|
||||
{#if s.phase === 'draft' && canRecordSession(it)}
|
||||
{#if it.rec === 'recorded'}
|
||||
<Badge color="green" size="xs">
|
||||
<Globe size={10} class="mr-0.5" />Public
|
||||
<Check size={10} class="mr-0.5" />Recorded
|
||||
</Badge>
|
||||
{#if it.publicUrl}
|
||||
<a
|
||||
href={it.publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} /> Open
|
||||
</a>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Copy }}
|
||||
onclick={() => copyIframeSnippet(it.publicUrl!)}
|
||||
>
|
||||
Copy iframe
|
||||
</Button>
|
||||
{:else if s.phase !== 'under_review'}
|
||||
<!-- Public but its URL didn't resolve: offer a retry, keep Unpublish. -->
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
onclick={() => openPublish(it)}
|
||||
>
|
||||
Retry link
|
||||
</Button>
|
||||
{/if}
|
||||
{#if s.phase !== 'under_review'}
|
||||
<Button size="xs" variant="subtle" onclick={() => s.unpublishApp(it)}
|
||||
>Unpublish</Button
|
||||
>
|
||||
{/if}
|
||||
{:else if s.phase !== 'under_review'}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Globe }}
|
||||
onclick={() => openPublish(it)}
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
onclick={() => openAppRecord(it)}
|
||||
>
|
||||
Share as iframe
|
||||
Re-record
|
||||
</Button>
|
||||
{:else}
|
||||
<Badge color="yellow" size="xs">No recording</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Play }}
|
||||
onclick={() => openAppRecord(it)}
|
||||
>
|
||||
Record demo
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -822,6 +782,37 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- Full screen on purpose: the demo is recorded at the size it will replay,
|
||||
and a recording driven in a narrow drawer replays as a narrow app. -->
|
||||
<Drawer bind:this={appRecordDrawer} size="100vw">
|
||||
<DrawerContent
|
||||
title={appRecordTarget ? `Record demo — ${appRecordTarget.path}` : 'Record demo'}
|
||||
on:close={() => appRecordDrawer?.closeDrawer()}
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Use the app the way a visitor would. Each interaction becomes a step, replayable on the
|
||||
Hub page so people see what it does before forking it.
|
||||
</span>
|
||||
{#if appRecordTarget}
|
||||
{#key appRecordTarget.key}
|
||||
<div class="h-full min-h-[600px] pt-2">
|
||||
<RawAppRecordSession
|
||||
workspace={s.workspace}
|
||||
path={appRecordTarget.path}
|
||||
onsave={s.hubItemIds[appRecordTarget.key]
|
||||
? async (recording) => {
|
||||
const ok = await s.saveAppRecording(appRecordTarget!, recording)
|
||||
if (ok) appRecordDrawer?.closeDrawer()
|
||||
return ok
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={pipelinePreviewDrawer} size="1100px">
|
||||
<DrawerContent
|
||||
title="Pipeline recording preview"
|
||||
@@ -835,65 +826,6 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={publishDrawer} size="600px">
|
||||
<DrawerContent
|
||||
title={s.publishTarget ? `Share as iframe — ${s.publishTarget.path}` : 'Share as iframe'}
|
||||
on:close={() => publishDrawer?.closeDrawer()}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-secondary">
|
||||
Expose <span class="font-mono text-emphasis">{s.publishTarget?.path}</span> at a public URL
|
||||
so it can be embedded as an iframe (e.g. on the Hub, a docs page, or your own site). Anyone
|
||||
with the URL will be able to interact with it.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<TriangleAlert
|
||||
size={14}
|
||||
class={s.workspaceRateLimit
|
||||
? 'text-secondary'
|
||||
: 'text-orange-600 dark:text-orange-400'}
|
||||
/>
|
||||
<span class="text-sm font-semibold">Rate limit (workspace-wide)</span>
|
||||
<Tooltip>
|
||||
Caps public app executions per minute per server. Applies to all public apps in this
|
||||
workspace.
|
||||
</Tooltip>
|
||||
</div>
|
||||
{#if s.workspaceRateLimit && s.workspaceRateLimit > 0}
|
||||
<span class="text-xs text-secondary">
|
||||
Currently <span class="font-mono text-emphasis">{s.workspaceRateLimit}</span> executions
|
||||
/ minute / server.
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-orange-700 dark:text-orange-300">
|
||||
No rate limit configured — anyone with the URL can hit this app at any rate.
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href="{base}/workspace_settings?tab=default_app"
|
||||
class="text-[11px] text-blue-600 underline"
|
||||
onclick={() => publishDrawer?.closeDrawer()}
|
||||
>
|
||||
Edit in Workspace settings → Apps
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="default" onclick={() => publishDrawer?.closeDrawer()}>Cancel</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={s.publishing}
|
||||
startIcon={{ icon: Globe }}
|
||||
onclick={confirmPublish}
|
||||
>
|
||||
Generate iframe
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={resourceDrawer} size="640px">
|
||||
<DrawerContent title="Resource dependencies" on:close={() => resourceDrawer?.closeDrawer()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Records a raw app's session for its Hub page: the app runs full-screen, the
|
||||
* user drives it, and what they did becomes a step-by-step recording visitors
|
||||
* can replay before forking the project. A raw app cannot demo itself with a
|
||||
* job log the way a script or flow does — this is its equivalent.
|
||||
*/
|
||||
import { Button } from '$lib/components/common'
|
||||
import RawAppPreview from '$lib/components/raw_apps/RawAppPreview.svelte'
|
||||
import RawAppRecordingReplay from '$lib/components/recording/RawAppRecordingReplay.svelte'
|
||||
import { createRawAppRecording } from '$lib/components/recording/rawAppRecording.svelte'
|
||||
import type { RawAppRecording } from '$lib/components/recording/types'
|
||||
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
|
||||
import { AppService } from '$lib/gen'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Check, Circle, Download, Loader2, Square } from 'lucide-svelte'
|
||||
import { onDestroy, setContext } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
workspace: string
|
||||
path: string
|
||||
/** Saves the finished recording against the Hub item. Absent while the
|
||||
* project has no Hub draft yet, which leaves Download as the only action. */
|
||||
onsave?: (recording: RawAppRecording) => Promise<boolean>
|
||||
}
|
||||
|
||||
let { workspace, path, onsave }: Props = $props()
|
||||
|
||||
const recorder = createRawAppRecording()
|
||||
|
||||
let app = $state<any>(undefined)
|
||||
// The publisher's isolation opt-in decides this, exactly as in the viewer:
|
||||
// forcing the unsandboxed path here would run a bundle its own author marked
|
||||
// untrusted same-origin with the session of whoever is recording it. An
|
||||
// isolated app simply cannot be recorded — its DOM is unreachable by design.
|
||||
let sandboxed = $derived(app?.policy?.sandbox === true)
|
||||
setContext('IS_APP_UNSANDBOXED', {
|
||||
get value() {
|
||||
return app !== undefined && !sandboxed
|
||||
}
|
||||
})
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let iframe = $state<HTMLIFrameElement | undefined>(undefined)
|
||||
let recording = $state<RawAppRecording | undefined>(undefined)
|
||||
let saving = $state(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const loaded: any = await AppService.getAppByPath({ workspace, path })
|
||||
if (!loaded?.bundle_secret) {
|
||||
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
|
||||
workspace,
|
||||
path
|
||||
})
|
||||
}
|
||||
app = loaded
|
||||
} catch (e: any) {
|
||||
loadError = e?.body ?? e?.message ?? String(e)
|
||||
}
|
||||
}
|
||||
load()
|
||||
|
||||
// Showing the replay unmounts the preview, so "Record again" has to wait for the
|
||||
// fresh one: `iframe` still points at the removed document until it mounts and
|
||||
// hands its own back.
|
||||
let startWhenReady = $state(false)
|
||||
|
||||
function start() {
|
||||
if (recording) {
|
||||
recording = undefined
|
||||
startWhenReady = true
|
||||
return
|
||||
}
|
||||
beginRecording()
|
||||
}
|
||||
|
||||
function beginRecording() {
|
||||
if (!iframe) return
|
||||
if (!recorder.start(iframe, { appPath: path, workspace })) {
|
||||
sendUserToast('Cannot record this app: its bundle runs sandbox-isolated', true)
|
||||
return
|
||||
}
|
||||
sendUserToast(
|
||||
'Recording — walk through the app as a visitor would. Passwords are masked; mark ' +
|
||||
'sensitive elements with data-wm-no-record to leave them out.'
|
||||
)
|
||||
}
|
||||
|
||||
function onIframe(el: HTMLIFrameElement | undefined) {
|
||||
iframe = el
|
||||
if (el && startWhenReady) {
|
||||
startWhenReady = false
|
||||
beginRecording()
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
recording = await recorder.stop()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!recording || !onsave) return
|
||||
saving = true
|
||||
try {
|
||||
if (await onsave(recording)) recording = undefined
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
// Not awaited: the drawer is going away, and the recorder resolves on its own
|
||||
// once the document it was reading is gone.
|
||||
if (recorder.active) recorder.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-0 gap-2">
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<span class="text-sm font-semibold text-primary">{path}</span>
|
||||
{#if sandboxed}
|
||||
<span class="text-xs text-secondary">Sandbox-isolated: not recordable</span>
|
||||
{:else if recorder.active || recorder.stopping}
|
||||
<span class="flex items-center gap-1 text-xs text-primary">
|
||||
<Circle size={10} class="text-red-500 animate-pulse" fill="currentColor" />
|
||||
{recorder.stepCount} step{recorder.stepCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
loading={recorder.stopping}
|
||||
startIcon={{ icon: Square }}
|
||||
onclick={stop}
|
||||
>
|
||||
{recorder.stopping ? 'Waiting for the job…' : 'Stop recording'}
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- While the replay is showing there is no preview and so no iframe; the
|
||||
re-record path remounts one and starts against it. -->
|
||||
<Button
|
||||
size="xs"
|
||||
variant="accent"
|
||||
disabled={!iframe && !recording}
|
||||
startIcon={{ icon: Circle }}
|
||||
onclick={start}
|
||||
>
|
||||
{recording ? 'Record again' : 'Start recording'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if recording}
|
||||
<span class="text-xs text-secondary">
|
||||
{recording.steps.length} step{recording.steps.length === 1 ? '' : 's'} captured
|
||||
</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: Download }}
|
||||
onclick={() => recorder.download(recording!)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
{#if onsave}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="accent"
|
||||
loading={saving}
|
||||
startIcon={{ icon: Check }}
|
||||
onclick={save}
|
||||
>
|
||||
Save as recording
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div class="text-sm text-red-600 dark:text-red-400">Could not load the app: {loadError}</div>
|
||||
{:else if sandboxed}
|
||||
<div class="text-sm text-secondary max-w-2xl">
|
||||
This app is opted into sandbox isolation, so it runs in an opaque-origin frame that nothing on
|
||||
this page can read — including the recorder. Turn isolation off in the app's settings to
|
||||
record a demo of it.
|
||||
</div>
|
||||
{:else if !app}
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Loader2 size={14} class="animate-spin" /> Loading the app…
|
||||
</div>
|
||||
{:else if recording}
|
||||
<!-- What was captured, in the same player the Hub page will use. -->
|
||||
<div class="flex-1 min-h-0">
|
||||
<RawAppRecordingReplay {recording} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 min-h-0 border rounded-md overflow-hidden">
|
||||
<RawAppPreview
|
||||
{workspace}
|
||||
user={$userStore}
|
||||
secret={app.bundle_secret}
|
||||
{path}
|
||||
runnables={(app.value?.runnables ?? {}) as Record<string, Runnable>}
|
||||
oniframe={onIframe}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canRecordSession, mergeAppTableOrigin, type DeployItem } from './deployToHubItems'
|
||||
|
||||
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
|
||||
return { rec: 'none', ...over }
|
||||
}
|
||||
|
||||
describe('canRecordSession', () => {
|
||||
it('offers the recorder only for app-table raw apps', () => {
|
||||
expect(
|
||||
canRecordSession(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
|
||||
).toBe(true)
|
||||
// Legacy entries live in the `raw_app` table, which the record surface's
|
||||
// AppService loader cannot see: offering the action opens a dead drawer.
|
||||
expect(canRecordSession(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
|
||||
expect(canRecordSession(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAppTableOrigin', () => {
|
||||
it('restores the origin so a reopened draft stays recordable', () => {
|
||||
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
|
||||
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
|
||||
expect(canRecordSession(mergeAppTableOrigin(drafts, workspace)[0])).toBe(true)
|
||||
})
|
||||
it('keeps the reference when nothing changes, and ignores unmatched drafts', () => {
|
||||
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
|
||||
expect(mergeAppTableOrigin(drafts, drafts)).toBe(drafts)
|
||||
const orphan = [item({ key: 'raw_app:f/gone', path: 'f/gone', kind: 'raw_app' })]
|
||||
expect(
|
||||
mergeAppTableOrigin(orphan, [
|
||||
item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })
|
||||
])
|
||||
).toBe(orphan)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The publish flow's item shape and the pure predicates over it. Kept apart from
|
||||
* the session store so they can be exercised without dragging in the API client
|
||||
* and the editor bundle the store pulls behind it.
|
||||
*/
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
|
||||
export type RecStatus = 'none' | 'recorded'
|
||||
|
||||
export interface DeployItem {
|
||||
key: string
|
||||
path: string
|
||||
kind: Kind
|
||||
summary?: string
|
||||
rec: RecStatus
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
|
||||
|
||||
// A raw app has no run to capture: its demo is a recorded session of someone
|
||||
// using it, driven in the record drawer and replayed on the Hub page. Legacy raw
|
||||
// apps live only in the `raw_app` table, and the record surface loads the app
|
||||
// (bundle secret, runnables) through AppService, so it can only offer the action
|
||||
// for apps stored in the `app` table.
|
||||
export const canRecordSession = (it: DeployItem): boolean =>
|
||||
it.kind === 'raw_app' && it.appTable === true
|
||||
|
||||
// Hub rehydration carries draft membership, not where an app is stored. Copy the
|
||||
// app-table origin from the loaded workspace items onto matching draft items so a
|
||||
// reopened draft still knows which raw apps can be recorded. Returns the original
|
||||
// array unchanged when nothing needs merging (stable reference).
|
||||
export function mergeAppTableOrigin(
|
||||
draftItems: DeployItem[],
|
||||
workspaceItems: DeployItem[]
|
||||
): DeployItem[] {
|
||||
if (draftItems.length === 0 || workspaceItems.length === 0) return draftItems
|
||||
const byKey = new Map(workspaceItems.map((w) => [w.key, w]))
|
||||
let changed = false
|
||||
const merged = draftItems.map((d) => {
|
||||
const w = byKey.get(d.key)
|
||||
if (!w || w.appTable === d.appTable) return d
|
||||
changed = true
|
||||
return { ...d, appTable: w.appTable }
|
||||
})
|
||||
return changed ? merged : draftItems
|
||||
}
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
RawAppService,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
WorkspaceService,
|
||||
ScheduleService
|
||||
} from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { sleep, emptySchema } from '$lib/utils'
|
||||
import { computeSecretUrl } from '$lib/components/apps/editor/appDeploy.svelte'
|
||||
import {
|
||||
buildProjectBundle,
|
||||
buildPathMap,
|
||||
@@ -37,6 +35,12 @@ import {
|
||||
type GeneratedMigration
|
||||
} from './projectMigrations'
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
import {
|
||||
canRecord,
|
||||
canRecordSession,
|
||||
mergeAppTableOrigin,
|
||||
type DeployItem
|
||||
} from './deployToHubItems'
|
||||
import type { AssetGraphResponse } from '$lib/components/assets/AssetGraph/types'
|
||||
import {
|
||||
CASCADE_JOB_TIMEOUT_MS,
|
||||
@@ -44,7 +48,7 @@ import {
|
||||
DATA_ASSET_KINDS
|
||||
} from '$lib/components/assets/AssetGraph/cascadeRun'
|
||||
import { capturePipelineRecording } from '$lib/components/recording/pipelineRecording.svelte'
|
||||
import type { PipelineRecording } from '$lib/components/recording/types'
|
||||
import type { PipelineRecording, RawAppRecording } from '$lib/components/recording/types'
|
||||
import {
|
||||
TRIGGER_KINDS,
|
||||
listAllWorkspaceTriggers,
|
||||
@@ -56,48 +60,14 @@ import {
|
||||
} from '../triggers/workspaceTriggersList'
|
||||
|
||||
export type Phase = 'predeploy' | 'draft' | 'under_review' | 'live'
|
||||
export type RecStatus = 'none' | 'recorded'
|
||||
export interface DeployItem {
|
||||
key: string
|
||||
path: string
|
||||
kind: Kind
|
||||
summary?: string
|
||||
rec: RecStatus
|
||||
published?: boolean
|
||||
publicUrl?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export const canRecord = (k: Kind) => k === 'script' || k === 'flow'
|
||||
// Legacy raw apps live only in the `raw_app` table, but the iframe share flow
|
||||
// drives AppService (the `app` table), so it can only target apps stored there.
|
||||
export const canShareAsIframe = (it: DeployItem): boolean =>
|
||||
it.kind === 'app' || (it.kind === 'raw_app' && it.appTable === true)
|
||||
|
||||
// Hub rehydration only carries draft membership, not the live share state of an
|
||||
// app. Copy the public-execution flag, public URL, and app-table origin from the
|
||||
// loaded workspace items onto matching draft items so a still-public app keeps its
|
||||
// Public badge, Unpublish, and iframe controls after its draft is reopened. Returns
|
||||
// the original array unchanged when nothing needs merging (stable reference).
|
||||
export function mergeShareState(
|
||||
draftItems: DeployItem[],
|
||||
workspaceItems: DeployItem[]
|
||||
): DeployItem[] {
|
||||
if (draftItems.length === 0 || workspaceItems.length === 0) return draftItems
|
||||
const byKey = new Map(workspaceItems.map((w) => [w.key, w]))
|
||||
let changed = false
|
||||
const merged = draftItems.map((d) => {
|
||||
const w = byKey.get(d.key)
|
||||
if (!w) return d
|
||||
if (w.published !== d.published || w.publicUrl !== d.publicUrl || w.appTable !== d.appTable) {
|
||||
changed = true
|
||||
return { ...d, published: w.published, publicUrl: w.publicUrl, appTable: w.appTable }
|
||||
}
|
||||
return d
|
||||
})
|
||||
return changed ? merged : draftItems
|
||||
}
|
||||
|
||||
// Re-exported so the publish-flow components keep one import site.
|
||||
export {
|
||||
canRecord,
|
||||
canRecordSession,
|
||||
mergeAppTableOrigin,
|
||||
type DeployItem,
|
||||
type RecStatus
|
||||
} from './deployToHubItems'
|
||||
export function sanitizeSlug(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
@@ -201,7 +171,6 @@ export class DeployToHubSession {
|
||||
schedulePreviews = $state<Record<string, string[]>>({})
|
||||
manualDeselected = $state<Set<string>>(new Set())
|
||||
loading = $state(false)
|
||||
workspaceRateLimit = $state<number | undefined>(undefined)
|
||||
deploymentStatus = $state<
|
||||
Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
>({})
|
||||
@@ -228,9 +197,6 @@ export class DeployToHubSession {
|
||||
pipelineRunError = $state<string | undefined>(undefined)
|
||||
pipelineRecorded = $state(false)
|
||||
|
||||
publishTarget = $state<DeployItem | undefined>()
|
||||
publishing = $state(false)
|
||||
|
||||
hubName = $state('')
|
||||
hubSummary = $state('')
|
||||
hubReadme = $state('')
|
||||
@@ -301,9 +267,9 @@ export class DeployToHubSession {
|
||||
)
|
||||
// Derived (not merged at load time) so it settles regardless of which of the
|
||||
// racing loads (#loadWorkspace / rehydrateFromHub) finishes last.
|
||||
draftItemsWithLocalState = $derived(mergeShareState(this.draftItems, this.workspaceItems))
|
||||
draftItemsWithOrigin = $derived(mergeAppTableOrigin(this.draftItems, this.workspaceItems))
|
||||
items = $derived(
|
||||
this.phase === 'predeploy' ? this.filteredWorkspaceItems : this.draftItemsWithLocalState
|
||||
this.phase === 'predeploy' ? this.filteredWorkspaceItems : this.draftItemsWithOrigin
|
||||
)
|
||||
selectedItems = $derived(
|
||||
this.phase === 'predeploy'
|
||||
@@ -315,7 +281,9 @@ export class DeployToHubSession {
|
||||
this.phase === 'predeploy' &&
|
||||
this.selectedItemKeys.length === this.filteredWorkspaceItems.length
|
||||
)
|
||||
recordableItems = $derived(this.items.filter((i) => canRecord(i.kind)))
|
||||
// A raw app's recorded session counts towards the project's recordings just as
|
||||
// a script's captured run does — both are what a visitor replays.
|
||||
recordableItems = $derived(this.items.filter((i) => canRecord(i.kind) || canRecordSession(i)))
|
||||
allRecorded = $derived(
|
||||
this.recordableItems.length > 0 && this.recordableItems.every((i) => i.rec === 'recorded')
|
||||
)
|
||||
@@ -479,23 +447,16 @@ export class DeployToHubSession {
|
||||
const workspace = this.workspace
|
||||
this.loading = true
|
||||
try {
|
||||
const [apps, rawApps, flows, scripts, settings] = await Promise.all([
|
||||
const [apps, rawApps, flows, scripts] = await Promise.all([
|
||||
this.#listAllPages((p) => AppService.listApps({ workspace, ...p })),
|
||||
this.#listAllPages((p) => RawAppService.listRawApps({ workspace, ...p })),
|
||||
this.#listAllPages((p) => FlowService.listFlows({ workspace, ...p })),
|
||||
this.#listAllPages((p) => ScriptService.listScripts({ workspace, ...p })),
|
||||
WorkspaceService.getSettings({ workspace }).catch(() => undefined)
|
||||
this.#listAllPages((p) => ScriptService.listScripts({ workspace, ...p }))
|
||||
])
|
||||
if (this.#disposed) return
|
||||
|
||||
this.workspaceRateLimit = settings?.public_app_execution_limit_per_minute
|
||||
|
||||
const next: DeployItem[] = []
|
||||
const publicApps = apps.filter((a) => a.execution_mode === 'anonymous')
|
||||
const publicUrls = await Promise.all(publicApps.map((a) => this.#resolvePublicUrl(a.path)))
|
||||
const publicUrlByPath = new Map(publicApps.map((a, i) => [a.path, publicUrls[i]]))
|
||||
for (const a of apps) {
|
||||
const isPublic = a.execution_mode === 'anonymous'
|
||||
// Raw apps live in the `app` table (value = files/runnables) but must be
|
||||
// published to the Hub as raw apps, not low-code apps.
|
||||
const isRaw = (a as any).raw_app === true
|
||||
@@ -505,9 +466,7 @@ export class DeployToHubSession {
|
||||
kind: isRaw ? 'raw_app' : 'app',
|
||||
appTable: isRaw || undefined,
|
||||
summary: a.summary,
|
||||
rec: 'none',
|
||||
published: isPublic,
|
||||
publicUrl: isPublic ? publicUrlByPath.get(a.path) : undefined
|
||||
rec: 'none'
|
||||
})
|
||||
}
|
||||
for (const a of rawApps) {
|
||||
@@ -571,15 +530,6 @@ export class DeployToHubSession {
|
||||
}
|
||||
}
|
||||
|
||||
async #resolvePublicUrl(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
const secret = await AppService.getPublicSecretOfApp({ workspace: this.workspace, path })
|
||||
return computeSecretUrl(secret)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async rehydrateFromHub() {
|
||||
try {
|
||||
const res = await fetch(`/api/w/${this.workspace}/hub/project${this.#folderQs()}`, {
|
||||
@@ -1198,33 +1148,6 @@ export class DeployToHubSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A re-bundle clears the Hub-side embed (idempotent replace), so re-push it
|
||||
// for any raw app that is already public — keeps the live iframe in sync
|
||||
// without forcing an unpublish/share round-trip. Updates by hub id, safe in parallel.
|
||||
const embedResults = await Promise.all(
|
||||
bundle.items
|
||||
.filter((it) => it.kind === 'raw_app')
|
||||
.map(async (it) => {
|
||||
const hubId = this.hubItemIds[`${it.kind}:${it.path}`]
|
||||
const src = itemsSnapshot.find((i) => i.kind === 'raw_app' && i.path === it.path)
|
||||
if (!hubId || !src?.published) return 0
|
||||
// The re-bundle cleared the embed; a public raw app with no resolved URL
|
||||
// can't have its iframe restored, so it's an incomplete publish too —
|
||||
// count it (like a push failure) so the draft can't become submit-ready.
|
||||
if (!src.publicUrl) {
|
||||
sendUserToast(`Cannot restore the iframe for ${it.path}: missing public URL`, true)
|
||||
return 1
|
||||
}
|
||||
try {
|
||||
await this.#pushRawAppEmbed(hubId, src.publicUrl)
|
||||
return 0
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to sync iframe for ${it.path}: ${e?.message ?? e}`, true)
|
||||
return 1
|
||||
}
|
||||
})
|
||||
)
|
||||
failures += embedResults.reduce((a: number, b) => a + b, 0)
|
||||
if (this.#disposed) return
|
||||
try {
|
||||
await this.#pushTriggers(slug, resourcePathMap, triggersSnapshot)
|
||||
@@ -1530,6 +1453,27 @@ export class DeployToHubSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a recorded raw-app session as that app's Hub recording. */
|
||||
async saveAppRecording(it: DeployItem, recording: RawAppRecording): Promise<boolean> {
|
||||
const hubId = this.hubItemIds[it.key]
|
||||
if (!hubId) {
|
||||
sendUserToast(`Push the bundle to the Hub first before saving recordings`, true)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await this.#postHub(`/hub/raw_apps/${hubId}/recording`, {
|
||||
recording,
|
||||
project_slug: this.hubSlug
|
||||
})
|
||||
this.#patchItem(it.key, { rec: 'recorded' })
|
||||
sendUserToast(`Recording saved — ${recording.steps.length} steps`)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to save recording: ${e?.message ?? e}`, true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the current successful run as the Hub recording. Returns true on success. */
|
||||
async saveRecording(): Promise<boolean> {
|
||||
const it = this.recordTarget
|
||||
@@ -1688,88 +1632,6 @@ export class DeployToHubSession {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set the Hub raw app's live-iframe URL (or clear it with null). The Hub renders
|
||||
// from external_embed_url; project_slug scopes ownership.
|
||||
async #pushRawAppEmbed(hubId: number, url: string | null) {
|
||||
await this.#postHub(`/hub/raw_apps/${hubId}/embed`, {
|
||||
external_embed_url: url,
|
||||
project_slug: this.hubSlug
|
||||
})
|
||||
}
|
||||
|
||||
// Flip an app/raw app between public (anonymous) and private (publisher) and keep
|
||||
// the Hub raw-app iframe in sync. Returns the resolved public URL when shared.
|
||||
async #setAppShared(it: DeployItem, shared: boolean): Promise<string | null> {
|
||||
const workspace = this.workspace
|
||||
const hubId = it.kind === 'raw_app' ? this.hubItemIds[it.key] : undefined
|
||||
// Sharing a raw app as an iframe needs its Hub item to wire the embed. Fail
|
||||
// before flipping the app public so it can't be left anonymous with no embed.
|
||||
if (shared && it.kind === 'raw_app' && !hubId) {
|
||||
throw new Error('Push the bundle to the Hub first to share the live iframe')
|
||||
}
|
||||
const app = await AppService.getAppByPath({ workspace, path: it.path })
|
||||
const prevMode = (app.policy?.execution_mode ?? 'publisher') as 'anonymous' | 'publisher'
|
||||
const nextMode = (shared ? 'anonymous' : 'publisher') as 'anonymous' | 'publisher'
|
||||
const setMode = (mode: 'anonymous' | 'publisher', message: string) =>
|
||||
AppService.updateApp({
|
||||
workspace,
|
||||
path: it.path,
|
||||
requestBody: {
|
||||
policy: { ...(app.policy ?? {}), execution_mode: mode },
|
||||
deployment_message: message
|
||||
}
|
||||
})
|
||||
// Undo the policy flip so the app's public state stays consistent when a later
|
||||
// step of the share fails. Best-effort: a revert failure must not mask the cause.
|
||||
const rollback = () => setMode(prevMode, 'Revert iframe share').catch(() => {})
|
||||
await setMode(nextMode, shared ? 'Share as iframe' : 'Unshare iframe')
|
||||
const url = shared ? ((await this.#resolvePublicUrl(it.path)) ?? null) : null
|
||||
// A share with no resolvable public URL is incomplete (no embeddable link, no
|
||||
// Unpublish control); don't leave the app anonymous while reporting success.
|
||||
if (shared && url === null) {
|
||||
await rollback()
|
||||
throw new Error(`Could not resolve the public URL for ${it.path}`)
|
||||
}
|
||||
if (hubId && it.kind === 'raw_app' && (!shared || url)) {
|
||||
try {
|
||||
await this.#pushRawAppEmbed(hubId, shared ? url : null)
|
||||
} catch (e) {
|
||||
await rollback()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/** Make the publish target public. Returns true on success. */
|
||||
async confirmPublish(): Promise<boolean> {
|
||||
const it = this.publishTarget
|
||||
if (!it || !canShareAsIframe(it)) return false
|
||||
this.publishing = true
|
||||
try {
|
||||
const url = await this.#setAppShared(it, true)
|
||||
this.#patchItem(it.key, { published: true, publicUrl: url ?? undefined })
|
||||
sendUserToast(`${it.path} is now public`)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to publish: ${e?.message ?? e}`, true)
|
||||
return false
|
||||
} finally {
|
||||
this.publishing = false
|
||||
}
|
||||
}
|
||||
|
||||
unpublishApp = async (it: DeployItem) => {
|
||||
if (!canShareAsIframe(it)) return
|
||||
try {
|
||||
await this.#setAppShared(it, false)
|
||||
this.#patchItem(it.key, { published: false, publicUrl: undefined })
|
||||
sendUserToast('App unpublished')
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to unpublish: ${e?.message ?? e}`, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canShareAsIframe, mergeShareState, type DeployItem } from './deployToHubSession.svelte'
|
||||
|
||||
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
|
||||
return { rec: 'none', ...over }
|
||||
}
|
||||
|
||||
describe('canShareAsIframe', () => {
|
||||
it('allows low-code apps and app-table raw apps', () => {
|
||||
expect(canShareAsIframe(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(true)
|
||||
expect(
|
||||
canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
it('hides the action for legacy raw apps (raw_app table only)', () => {
|
||||
// Legacy entries from RawAppService carry no appTable flag; AppService can't load them.
|
||||
expect(canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
|
||||
})
|
||||
it('never offers the action for flows or scripts', () => {
|
||||
expect(canShareAsIframe(item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeShareState', () => {
|
||||
it('carries live public-share state from workspace items onto matching drafts', () => {
|
||||
const drafts = [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })]
|
||||
const workspace = [
|
||||
item({
|
||||
key: 'app:f/a',
|
||||
path: 'f/a',
|
||||
kind: 'app',
|
||||
published: true,
|
||||
publicUrl: 'https://x/app'
|
||||
})
|
||||
]
|
||||
const merged = mergeShareState(drafts, workspace)
|
||||
expect(merged[0].published).toBe(true)
|
||||
expect(merged[0].publicUrl).toBe('https://x/app')
|
||||
})
|
||||
it('restores the app-table origin so app-table raw apps stay shareable', () => {
|
||||
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
|
||||
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
|
||||
expect(canShareAsIframe(mergeShareState(drafts, workspace)[0])).toBe(true)
|
||||
})
|
||||
it('returns the same reference when nothing changes', () => {
|
||||
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
|
||||
expect(mergeShareState(drafts, drafts)).toBe(drafts)
|
||||
})
|
||||
it('leaves drafts without a workspace match untouched', () => {
|
||||
const drafts = [item({ key: 'app:f/gone', path: 'f/gone', kind: 'app' })]
|
||||
const merged = mergeShareState(drafts, [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })])
|
||||
expect(merged).toBe(drafts)
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,16 @@
|
||||
<script lang="ts">
|
||||
import FlowRecordingReplay from '$lib/components/recording/FlowRecordingReplay.svelte'
|
||||
import ScriptRecordingReplay from '$lib/components/recording/ScriptRecordingReplay.svelte'
|
||||
import PipelineRecordingReplay from '$lib/components/recording/PipelineRecordingReplay.svelte'
|
||||
import type {
|
||||
FlowRecording,
|
||||
PipelineRecording,
|
||||
ScriptRecording
|
||||
} from '$lib/components/recording/types'
|
||||
import RecordingPlayer from '$lib/components/recording/RecordingPlayer.svelte'
|
||||
import {
|
||||
fetchRecording,
|
||||
parseRecording,
|
||||
type LoadedRecording
|
||||
} from '$lib/components/recording/rawAppRecordingLoad'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Button } from '$lib/components/common'
|
||||
import FileInput from '$lib/components/common/fileInput/FileInput.svelte'
|
||||
import { Loader2, TriangleAlert, Upload } from 'lucide-svelte'
|
||||
import { setActiveReplay } from '$lib/components/recording/flowRecording.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let flowRecording: FlowRecording | undefined = $state(undefined)
|
||||
let scriptRecording: ScriptRecording | undefined = $state(undefined)
|
||||
let pipelineRecording: PipelineRecording | undefined = $state(undefined)
|
||||
|
||||
// Upper bound on a `?src=` fetched recording (JSON with capped samples/logs) so
|
||||
// an arbitrary origin can't OOM the tab with an endless/huge response.
|
||||
const MAX_RECORDING_BYTES = 100 * 1024 * 1024
|
||||
let loaded: LoadedRecording | undefined = $state(undefined)
|
||||
|
||||
// Auto-download state: when the page is opened with `?src=<url>` it fetches
|
||||
// the recording JSON at that URL (with progress) instead of showing the
|
||||
@@ -30,112 +20,26 @@
|
||||
let downloadedBytes = $state(0)
|
||||
let downloadError = $state<string | undefined>(undefined)
|
||||
|
||||
function reset() {
|
||||
flowRecording = undefined
|
||||
scriptRecording = undefined
|
||||
pipelineRecording = undefined
|
||||
}
|
||||
|
||||
/** Parse recording JSON text and route it to the right player. Returns false
|
||||
/** Validate a parsed recording and route it to the right player. Returns false
|
||||
* (and toasts) when the payload isn't a recognized recording. */
|
||||
function loadRecordingFromText(content: string): boolean {
|
||||
let data: any
|
||||
try {
|
||||
data = JSON.parse(content)
|
||||
} catch (err) {
|
||||
sendUserToast('Failed to load recording: ' + err, true)
|
||||
function accept(data: unknown): boolean {
|
||||
const res = parseRecording(data)
|
||||
if (!res.ok) {
|
||||
sendUserToast(res.error, true)
|
||||
return false
|
||||
}
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
// A RecordedJob whose events all carry an object `data`: JobLoader replays
|
||||
// each `event.data` in a `setTimeout`, whose throw a Svelte boundary can't
|
||||
// catch, so a malformed event must be rejected at load. Shared by all three
|
||||
// recording types (flow/script/pipeline all mount jobs through JobLoader).
|
||||
const isRecordedJob = (j: unknown): boolean =>
|
||||
isObject(j) &&
|
||||
isObject(j.initial_job) &&
|
||||
Array.isArray(j.events) &&
|
||||
j.events.every((e) => isObject(e) && isObject(e.data))
|
||||
const isJobsMap = (v: unknown): boolean => isObject(v) && Object.values(v).every(isRecordedJob)
|
||||
// A non-object payload (JSON `null`, an array, a scalar) has no `.version`
|
||||
// to read — guard before dereferencing so it toasts instead of throwing.
|
||||
if (!isObject(data)) {
|
||||
sendUserToast('Invalid recording format', true)
|
||||
return false
|
||||
}
|
||||
if (data.version !== 1) {
|
||||
sendUserToast('Invalid recording format', true)
|
||||
return false
|
||||
}
|
||||
if (data.type === 'script') {
|
||||
if (!isRecordedJob(data.job)) {
|
||||
sendUserToast('Invalid script recording format', true)
|
||||
return false
|
||||
}
|
||||
reset()
|
||||
scriptRecording = data as ScriptRecording
|
||||
} else if (data.type === 'pipeline') {
|
||||
// Load-time check on caller-controlled input (upload / `?src=` fetch) so the
|
||||
// common malformed shapes toast here; the render boundary below is the
|
||||
// catch-all for anything deeper.
|
||||
const objectArray = (v: unknown) => Array.isArray(v) && v.every(isObject)
|
||||
const optionalObjectArray = (v: unknown) => v === undefined || objectArray(v)
|
||||
const optionalRecord = (v: unknown, valid: (x: Record<string, unknown>) => boolean) =>
|
||||
v === undefined || (isObject(v) && Object.values(v).every((x) => isObject(x) && valid(x)))
|
||||
const g = data.graph as Record<string, unknown> | null
|
||||
const validGraph =
|
||||
isObject(g) &&
|
||||
objectArray(g.runnables) &&
|
||||
objectArray(g.assets) &&
|
||||
objectArray(g.edges) &&
|
||||
Array.isArray(g.triggers) &&
|
||||
g.triggers.every((t) => isObject(t) && typeof t.trigger_kind === 'string') &&
|
||||
optionalObjectArray(g.macro_edges) &&
|
||||
optionalObjectArray(g.test_edges)
|
||||
const validTimeline =
|
||||
Array.isArray(data.timeline) &&
|
||||
data.timeline.every(
|
||||
(f: unknown) =>
|
||||
isObject(f) && isObject(f.statuses) && Object.values(f.statuses).every(isObject)
|
||||
)
|
||||
const validJobs = isJobsMap(data.jobs)
|
||||
// A sample renders `rows`/`columns` unless it carries a non-empty `error`.
|
||||
const validSamples = optionalRecord(
|
||||
data.assetSamples,
|
||||
(s) =>
|
||||
(typeof s.error === 'string' && s.error !== '') ||
|
||||
(objectArray(s.rows) && objectArray(s.columns))
|
||||
)
|
||||
const validCodes = optionalRecord(
|
||||
data.codes,
|
||||
(c) => typeof c.content === 'string' && typeof c.language === 'string'
|
||||
)
|
||||
if (!(validGraph && validTimeline && validJobs && validSamples && validCodes)) {
|
||||
sendUserToast('Invalid pipeline recording format', true)
|
||||
return false
|
||||
}
|
||||
reset()
|
||||
pipelineRecording = data as PipelineRecording
|
||||
} else {
|
||||
// Flow recording (type === 'flow' or type is absent for backwards compat).
|
||||
// Validate jobs structurally — the `?src=` loader accepts remote payloads
|
||||
// and FlowRecordingReplay mounts them straight into JobLoader, outside the
|
||||
// pipeline boundary.
|
||||
if (!isJobsMap(data.jobs)) {
|
||||
sendUserToast('Invalid flow recording format', true)
|
||||
return false
|
||||
}
|
||||
reset()
|
||||
flowRecording = data as FlowRecording
|
||||
}
|
||||
loaded = res.loaded
|
||||
return true
|
||||
}
|
||||
|
||||
function handleFileChange(event: CustomEvent<(string | ArrayBuffer | null)[]>) {
|
||||
const content = event.detail?.[0]
|
||||
if (!content || typeof content !== 'string') return
|
||||
loadRecordingFromText(content)
|
||||
try {
|
||||
accept(JSON.parse(content))
|
||||
} catch (err) {
|
||||
sendUserToast('Failed to load recording: ' + err, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a recording JSON from `url`, streaming so a progress bar can show,
|
||||
@@ -146,43 +50,11 @@
|
||||
downloadPercent = undefined
|
||||
downloadedBytes = 0
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
|
||||
const total = Number(res.headers.get('content-length')) || 0
|
||||
// `?src=` is an arbitrary (possibly permissive-CORS) origin, so cap the
|
||||
// download: reject an oversized advertised length up front and abort the
|
||||
// stream once the running total exceeds the limit, so an endless/huge
|
||||
// response can't exhaust the tab before validation runs.
|
||||
if (total > MAX_RECORDING_BYTES) {
|
||||
throw new Error(
|
||||
`Recording is too large (${fmtBytes(total)}, max ${fmtBytes(MAX_RECORDING_BYTES)})`
|
||||
)
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
let text: string
|
||||
if (reader) {
|
||||
const chunks: Uint8Array[] = []
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) {
|
||||
chunks.push(value)
|
||||
downloadedBytes += value.length
|
||||
if (downloadedBytes > MAX_RECORDING_BYTES) {
|
||||
await reader.cancel()
|
||||
throw new Error(`Recording exceeded the ${fmtBytes(MAX_RECORDING_BYTES)} limit`)
|
||||
}
|
||||
if (total) downloadPercent = Math.round((downloadedBytes / total) * 100)
|
||||
}
|
||||
}
|
||||
text = await new Blob(chunks as BlobPart[]).text()
|
||||
} else {
|
||||
text = await res.text()
|
||||
if (text.length > MAX_RECORDING_BYTES) {
|
||||
throw new Error(`Recording exceeded the ${fmtBytes(MAX_RECORDING_BYTES)} limit`)
|
||||
}
|
||||
}
|
||||
if (!loadRecordingFromText(text)) {
|
||||
const data = await fetchRecording(url, (bytes, total) => {
|
||||
downloadedBytes = bytes
|
||||
downloadPercent = total ? Math.round((bytes / total) * 100) : undefined
|
||||
})
|
||||
if (!accept(data)) {
|
||||
downloadError = 'The downloaded file is not a valid recording.'
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -197,11 +69,6 @@
|
||||
if (src) loadFromUrl(src)
|
||||
})
|
||||
|
||||
function quit() {
|
||||
setActiveReplay(undefined)
|
||||
reset()
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
|
||||
@@ -209,88 +76,37 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Shown by every player's boundary when a malformed recording crashes on
|
||||
render/effect (the load-time validation and JobLoader guards cover the rest). -->
|
||||
{#snippet replayFailed()}
|
||||
<div class="flex flex-col items-center justify-center h-full gap-2 text-center">
|
||||
<TriangleAlert class="text-red-500" size={28} />
|
||||
<p class="max-w-md text-sm text-secondary">
|
||||
This recording could not be replayed — it may be malformed or from an incompatible version.
|
||||
</p>
|
||||
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
{#if loaded}
|
||||
<RecordingPlayer {loaded} onreset={() => (loaded = undefined)} class="px-4 py-4" />
|
||||
{:else if downloading}
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
|
||||
<div class="flex flex-col items-center gap-3 max-w-md w-full">
|
||||
<Loader2 class="animate-spin text-blue-500" size={28} />
|
||||
<h2 class="text-lg font-semibold text-emphasis">Downloading recording…</h2>
|
||||
{#if downloadPercent !== undefined}
|
||||
<div class="w-full h-2 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full bg-blue-500 transition-all" style="width: {downloadPercent}%"></div>
|
||||
</div>
|
||||
<p class="text-2xs text-tertiary">{downloadPercent}% · {fmtBytes(downloadedBytes)}</p>
|
||||
{:else}
|
||||
<p class="text-2xs text-tertiary">{fmtBytes(downloadedBytes)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- The pipeline player fills the viewport (graph left, detail right, like the
|
||||
pipeline editor); the flow/script players keep the centered scrolling page. -->
|
||||
<div
|
||||
class={pipelineRecording
|
||||
? 'flex flex-col h-full w-full px-4 py-4 min-h-0'
|
||||
: 'max-w-7xl mx-auto px-4 py-8 w-full'}
|
||||
>
|
||||
{#if flowRecording}
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4">
|
||||
<div class="flex flex-col items-center gap-2 max-w-md w-full">
|
||||
<h2 class="text-lg font-semibold text-emphasis">Replay a recording</h2>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
Upload a recording JSON file to replay a flow, script or data-pipeline execution — or a
|
||||
raw-app session — offline.
|
||||
</p>
|
||||
{#if downloadError}
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mb-1 text-center">{downloadError}</p>
|
||||
{/if}
|
||||
<FileInput accept=".json" convertTo="text" class="w-full" on:change={handleFileChange}>
|
||||
Drag and drop a recording file
|
||||
</FileInput>
|
||||
</div>
|
||||
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
|
||||
<FlowRecordingReplay recording={flowRecording} />
|
||||
{#snippet failed()}{@render replayFailed()}{/snippet}
|
||||
</svelte:boundary>
|
||||
{:else if scriptRecording}
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
|
||||
<ScriptRecordingReplay recording={scriptRecording} />
|
||||
{#snippet failed()}{@render replayFailed()}{/snippet}
|
||||
</svelte:boundary>
|
||||
{:else if pipelineRecording}
|
||||
<div class="flex justify-end mb-2 shrink-0">
|
||||
<Button variant="border" size="xs" onclick={quit} startIcon={{ icon: Upload }}>
|
||||
Load another recording
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<svelte:boundary onerror={() => setActiveReplay(undefined)}>
|
||||
<PipelineRecordingReplay recording={pipelineRecording} />
|
||||
{#snippet failed()}{@render replayFailed()}{/snippet}
|
||||
</svelte:boundary>
|
||||
</div>
|
||||
{:else if downloading}
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<div class="flex flex-col items-center gap-3 max-w-md w-full">
|
||||
<Loader2 class="animate-spin text-blue-500" size={28} />
|
||||
<h2 class="text-lg font-semibold text-emphasis">Downloading recording…</h2>
|
||||
{#if downloadPercent !== undefined}
|
||||
<div class="w-full h-2 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full bg-blue-500 transition-all" style="width: {downloadPercent}%"></div>
|
||||
</div>
|
||||
<p class="text-2xs text-tertiary">{downloadPercent}% · {fmtBytes(downloadedBytes)}</p>
|
||||
{:else}
|
||||
<p class="text-2xs text-tertiary">{fmtBytes(downloadedBytes)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh]">
|
||||
<div class="flex flex-col items-center gap-2 max-w-md w-full">
|
||||
<h2 class="text-lg font-semibold text-emphasis">Replay a recording</h2>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
Upload a recording JSON file to replay a flow, script or data-pipeline execution offline.
|
||||
</p>
|
||||
{#if downloadError}
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mb-1 text-center">{downloadError}</p>
|
||||
{/if}
|
||||
<FileInput accept=".json" convertTo="text" class="w-full" on:change={handleFileChange}>
|
||||
Drag and drop a recording file
|
||||
</FileInput>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { redirect } from '@sveltejs/kit'
|
||||
import { base } from '$app/paths'
|
||||
|
||||
// The replay page moved to /pipeline_replay (it now replays data-pipeline
|
||||
// recordings in addition to flow/script ones). Redirect the old path in `load`
|
||||
// so existing /replay links and bookmarks still resolve instead of 404-ing.
|
||||
export function load({ url }: { url: URL }) {
|
||||
redirect(307, `${base}/pipeline_replay${url.search}`)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Public, chrome-less player for a Windmill recording — no login, and
|
||||
* embeddable in an iframe. Windmill stores nothing: the recording is either a
|
||||
* file the visitor opens locally or a URL they host themselves (`?src=`), so a
|
||||
* demo can live next to the docs or README that links it. Every kind of
|
||||
* recording (app session, flow, script, pipeline run) plays here, offline.
|
||||
*/
|
||||
import RecordingPlayer from '$lib/components/recording/RecordingPlayer.svelte'
|
||||
import { setOfflineReplay } from '$lib/components/recording/offlineReplay.svelte'
|
||||
import {
|
||||
fetchRecording,
|
||||
MAX_RECORDING_BYTES,
|
||||
parseRecording
|
||||
} from '$lib/components/recording/rawAppRecordingLoad'
|
||||
import type { LoadedRecording } from '$lib/components/recording/rawAppRecordingLoad'
|
||||
import { Loader2, TriangleAlert, Upload } from 'lucide-svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
let loaded = $state<LoadedRecording | undefined>(undefined)
|
||||
let loading = $state(false)
|
||||
let error = $state<string | undefined>(undefined)
|
||||
let progress = $state<number | undefined>(undefined)
|
||||
|
||||
function accept(data: unknown): boolean {
|
||||
const res = parseRecording(data)
|
||||
if (!res.ok) {
|
||||
error = res.error
|
||||
return false
|
||||
}
|
||||
error = undefined
|
||||
loaded = res.loaded
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadFromUrl(src: string) {
|
||||
loading = true
|
||||
error = undefined
|
||||
try {
|
||||
accept(
|
||||
await fetchRecording(src, (bytes, total) => {
|
||||
progress = total ? Math.round((bytes / total) * 100) : undefined
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
error = `Could not load the recording: ${e instanceof Error ? e.message : e}`
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Cleared so picking the same file again after an error still fires `change`.
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
// Same cap the `?src=` fetch enforces: reading a multi-hundred-MB file into a
|
||||
// string is enough to take the tab down before validation gets a say.
|
||||
if (file.size > MAX_RECORDING_BYTES) {
|
||||
error = `That file is too large to replay (${file.size} bytes).`
|
||||
return
|
||||
}
|
||||
try {
|
||||
accept(JSON.parse(await file.text()))
|
||||
} catch (err) {
|
||||
error = `Could not read the file: ${err instanceof Error ? err.message : err}`
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setOfflineReplay(true)
|
||||
const src = new URL(window.location.href).searchParams.get('src')
|
||||
if (src) loadFromUrl(src)
|
||||
})
|
||||
|
||||
onDestroy(() => setOfflineReplay(false))
|
||||
|
||||
let title = $derived.by(() => {
|
||||
if (!loaded) return 'Replay'
|
||||
switch (loaded.kind) {
|
||||
case 'app':
|
||||
return `Replay — ${loaded.recording.app_path}`
|
||||
case 'script':
|
||||
return `Replay — ${loaded.recording.script_path}`
|
||||
case 'pipeline':
|
||||
return `Replay — ${loaded.recording.folder}`
|
||||
case 'flow':
|
||||
return `Replay — ${loaded.recording.flow_path}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{title}</title></svelte:head>
|
||||
|
||||
<div class="h-screen w-screen p-3 bg-surface overflow-auto">
|
||||
{#if loaded}
|
||||
<RecordingPlayer {loaded} hideHeader onreset={() => (loaded = undefined)} />
|
||||
{:else if loading}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 text-sm text-secondary">
|
||||
<Loader2 size={24} class="animate-spin text-blue-500" />
|
||||
Downloading the recording{progress !== undefined ? ` — ${progress}%` : ''}…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-3 text-center px-4">
|
||||
<h1 class="text-lg font-semibold text-emphasis">Replay a recording</h1>
|
||||
<p class="text-xs text-secondary max-w-lg">
|
||||
Open a recording file, or point this page at one you host yourself with
|
||||
<span class="font-mono">?src=<url></span>. Nothing is uploaded to Windmill — the
|
||||
recording is read in your browser and replayed from it alone.
|
||||
</p>
|
||||
{#if error}
|
||||
<p class="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
<TriangleAlert size={14} />
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
<label
|
||||
class="inline-flex items-center gap-2 text-xs border rounded-md px-3 py-2 cursor-pointer hover:bg-surface-hover"
|
||||
>
|
||||
<Upload size={14} /> Choose a recording file
|
||||
<input type="file" accept=".json" class="hidden" onchange={onFile} />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
+12
-1
@@ -138,9 +138,20 @@ const config = {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/**/*.dom.{test,spec}.{js,ts}'],
|
||||
setupFiles: ['src/lib/test-setup.ts']
|
||||
}
|
||||
},
|
||||
{
|
||||
// `*.dom.test.ts` — for the pure DOM utilities (snapshot serialization,
|
||||
// replay sanitization) whose contracts can only be asserted against a
|
||||
// real document.
|
||||
extends: './vite.config.js',
|
||||
test: {
|
||||
name: 'dom',
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.dom.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -647,6 +647,18 @@ const user = await backend.get_user({ user_id: '123' });
|
||||
|
||||
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.<key>(args)\` for everything server-side.
|
||||
|
||||
### Keeping data out of recorded demos
|
||||
|
||||
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with \`data-wm-no-record\` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
|
||||
|
||||
\`\`\`tsx
|
||||
<label data-wm-no-record>
|
||||
Customer SSN <input value={ssn} onChange={onSsn} />
|
||||
</label>
|
||||
\`\`\`
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
@@ -749,6 +761,7 @@ def main(user_id: str):
|
||||
3. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
4. **Use descriptive keys** — \`get_user\`, not \`a\`.
|
||||
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first.
|
||||
6. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
`;
|
||||
|
||||
export const PIPELINE_BASE = `# Data pipeline authoring
|
||||
|
||||
@@ -284,6 +284,18 @@ const user = await backend.get_user({ user_id: '123' });
|
||||
|
||||
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.<key>(args)` for everything server-side.
|
||||
|
||||
### Keeping data out of recorded demos
|
||||
|
||||
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
|
||||
|
||||
```tsx
|
||||
<label data-wm-no-record>
|
||||
Customer SSN <input value={ssn} onChange={onSsn} />
|
||||
</label>
|
||||
```
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
@@ -386,3 +398,4 @@ def main(user_id: str):
|
||||
3. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
4. **Use descriptive keys** — `get_user`, not `a`.
|
||||
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.
|
||||
6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
|
||||
@@ -49,6 +49,18 @@ const user = await backend.get_user({ user_id: '123' });
|
||||
|
||||
The frontend cannot reach datatables, workspace items, or external services on its own — it goes through `backend.<key>(args)` for everything server-side.
|
||||
|
||||
### Keeping data out of recorded demos
|
||||
|
||||
An app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:
|
||||
|
||||
```tsx
|
||||
<label data-wm-no-record>
|
||||
Customer SSN <input value={ssn} onChange={onSsn} />
|
||||
</label>
|
||||
```
|
||||
|
||||
Apply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.
|
||||
|
||||
## Backend runnables
|
||||
|
||||
Each runnable has a unique key (used to call it from the frontend) and one of four types:
|
||||
@@ -151,3 +163,4 @@ def main(user_id: str):
|
||||
3. **Keep runnables focused** — one function per runnable; small surface area.
|
||||
4. **Use descriptive keys** — `get_user`, not `a`.
|
||||
5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.
|
||||
6. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.
|
||||
|
||||
Reference in New Issue
Block a user