Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion products/canvas/backend/tests/test_cloud_builder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import hashlib
import tempfile
import subprocess
from pathlib import Path
from typing import Any

Expand All @@ -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

Expand Down Expand Up @@ -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"] = {
Expand Down
2 changes: 1 addition & 1 deletion products/canvas/packages/canvas_builder/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading