From ad17582d5a95400173c2601583fb1dcd17938eda Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 10 Nov 2026 16:21:17 +0100 Subject: [PATCH 1/3] DISCO-282: Inject the app reporter into proxied preview pages sdk preview now adds the Celonis app reporter as the first element of every proxied HTML page and allows it by its hash in the preview CSP. The reporter tells the Celonis preview that the page loaded, how many inline scripts the preview blocks, and which errors stopped the app, so the Codehub can show the matching hint right away. Co-Authored-By: Claude Includes-AI-Code: true --- src/commands/sdk/preview-app-reporter.ts | 57 +++++++++++++++++++ src/commands/sdk/preview-headers.ts | 2 +- src/commands/sdk/preview-proxy.ts | 7 ++- .../commands/sdk/preview-app-reporter.spec.ts | 45 +++++++++++++++ tests/commands/sdk/preview-proxy.spec.ts | 19 ++++--- 5 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/commands/sdk/preview-app-reporter.ts create mode 100644 tests/commands/sdk/preview-app-reporter.spec.ts diff --git a/src/commands/sdk/preview-app-reporter.ts b/src/commands/sdk/preview-app-reporter.ts new file mode 100644 index 00000000..f8cad531 --- /dev/null +++ b/src/commands/sdk/preview-app-reporter.ts @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; + +/** + * Copied verbatim from `SANDBOX_APP_REPORTER` in ems-frontend `libs/common/fragments/sandbox/src/lib/app-reporter.ts`. + * The preview injects it into every proxied HTML page and allows it by its hash in `LOCAL_PREVIEW_HEADERS`, so the + * Celonis preview can tell that the page loaded and why the app stopped. Update both together. + */ +export const APP_REPORTER_SCRIPT = `(() => { + if (parent === window) return; + const text = (value) => String(value == null ? '' : value).slice(0, 500); + const post = (report) => parent.postMessage(Object.assign({type: 'ems-fragment-app-report-v1'}, report), '*'); + let blockedInlineScripts = 0; + try { + const count = JSON.parse(document.getElementById('ems-fragment-app-report-config').textContent).blockedInlineScripts; + if (Number.isInteger(count) && count >= 0) blockedInlineScripts = count; + } catch {} + post({kind: 'loaded', blockedInlineScripts}); + let problems = 0; + const problem = (report) => { + if (problems++ < 10) post(report); + }; + const failure = (error, fallback) => + problem({kind: 'error', name: text((error && error.name) || 'Error'), message: text((error && error.message) || fallback)}); + addEventListener('error', (event) => failure(event.error, event.message)); + addEventListener('unhandledrejection', (event) => + failure(event.reason instanceof Object ? event.reason : null, event.reason), + ); + addEventListener('securitypolicyviolation', (event) => + problem({kind: 'csp', directive: text(event.effectiveDirective), blocked: text(event.blockedURI)}), + ); +})();`; + +/** Id of the element the reporter reads `{blockedInlineScripts}` from. */ +export const APP_REPORTER_CONFIG_ID = "ems-fragment-app-report-config"; + +/** `'sha256-'` of {@link APP_REPORTER_SCRIPT}, ready for a CSP source list. */ +export const APP_REPORTER_HASH = `'sha256-${createHash("sha256").update(APP_REPORTER_SCRIPT, "utf8").digest("base64")}'`; + +const HEAD_TAG = /]*>/i; +const HTML_TAG = /]*>/i; + +/** + * Inserts the reporter and its config as the first elements of the page's ``, so it runs before any app + * script. Without a `` it goes right after ``, and without either at the very start. + */ +export function injectAppReporter(html: string, blockedInlineScripts: number): string { + const config = JSON.stringify({ blockedInlineScripts }); + const reporter = + `` + + ``; + const tag = HEAD_TAG.exec(html) ?? HTML_TAG.exec(html); + if (!tag) { + return reporter + html; + } + const end = tag.index + tag[0].length; + return html.slice(0, end) + reporter + html.slice(end); +} diff --git a/src/commands/sdk/preview-headers.ts b/src/commands/sdk/preview-headers.ts index 286fd45c..d61d4a18 100644 --- a/src/commands/sdk/preview-headers.ts +++ b/src/commands/sdk/preview-headers.ts @@ -4,7 +4,7 @@ */ export const LOCAL_PREVIEW_HEADERS: Readonly> = { "content-security-policy": - "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts", + "default-src 'none'; script-src 'self' 'sha256-DFRcr1Q+NdEcWiZa+OPfffD+SWji1eiVtoQZzPzDLCw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts", "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), clipboard-read=(), clipboard-write=(), display-capture=()", "access-control-allow-origin": "*", diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index cca68f30..d1ec35fd 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -5,6 +5,7 @@ import { Duplex } from "node:stream"; import * as tls from "node:tls"; import { FatalError, logger } from "../../core/utils/logger"; import { PreviewCertificate } from "./preview-certificate"; +import { injectAppReporter } from "./preview-app-reporter"; import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts"; export interface PreviewProxyOptions { @@ -90,14 +91,16 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise + `` + + ``; + +describe("Preview app reporter", () => { + it("Should be allowed by the preview's script-src with the hash ems-frontend pins", () => { + // If this fails, update APP_REPORTER_SCRIPT and LOCAL_PREVIEW_HEADERS from ems-frontend together. + expect(APP_REPORTER_HASH).toBe("'sha256-DFRcr1Q+NdEcWiZa+OPfffD+SWji1eiVtoQZzPzDLCw='"); + expect(APP_REPORTER_HASH).toBe( + `'sha256-${createHash("sha256").update(APP_REPORTER_SCRIPT).digest("base64")}'`, + ); + const scriptSrc = LOCAL_PREVIEW_HEADERS["content-security-policy"] + .split(";") + .map(directive => directive.trim()) + .find(directive => directive.startsWith("script-src ")); + expect(scriptSrc).toBe(`script-src 'self' ${APP_REPORTER_HASH}`); + }); + + it("Should insert the reporter and its config as the first elements of ", () => { + const html = ""; + + expect(injectAppReporter(html, 2)).toBe( + `${injected(2)}`, + ); + }); + + it("Should insert the reporter after when the page has no ", () => { + expect(injectAppReporter("App", 0)).toBe( + `${injected(0)}App`, + ); + }); + + it("Should insert the reporter at the start of a page without and ", () => { + expect(injectAppReporter("
App
", 1)).toBe(`${injected(1)}
App
`); + }); +}); diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 8d85b8c1..9bc0056f 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -4,6 +4,7 @@ import * as https from "node:https"; import * as net from "node:net"; import { Duplex } from "node:stream"; import { loadOrCreatePreviewCertificate, PreviewCertificate } from "../../../src/commands/sdk/preview-certificate"; +import { APP_REPORTER_HASH, injectAppReporter } from "../../../src/commands/sdk/preview-app-reporter"; import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers"; import { PreviewProxy, PreviewProxyOptions, startPreviewProxy } from "../../../src/commands/sdk/preview-proxy"; import { loggingTestTransport } from "../../jest.setup"; @@ -152,7 +153,7 @@ describe("Preview proxy", () => { expect(headerCount(response.rawHeaders, name)).toBe(1); expect(response.headers[name]).toBe(value); } - expect(response.body).toBe(""); + expect(response.body).toBe(injectAppReporter("", 0)); }); it("Should add the preview headers to responses without any headers of their own", async () => { @@ -329,8 +330,8 @@ describe("Preview proxy", () => { for (const response of [first, second]) { expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(reactPage); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(reactPage))); + expect(response.body).toBe(injectAppReporter(reactPage, 1)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(reactPage, 1)))); } expect(warnings()).toEqual([ `Blocking an inline script in /index.html: "${reactPreamble}". Inline scripts are disabled for published apps. ` + @@ -347,11 +348,11 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(scriptSrc(response)).toEqual(["'self'", sha256(inline)]); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256(inline)]); expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")) .toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); - expect(response.body).toBe(html); - expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(html))); + expect(response.body).toBe(injectAppReporter(html, 0)); + expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(html, 0)))); expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); } expect(warnings()).toEqual([ @@ -370,8 +371,8 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(scriptSrc(response)).toEqual(["'self'", sha256("a()")]); - expect(response.body).toBe(""); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256("a()")]); + expect(response.body).toBe(injectAppReporter("", 0)); expect(response.headers["content-length"]).toBeUndefined(); }); @@ -431,7 +432,7 @@ describe("Preview proxy", () => { const next = await send(proxy, "/"); expect(next.status).toBe(200); - expect(next.body).toBe(""); + expect(next.body).toBe(injectAppReporter("", 0)); expect(warnings()).toEqual([]); }); From 100a66932bfaa7c3b7c102c85e6172622c74e838 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Tue, 10 Nov 2026 17:15:50 +0100 Subject: [PATCH 2/3] DISCO-282: Add the app reporter's hash to the preview CSP at runtime The reporter is copied from ems-frontend's SANDBOX_APP_REPORTER, which is now built from typed observers. The proxy hashes the copy it injects and adds that hash to script-src, so LOCAL_PREVIEW_HEADERS stays equal to ems-frontend's localPreviewHeaders(). The spec checks the report messages, which are the contract with ems-frontend. Co-Authored-By: Claude Includes-AI-Code: true --- src/commands/sdk/preview-app-reporter.ts | 68 +++++++++++++------ src/commands/sdk/preview-headers.ts | 2 +- src/commands/sdk/preview-proxy.ts | 9 ++- .../commands/sdk/preview-app-reporter.spec.ts | 36 +++++++--- tests/commands/sdk/preview-proxy.spec.ts | 14 ++-- 5 files changed, 91 insertions(+), 38 deletions(-) diff --git a/src/commands/sdk/preview-app-reporter.ts b/src/commands/sdk/preview-app-reporter.ts index f8cad531..83dead2b 100644 --- a/src/commands/sdk/preview-app-reporter.ts +++ b/src/commands/sdk/preview-app-reporter.ts @@ -1,34 +1,60 @@ import { createHash } from "node:crypto"; /** - * Copied verbatim from `SANDBOX_APP_REPORTER` in ems-frontend `libs/common/fragments/sandbox/src/lib/app-reporter.ts`. - * The preview injects it into every proxied HTML page and allows it by its hash in `LOCAL_PREVIEW_HEADERS`, so the - * Celonis preview can tell that the page loaded and why the app stopped. Update both together. + * Copied from the value of `SANDBOX_APP_REPORTER` in ems-frontend `libs/common/fragments/sandbox/src/lib/app-reporter.ts`. + * The preview injects it into every proxied HTML page and allows it by {@link APP_REPORTER_HASH}, so the Celonis + * preview can tell that the page loaded and why the app stopped. Its report messages are the contract with + * ems-frontend; the spec checks them. */ -export const APP_REPORTER_SCRIPT = `(() => { - if (parent === window) return; - const text = (value) => String(value == null ? '' : value).slice(0, 500); - const post = (report) => parent.postMessage(Object.assign({type: 'ems-fragment-app-report-v1'}, report), '*'); +export const APP_REPORTER_SCRIPT = `(function sandboxAppReporter(reportType, configId, installObserver) { + if (parent === window) return () => void 0; + const post = (report) => parent.postMessage(Object.assign({ type: reportType }, report), "*"); + const text = (value) => value.slice(0, 500); let blockedInlineScripts = 0; try { - const count = JSON.parse(document.getElementById('ems-fragment-app-report-config').textContent).blockedInlineScripts; - if (Number.isInteger(count) && count >= 0) blockedInlineScripts = count; - } catch {} - post({kind: 'loaded', blockedInlineScripts}); + const config = JSON.parse(document.getElementById(configId)?.textContent ?? "{}"); + const count = config.blockedInlineScripts; + if (typeof count === "number" && Number.isInteger(count) && count >= 0) blockedInlineScripts = count; + } catch { + } + post({ kind: "loaded", blockedInlineScripts }); let problems = 0; const problem = (report) => { if (problems++ < 10) post(report); }; - const failure = (error, fallback) => - problem({kind: 'error', name: text((error && error.name) || 'Error'), message: text((error && error.message) || fallback)}); - addEventListener('error', (event) => failure(event.error, event.message)); - addEventListener('unhandledrejection', (event) => - failure(event.reason instanceof Object ? event.reason : null, event.reason), - ); - addEventListener('securitypolicyviolation', (event) => - problem({kind: 'csp', directive: text(event.effectiveDirective), blocked: text(event.blockedURI)}), - ); -})();`; + return installObserver({ + onError: ({ name, message }) => problem({ kind: "error", name: text(name), message: text(message) }), + onViolation: ({ directive, blocked }) => problem({ kind: "csp", directive: text(directive), blocked: text(blocked) }) + }); +})("ems-fragment-app-report-v1", "ems-fragment-app-report-config", function installErrorObserver(options) { + let active = true; + const describe = (error, fallback) => { + const fields = typeof error === "object" && error !== null ? error : {}; + return { + name: typeof fields.name === "string" && fields.name ? fields.name : "Error", + message: typeof fields.message === "string" && fields.message ? fields.message : String(fallback ?? "") + }; + }; + const reportError = (event) => { + if (active) options.onError(describe(event.error, event.message)); + }; + const reportRejection = (event) => { + if (active) options.onError(describe(event.reason, event.reason)); + }; + const reportViolation = (event) => { + if (active) options.onViolation({ directive: event.effectiveDirective, blocked: event.blockedURI }); + }; + window.addEventListener("error", reportError); + window.addEventListener("unhandledrejection", reportRejection); + window.addEventListener("securitypolicyviolation", reportViolation); + return () => { + if (!active) return; + active = false; + window.removeEventListener("error", reportError); + window.removeEventListener("unhandledrejection", reportRejection); + window.removeEventListener("securitypolicyviolation", reportViolation); + }; +});`; /** Id of the element the reporter reads `{blockedInlineScripts}` from. */ export const APP_REPORTER_CONFIG_ID = "ems-fragment-app-report-config"; diff --git a/src/commands/sdk/preview-headers.ts b/src/commands/sdk/preview-headers.ts index d61d4a18..286fd45c 100644 --- a/src/commands/sdk/preview-headers.ts +++ b/src/commands/sdk/preview-headers.ts @@ -4,7 +4,7 @@ */ export const LOCAL_PREVIEW_HEADERS: Readonly> = { "content-security-policy": - "default-src 'none'; script-src 'self' 'sha256-DFRcr1Q+NdEcWiZa+OPfffD+SWji1eiVtoQZzPzDLCw='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts", + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts", "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), clipboard-read=(), clipboard-write=(), display-capture=()", "access-control-allow-origin": "*", diff --git a/src/commands/sdk/preview-proxy.ts b/src/commands/sdk/preview-proxy.ts index d1ec35fd..53b7cc30 100644 --- a/src/commands/sdk/preview-proxy.ts +++ b/src/commands/sdk/preview-proxy.ts @@ -5,7 +5,7 @@ import { Duplex } from "node:stream"; import * as tls from "node:tls"; import { FatalError, logger } from "../../core/utils/logger"; import { PreviewCertificate } from "./preview-certificate"; -import { injectAppReporter } from "./preview-app-reporter"; +import { APP_REPORTER_HASH, injectAppReporter } from "./preview-app-reporter"; import { allowInlineScripts, inlineScriptHashes } from "./preview-inline-scripts"; export interface PreviewProxyOptions { @@ -108,8 +108,11 @@ export function startPreviewProxy(options: PreviewProxyOptions): Promise script.hash)), + ...(csp === undefined ? {} : { + "content-security-policy": allowInlineScripts(csp, [ + APP_REPORTER_HASH, + ...(allowInline ? scripts.map(script => script.hash) : []), + ]), }), }); res.end(body); diff --git a/tests/commands/sdk/preview-app-reporter.spec.ts b/tests/commands/sdk/preview-app-reporter.spec.ts index 5ad7748f..acc3ec20 100644 --- a/tests/commands/sdk/preview-app-reporter.spec.ts +++ b/tests/commands/sdk/preview-app-reporter.spec.ts @@ -5,24 +5,42 @@ import { APP_REPORTER_SCRIPT, injectAppReporter, } from "../../../src/commands/sdk/preview-app-reporter"; -import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers"; const injected = (count: number) => `` + ``; describe("Preview app reporter", () => { - it("Should be allowed by the preview's script-src with the hash ems-frontend pins", () => { - // If this fails, update APP_REPORTER_SCRIPT and LOCAL_PREVIEW_HEADERS from ems-frontend together. - expect(APP_REPORTER_HASH).toBe("'sha256-DFRcr1Q+NdEcWiZa+OPfffD+SWji1eiVtoQZzPzDLCw='"); + it("Should hash its exact text for the preview's script-src", () => { expect(APP_REPORTER_HASH).toBe( `'sha256-${createHash("sha256").update(APP_REPORTER_SCRIPT).digest("base64")}'`, ); - const scriptSrc = LOCAL_PREVIEW_HEADERS["content-security-policy"] - .split(";") - .map(directive => directive.trim()) - .find(directive => directive.startsWith("script-src ")); - expect(scriptSrc).toBe(`script-src 'self' ${APP_REPORTER_HASH}`); + }); + + it("Should post the report messages the Celonis preview expects", () => { + // If this fails, copy SANDBOX_APP_REPORTER from ems-frontend again; the messages are the contract. + const parent = { postMessage: jest.fn() }; + const listeners = new Map void>(); + const window = { + addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), + removeEventListener: (name: string) => listeners.delete(name), + }; + const document = { + getElementById: (id: string) => + id === APP_REPORTER_CONFIG_ID ? { textContent: "{\"blockedInlineScripts\":2}" } : null, + }; + new Function("window", "parent", "document", APP_REPORTER_SCRIPT)(window, parent, document); + + listeners.get("error")({ message: "Uncaught ", error: { name: "SecurityError", message: "Denied" } }); + listeners.get("unhandledrejection")({ reason: "x".repeat(600) }); + listeners.get("securitypolicyviolation")({ effectiveDirective: "connect-src", blockedURI: "https://api.example" }); + + expect(parent.postMessage.mock.calls.map(([message]) => message)).toEqual([ + { type: "ems-fragment-app-report-v1", kind: "loaded", blockedInlineScripts: 2 }, + { type: "ems-fragment-app-report-v1", kind: "error", name: "SecurityError", message: "Denied" }, + { type: "ems-fragment-app-report-v1", kind: "error", name: "Error", message: "x".repeat(500) }, + { type: "ems-fragment-app-report-v1", kind: "csp", directive: "connect-src", blocked: "https://api.example" }, + ]); }); it("Should insert the reporter and its config as the first elements of ", () => { diff --git a/tests/commands/sdk/preview-proxy.spec.ts b/tests/commands/sdk/preview-proxy.spec.ts index 9bc0056f..a28a9538 100644 --- a/tests/commands/sdk/preview-proxy.spec.ts +++ b/tests/commands/sdk/preview-proxy.spec.ts @@ -5,6 +5,7 @@ import * as net from "node:net"; import { Duplex } from "node:stream"; import { loadOrCreatePreviewCertificate, PreviewCertificate } from "../../../src/commands/sdk/preview-certificate"; import { APP_REPORTER_HASH, injectAppReporter } from "../../../src/commands/sdk/preview-app-reporter"; +import { allowInlineScripts } from "../../../src/commands/sdk/preview-inline-scripts"; import { LOCAL_PREVIEW_HEADERS } from "../../../src/commands/sdk/preview-headers"; import { PreviewProxy, PreviewProxyOptions, startPreviewProxy } from "../../../src/commands/sdk/preview-proxy"; import { loggingTestTransport } from "../../jest.setup"; @@ -144,14 +145,17 @@ describe("Preview proxy", () => { const response = await send(proxy, "/"); - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); + expect(response.headers["content-security-policy"]) + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); expect(response.headers["content-security-policy-report-only"]).toBeUndefined(); expect(response.headers["x-frame-options"]).toBeUndefined(); expect(response.headers["access-control-allow-origin"]).toBe("*"); expect(response.headers["content-type"]).toBe("text/html"); for (const [name, value] of Object.entries(LOCAL_PREVIEW_HEADERS)) { expect(headerCount(response.rawHeaders, name)).toBe(1); - expect(response.headers[name]).toBe(value); + if (name !== "content-security-policy") { + expect(response.headers[name]).toBe(value); + } } expect(response.body).toBe(injectAppReporter("", 0)); }); @@ -329,7 +333,9 @@ describe("Preview proxy", () => { const second = await send(proxy, "/index.html"); for (const response of [first, second]) { - expect(response.headers["content-security-policy"]).toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); + expect(response.headers["content-security-policy"]) + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); + expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH]); expect(response.body).toBe(injectAppReporter(reactPage, 1)); expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(reactPage, 1)))); } @@ -350,7 +356,7 @@ describe("Preview proxy", () => { for (const response of [first, second]) { expect(scriptSrc(response)).toEqual(["'self'", APP_REPORTER_HASH, sha256(inline)]); expect((response.headers["content-security-policy"] as string).replace(` ${sha256(inline)}`, "")) - .toBe(LOCAL_PREVIEW_HEADERS["content-security-policy"]); + .toBe(allowInlineScripts(LOCAL_PREVIEW_HEADERS["content-security-policy"], [APP_REPORTER_HASH])); expect(response.body).toBe(injectAppReporter(html, 0)); expect(response.headers["content-length"]).toBe(String(Buffer.byteLength(injectAppReporter(html, 0)))); expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); From 906dffc961686c023a9242f53f23bfb16b20b001 Mon Sep 17 00:00:00 2001 From: Philipp Koytek Date: Wed, 11 Nov 2026 15:56:03 +0100 Subject: [PATCH 3/3] DISCO-282: Document the app reporter of the local preview Co-Authored-By: Claude Includes-AI-Code: true --- docs/user-guide/sdk-commands.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user-guide/sdk-commands.md b/docs/user-guide/sdk-commands.md index 0e38ac2e..ad0514e6 100644 --- a/docs/user-guide/sdk-commands.md +++ b/docs/user-guide/sdk-commands.md @@ -31,6 +31,8 @@ The command does not need a profile. It serves your dev server over HTTPS with a Published apps block inline scripts, and so does the Local preview by default. Some dev servers inject one, such as React's Fast Refresh preamble, and the app stays blank without it. Pass `--allow-inline-scripts` to allow them: the command adds a hash of each inline script to the page's `script-src`. Either way it prints a warning for each inline script the first time it sees it. Move any inline script of your own into a separate file before publishing. +The command also adds a small Celonis script as the first element of each HTML page, allowed by its hash. It reports to the Celonis preview that the page loaded, how many inline scripts the preview blocks, and which errors stopped the app, so the Local preview can show what to fix. + ## Print Token Print a fresh token that only grants read access to the Celonis SDK, e.g. to authenticate against the npm registry.