Skip to content

Commit 8668c99

Browse files
committed
Adding code to support exection of dynamic scripts within the API,
1 parent 8964bb4 commit 8668c99

File tree

4 files changed

+288
-2
lines changed

4 files changed

+288
-2
lines changed

src/ui/widgets/EmbeddedDisplay/bobParser.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ describe("bob widget parser", (): void => {
8181
<scripts>
8282
<script file="EmbeddedJs">
8383
<text>
84-
/* Embedded javascript */ importClass(org.csstudio.display.builder.runtime.script.PVUtil);
84+
/* Embedded javascript */
85+
importClass(org.csstudio.display.builder.runtime.script.PVUtil);
8586
importClass(org.csstudio.display.builder.runtime.script.ScriptUtil);
8687
importPackage(Packages.org.csstudio.opibuilder.scriptUtil);
8788
logger = ScriptUtil.getLogger();
@@ -127,7 +128,8 @@ describe("bob widget parser", (): void => {
127128
}
128129
],
129130
text: `
130-
/* Embedded javascript */ importClass(org.csstudio.display.builder.runtime.script.PVUtil);
131+
/* Embedded javascript */
132+
importClass(org.csstudio.display.builder.runtime.script.PVUtil);
131133
importClass(org.csstudio.display.builder.runtime.script.ScriptUtil);
132134
importPackage(Packages.org.csstudio.opibuilder.scriptUtil);
133135
logger = ScriptUtil.getLogger();
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { iFrameScriptExecutionHandlerCode } from "./scriptExecutor";
3+
4+
describe("iFrameScriptExecutionHandlerCode", () => {
5+
let iframe: HTMLIFrameElement;
6+
let messageEventListener: (event: MessageEvent) => void;
7+
8+
beforeEach(() => {
9+
// Create a spy on window.addEventListener to capture the message event handler
10+
vi.spyOn(window, "addEventListener").mockImplementation(
11+
(event, handler) => {
12+
if (event === "message") {
13+
messageEventListener = handler as any;
14+
}
15+
}
16+
);
17+
18+
// Create a spy on window.postMessage
19+
vi.spyOn(window, "postMessage").mockImplementation(() => {});
20+
21+
// Build the iframe
22+
iframe = document.createElement("iframe");
23+
document.body.appendChild(iframe);
24+
25+
if (iframe.contentDocument) {
26+
iframe.contentDocument.open();
27+
iframe.contentDocument.write(iFrameScriptExecutionHandlerCode);
28+
iframe.contentDocument.close();
29+
}
30+
});
31+
32+
afterEach(() => {
33+
if (iframe && iframe.parentNode) {
34+
iframe.parentNode.removeChild(iframe);
35+
}
36+
vi.restoreAllMocks();
37+
});
38+
39+
it("should execute simple dynamic code and return results via postMessage", async () => {
40+
const postMessageMock = vi.fn();
41+
42+
expect(iframe.contentWindow).not.toBeNull();
43+
if (iframe.contentWindow) {
44+
// Override the postMessage method on the iframe's parent
45+
Object.defineProperty(iframe.contentWindow, "parent", {
46+
value: {
47+
postMessage: postMessageMock
48+
},
49+
configurable: true
50+
});
51+
52+
const messageData = {
53+
functionCode: "return 40 + 2;",
54+
widget: {},
55+
PVs: []
56+
};
57+
58+
const messageEvent = new MessageEvent("message", {
59+
data: messageData,
60+
source: null
61+
});
62+
63+
// Send the code to the iframe via a message
64+
iframe.contentWindow.dispatchEvent(messageEvent);
65+
66+
// Short wait for async execution to complete
67+
await new Promise(resolve => setTimeout(resolve, 1000));
68+
69+
// Validate that the dynamic script executed as expected
70+
expect(postMessageMock).toHaveBeenCalledWith(42, "*");
71+
}
72+
});
73+
74+
it("should execute code containing some Phoebus built ins and return results via postMessage", async () => {
75+
const postMessageMock = vi.fn();
76+
77+
expect(iframe.contentWindow).not.toBeNull();
78+
if (iframe.contentWindow) {
79+
// Override the postMessage method on the iframe's parent
80+
Object.defineProperty(iframe.contentWindow, "parent", {
81+
value: {
82+
postMessage: postMessageMock
83+
},
84+
configurable: true
85+
});
86+
87+
const script = `
88+
importClass(org.csstudio.display.builder.runtime.script.PVUtil);
89+
importClass(org.csstudio.display.builder.runtime.script.ScriptUtil);
90+
importPackage(Packages.org.csstudio.opibuilder.scriptUtil);
91+
92+
// widget.setPropertyValue("background_color", ColorFontUtil.getColorFromRGB(255, 255, 0));
93+
94+
return PVUtil.getDouble(1+2);
95+
`;
96+
97+
const messageData = {
98+
functionCode: script,
99+
widget: {},
100+
PVs: []
101+
};
102+
103+
const messageEvent = new MessageEvent("message", {
104+
data: messageData,
105+
source: null
106+
});
107+
108+
// Send the code to the iframe via a message
109+
iframe.contentWindow.dispatchEvent(messageEvent);
110+
111+
// Short wait for async execution to complete
112+
await new Promise(resolve => setTimeout(resolve, 1000));
113+
114+
// Validate that the dynamic script executed as expected
115+
expect(postMessageMock).toHaveBeenCalledWith(3, "*");
116+
}
117+
});
118+
119+
it("should handle exception in the dynamic code and send error message via postMessage", async () => {
120+
const postMessageMock = vi.fn();
121+
122+
expect(iframe.contentWindow).not.toBeNull();
123+
if (iframe.contentWindow) {
124+
// Override the postMessage method on the iframe's parent
125+
Object.defineProperty(iframe.contentWindow, "parent", {
126+
value: {
127+
postMessage: postMessageMock
128+
},
129+
configurable: true
130+
});
131+
132+
const messageData = {
133+
functionCode: 'throw new Error("Test error");',
134+
widget: {},
135+
PVs: []
136+
};
137+
138+
// Create a test message event with invalid code
139+
const messageEvent = new MessageEvent("message", {
140+
data: messageData,
141+
source: null
142+
});
143+
144+
// Dispatch the event to the iframe's contentWindow
145+
iframe.contentWindow.dispatchEvent(messageEvent);
146+
147+
// Wait for async operations
148+
await new Promise(resolve => setTimeout(resolve, 1000));
149+
150+
// Check if postMessage was called with the expected error message
151+
expect(postMessageMock).toHaveBeenCalledWith("Error: Test error", "*");
152+
}
153+
});
154+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
importClass(org.csstudio.display.builder.runtime.script.PVUtil);
3+
importClass(org.csstudio.display.builder.runtime.script.ScriptUtil);
4+
importPackage(Packages.org.csstudio.opibuilder.scriptUtil);
5+
logger = ScriptUtil.getLogger();
6+
logger.info("Hello")
7+
var value = PVUtil.getDouble(pvs[0]);
8+
9+
if (value > 299) {
10+
widget.setPropertyValue("background_color", ColorFontUtil.getColorFromRGB(255, 255, 0));
11+
} else {
12+
widget.setPropertyValue("background_color", ColorFontUtil.getColorFromRGB(128, 255, 255));
13+
}
14+
*/
15+
import log from "loglevel";
16+
17+
let iFrameScriptRunner: HTMLIFrameElement | null = null;
18+
19+
export const iFrameScriptExecutionHandlerCode = `
20+
<!DOCTYPE html>
21+
<html>
22+
<head>
23+
<script>
24+
const mockPhoebusApi = {
25+
importClass: (a) => {},
26+
importPackage: (a) => {},
27+
PVUtil: {
28+
getDouble: (value) => Number(value),
29+
},
30+
org: {
31+
csstudio: { display: { builder: { runtime: { script: { PVUtil: undefined, ScriptUtil: undefined } } } } }
32+
},
33+
Packages: {
34+
org: { csstudio: { opibuilder: { scriptUtil: undefined } } }
35+
}
36+
};
37+
38+
window.addEventListener('message', async (event) => {
39+
try {
40+
const widget = event?.data?.widget;
41+
const functionCode = event?.data?.functionCode;
42+
43+
const fn = new Function(...Object.keys(mockPhoebusApi), 'widget', functionCode);
44+
const result = await fn(...Object.values(mockPhoebusApi), widget);
45+
window.parent.postMessage(result, '*');
46+
} catch (error) {
47+
window.parent.postMessage("Error: " + error.message, '*');
48+
}
49+
});
50+
window.parent.postMessage('IFRAME_READY', '*');
51+
</script>
52+
</head>
53+
</html>
54+
`;
55+
56+
/***
57+
* A function that creates a sandboxed IFrame, in which to execute dynamic scripts.
58+
* On first execution it will set the singleton iFrameScriptRunner variable, and return iFrameScriptRunner
59+
* On subsequent execution it will return the same instance of iFrameScriptRunner
60+
* @returns An instance of HTMLIFrameElement.
61+
*/
62+
const buildIframe = async (): Promise<HTMLIFrameElement> => {
63+
return new Promise<HTMLIFrameElement>((resolve, reject) => {
64+
if (iFrameScriptRunner) {
65+
resolve(iFrameScriptRunner);
66+
return;
67+
}
68+
69+
iFrameScriptRunner = document.createElement("iframe");
70+
iFrameScriptRunner.setAttribute("sandbox", "allow-scripts");
71+
iFrameScriptRunner.style.display = "none";
72+
iFrameScriptRunner.id = "script-runner-iframe";
73+
74+
// This adds an event listen to recieve the IFRAME_READY message, that is sent by the iFrame when it is ready to run scripts.
75+
const onMessage = (event: MessageEvent) => {
76+
if (event.data === "IFRAME_READY") {
77+
log.debug(
78+
`The script runner iframe has started the following messeage was recievd: ${event.data}`
79+
);
80+
window.removeEventListener("message", onMessage);
81+
resolve(iFrameScriptRunner as HTMLIFrameElement);
82+
}
83+
};
84+
85+
window.addEventListener("message", onMessage);
86+
87+
iFrameScriptRunner.onload = () => {
88+
if (!iFrameScriptRunner) {
89+
return;
90+
}
91+
iFrameScriptRunner.srcdoc = iFrameScriptExecutionHandlerCode;
92+
};
93+
94+
document.body.appendChild(iFrameScriptRunner);
95+
96+
setTimeout(() => {
97+
window.removeEventListener("message", onMessage);
98+
reject(new Error("The creation of a script execution iframe timed out"));
99+
}, 5000);
100+
});
101+
};
102+
103+
export const runScript = async (code: string): Promise<any> => {
104+
if (!iFrameScriptRunner) {
105+
await buildIframe();
106+
}
107+
108+
if (!iFrameScriptRunner?.contentWindow) {
109+
throw new Error("Iframe content window not available");
110+
}
111+
112+
return new Promise<any>((resolve, reject) => {
113+
const messageHandler = (event: MessageEvent) => {
114+
if (event.source === iFrameScriptRunner?.contentWindow) {
115+
window.removeEventListener("message", messageHandler);
116+
resolve(event.data);
117+
}
118+
};
119+
120+
window.addEventListener("message", messageHandler);
121+
122+
setTimeout(() => {
123+
window.removeEventListener("message", messageHandler);
124+
reject(new Error("Script execution timed out"));
125+
}, 5000);
126+
127+
iFrameScriptRunner?.contentWindow?.postMessage(code, "*");
128+
});
129+
};

src/ui/widgets/EmbeddedDisplay/scripts/scriptParser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { opiParsePvName } from "../opiParser";
88
* @param jsonProp The json describing the array of script objects
99
* @param defaultProtocol The default protocol eg ca (channel access)
1010
* @param isOpiFile true if this is an OPI file, false otherwise.
11+
* @returns An array of Script objects that have been extracted from jsonProp
1112
*/
1213
export const scriptParser = (
1314
jsonProp: ElementCompact,

0 commit comments

Comments
 (0)