diff --git a/products/canvas/backend/tests/test_cloud_builder.py b/products/canvas/backend/tests/test_cloud_builder.py index 2337bc96dc5f..3691dab3d0c1 100644 --- a/products/canvas/backend/tests/test_cloud_builder.py +++ b/products/canvas/backend/tests/test_cloud_builder.py @@ -1,6 +1,7 @@ import os import hashlib import tempfile +import subprocess from pathlib import Path from typing import Any @@ -10,7 +11,7 @@ from parameterized import parameterized -from products.canvas.backend.build_service import run_cloud_builder, validate_builder_output +from products.canvas.backend.build_service import node_executable, run_cloud_builder, validate_builder_output from products.canvas.backend.presentation.serializers import CanvasSourceProjectSerializer from products.canvas.backend.source import synthetic_source_project, validate_source_project @@ -105,6 +106,70 @@ def test_runtime_bounds_host_side_effects(self) -> None: self.assertIn('url.hostname.endsWith(".posthog.com")', runtime) self.assertIn("serialized.length>16384", runtime) + def test_runtime_applies_the_host_theme(self) -> None: + result = run_cloud_builder(self._project('document.body.textContent = "Hello"')) + + runtime = next(file["content"] for file in result["files"] if file["path"] == "assets/canvas-runtime.js") + harness = """ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const classes = new Set(); +const toggles = []; +const style = {}; +const listeners = { message: [] }; +globalThis.window = globalThis; +globalThis.parent = {}; +globalThis.location = { hash: "#theme=dark" }; +globalThis.document = { + readyState: "complete", + documentElement: { + classList: { + toggle: (name, force) => { + toggles.push([name, force]); + force ? classes.add(name) : classes.delete(name); + }, + }, + style, + }, +}; +globalThis.addEventListener = (type, handler) => (listeners[type] ??= []).push(handler); + +new Function(readFileSync(new URL("./runtime.js", import.meta.url), "utf8"))(); + +assert.ok(classes.has("dark"), "the theme fragment did not add the dark class before connect"); +assert.equal(style.colorScheme, "dark"); + +const bridge = new MessageChannel(); +for (const handler of listeners.message) { + handler({ source: globalThis.parent, data: { channel: "posthog-canvas", type: "connect" }, ports: [bridge.port2] }); +} +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme", theme: "solarized" }); +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme" }); +bridge.port1.postMessage({ channel: "posthog-canvas", type: "set-theme", theme: "light" }); +const deadline = Date.now() + 5000; +while (classes.has("dark") && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +assert.ok(!classes.has("dark"), "the set-theme frame did not remove the dark class"); +assert.equal(style.colorScheme, "light"); +// Port delivery is ordered, so by the light flip the invalid frames were +// already processed — exactly two toggles proves they were ignored, not +// coerced to light. +assert.deepEqual(toggles, [["dark", true], ["dark", false]]); +bridge.port1.close(); +""" + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / "runtime.js").write_text(runtime) + (Path(directory) / "harness.mjs").write_text(harness) + process = subprocess.run( + [node_executable(), str(Path(directory) / "harness.mjs")], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(process.returncode, 0, process.stderr) + def test_freezes_declared_capabilities_into_manifest(self) -> None: project = self._project('document.body.textContent = "Hello"') project["capabilities"] = { diff --git a/products/canvas/packages/canvas_builder/build.mjs b/products/canvas/packages/canvas_builder/build.mjs index 04f06d4e630e..4a2665d9b871 100644 --- a/products/canvas/packages/canvas_builder/build.mjs +++ b/products/canvas/packages/canvas_builder/build.mjs @@ -23,7 +23,7 @@ const htmlAttribute = /([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g const forbiddenHtml = /(?:src|href)\s*=\s*["']\s*(javascript|data:text\/html|vbscript)/i const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.css', '.json', '.svg', '.txt'] const runtimePath = 'assets/canvas-runtime.js' -const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0,port;const post=(message)=>port?.postMessage({channel,...message});const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);const timer=setTimeout(()=>{pending.delete(id);reject(new Error("Canvas request timed out"));},30000);pending.set(id,{resolve,reject,timer});post({type:"data-request",id,method,payload});});const receive=(event)=>{if(event.data?.channel!==channel||event.data?.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);clearTimeout(request.timer);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));};const capture=(event,properties,distinctId)=>{const normalized=properties??{};let serialized;try{serialized=JSON.stringify(normalized)}catch{throw new Error("Canvas capture properties must be serializable")};if(typeof serialized!=="string"||serialized.length>16384)throw new Error("Canvas capture properties are too large");return call("capture",{event,properties:normalized,distinctId})};const openExternal=(value)=>{const url=new URL(value);if(url.protocol!=="https:"||!(url.hostname==="posthog.com"||url.hostname.endsWith(".posthog.com")))throw new Error("Canvas external URL is not allowed");post({type:"open-external",url:url.href})};window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture,openExternal};addEventListener("message",(event)=>{if(port||event.source!==parent||event.data?.channel!==channel||event.data?.type!=="connect"||!event.ports[0])return;port=event.ports[0];port.addEventListener("message",receive);port.start();if(document.readyState!=="loading")post({type:"ready"});if(document.readyState==="complete")post({type:"rendered"});});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();` +const runtime = `(()=>{const channel="posthog-canvas",pending=new Map;let sequence=0,port;const post=(message)=>port?.postMessage({channel,...message});const call=(method,payload)=>new Promise((resolve,reject)=>{const id=String(++sequence);const timer=setTimeout(()=>{pending.delete(id);reject(new Error("Canvas request timed out"));},30000);pending.set(id,{resolve,reject,timer});post({type:"data-request",id,method,payload});});const applyTheme=(theme)=>{if(theme!=="dark"&&theme!=="light")return;const dark=theme==="dark";document.documentElement.classList.toggle("dark",dark);document.documentElement.style.colorScheme=dark?"dark":"light";};applyTheme(new URLSearchParams(location.hash.slice(1)).get("theme"));const receive=(event)=>{if(event.data?.channel!==channel)return;if(event.data.type==="set-theme"){applyTheme(event.data.theme);return}if(event.data.type!=="data-response")return;const request=pending.get(event.data.id);if(!request)return;pending.delete(event.data.id);clearTimeout(request.timer);event.data.ok?request.resolve(event.data.result):request.reject(new Error(event.data.error??"Canvas request failed"));};const capture=(event,properties,distinctId)=>{const normalized=properties??{};let serialized;try{serialized=JSON.stringify(normalized)}catch{throw new Error("Canvas capture properties must be serializable")};if(typeof serialized!=="string"||serialized.length>16384)throw new Error("Canvas capture properties are too large");return call("capture",{event,properties:normalized,distinctId})};const openExternal=(value)=>{const url=new URL(value);if(url.protocol!=="https:"||!(url.hostname==="posthog.com"||url.hostname.endsWith(".posthog.com")))throw new Error("Canvas external URL is not allowed");post({type:"open-external",url:url.href})};window.ph={loadInsight:(shortId,options)=>call("loadInsight",{shortId,dateRange:options?.dateRange}),query:(query,params)=>call("query",typeof query==="string"?{hogql:query,params:params??{}}:{query,params:params??{}}),capture,openExternal};addEventListener("message",(event)=>{if(port||event.source!==parent||event.data?.channel!==channel||event.data?.type!=="connect"||!event.ports[0])return;port=event.ports[0];port.addEventListener("message",receive);port.start();if(document.readyState!=="loading")post({type:"ready"});if(document.readyState==="complete")post({type:"rendered"});});addEventListener("error",(event)=>post({type:"error",message:event.message||"Canvas runtime error",stack:event.error?.stack}));addEventListener("unhandledrejection",(event)=>post({type:"error",message:event.reason instanceof Error?event.reason.message:String(event.reason),stack:event.reason instanceof Error?event.reason.stack:undefined}));addEventListener("DOMContentLoaded",()=>post({type:"ready"}));addEventListener("load",()=>post({type:"rendered"}));})();` const platformStylesheet = ` @import "tailwindcss"; @import "@posthog/quill/tokens.css";