Skip to content

Commit d915d09

Browse files
authored
Merge pull request #138 from DiamondLightSource/simple-script-execution
Simple script execution
2 parents 4b8ef49 + d8f7c56 commit d915d09

File tree

7 files changed

+366
-19
lines changed

7 files changed

+366
-19
lines changed

package-lock.json

Lines changed: 10 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@rollup/plugin-node-resolve": "^15.3.0",
3939
"@rollup/plugin-typescript": "^11.1.6",
4040
"@testing-library/jest-dom": "^5.17.0",
41-
"@testing-library/react": "^16.0.1",
41+
"@testing-library/react": "^16.3.0",
4242
"@types/d3": "^7.4.3",
4343
"@types/jest": "^27.5.2",
4444
"@types/node": "^22.9.0",

src/types/position.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { CSSProperties } from "react";
22

33
export type Position = AbsolutePosition | RelativePosition;
4+
export type PositionPropNames =
5+
| "x"
6+
| "y"
7+
| "width"
8+
| "height"
9+
| "margin"
10+
| "padding"
11+
| "minWidth"
12+
| "maxWidth"
13+
| "minHeight";
414

515
function invalidSize(size?: string): boolean {
616
return size === "" || size === undefined;
@@ -67,6 +77,20 @@ export class AbsolutePosition {
6777
public toString(): string {
6878
return `AbsolutePosition (${this.x},${this.y},${this.width},${this.height})`;
6979
}
80+
81+
public clone(): AbsolutePosition {
82+
return new AbsolutePosition(
83+
this.x,
84+
this.y,
85+
this.width,
86+
this.height,
87+
this.margin,
88+
this.padding,
89+
this.minWidth,
90+
this.maxWidth,
91+
this.minHeight
92+
);
93+
}
7094
}
7195

7296
export class RelativePosition {
@@ -118,4 +142,18 @@ export class RelativePosition {
118142
public toString(): string {
119143
return `RelativePosition (${this.width},${this.height})`;
120144
}
145+
146+
public clone(): RelativePosition {
147+
return new RelativePosition(
148+
this.x,
149+
this.y,
150+
this.width,
151+
this.height,
152+
this.margin,
153+
this.padding,
154+
this.minWidth,
155+
this.maxWidth,
156+
this.minHeight
157+
);
158+
}
121159
}

src/ui/hooks/useScripts.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { renderHook } from "@testing-library/react";
3+
import { useScripts } from "./useScripts";
4+
import { useSubscription } from "./useSubscription";
5+
import { useSelector } from "react-redux";
6+
import { executeDynamicScriptInSandbox } from "../widgets/EmbeddedDisplay/scripts/scriptExecutor";
7+
import { PV } from "../../types/pv";
8+
import { Script } from "../../types/props";
9+
10+
vi.mock("./useSubscription");
11+
vi.mock("react-redux");
12+
vi.mock("../widgets/EmbeddedDisplay/scripts/scriptExecutor");
13+
14+
describe("useScripts", () => {
15+
const mockCallback = vi.fn();
16+
const mockWidgetId = "test-widget-id";
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
21+
(useSelector as ReturnType<typeof vi.fn>).mockImplementation(selector => {
22+
return {
23+
"ca://pv1": [
24+
{
25+
value: {
26+
getDoubleValue: () => 10,
27+
getStringValue: () => "10",
28+
getArrayValue: () => [10],
29+
toString: () => "10"
30+
}
31+
}
32+
],
33+
"ca://pv2": [
34+
{
35+
value: {
36+
getDoubleValue: () => null,
37+
getStringValue: () => null,
38+
getArrayValue: () => null,
39+
toString: () => "test"
40+
}
41+
}
42+
],
43+
"ca://pv3": [{ value: null }]
44+
};
45+
});
46+
47+
(
48+
executeDynamicScriptInSandbox as ReturnType<typeof vi.fn>
49+
).mockResolvedValue({
50+
success: true,
51+
result: "test result"
52+
});
53+
});
54+
55+
afterEach(() => {
56+
vi.resetAllMocks();
57+
});
58+
59+
it("should subscribe to all PVs from scripts", () => {
60+
const scripts = [
61+
{
62+
text: "return pvs[0] + pvs[1]",
63+
pvs: [
64+
{ pvName: new PV("pv1"), trigger: true },
65+
{ pvName: new PV("pv2"), trigger: true }
66+
]
67+
} as Partial<Script> as Script
68+
];
69+
70+
renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));
71+
72+
expect(useSubscription).toHaveBeenCalledWith(
73+
mockWidgetId,
74+
["ca://pv1", "ca://pv2"],
75+
[
76+
{ string: true, double: true },
77+
{ string: true, double: true }
78+
]
79+
);
80+
});
81+
82+
it("should execute scripts with PV values", async () => {
83+
const scripts = [
84+
{
85+
text: "return pvs[0] + pvs[1]",
86+
pvs: [{ pvName: new PV("pv1") }, { pvName: new PV("pv2") }]
87+
} as Partial<Script> as Script
88+
];
89+
90+
renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));
91+
92+
vi.useFakeTimers();
93+
await vi.runAllTimersAsync();
94+
95+
expect(executeDynamicScriptInSandbox).toHaveBeenCalledWith(
96+
"return pvs[0] + pvs[1]",
97+
[10, null]
98+
);
99+
expect(mockCallback).toHaveBeenCalledWith({
100+
success: true,
101+
result: "test result"
102+
});
103+
});
104+
105+
it("should handle undefined scripts prop", () => {
106+
// @ts-expect-error Testing undefined input
107+
// eslint-disable-next-line
108+
renderHook(() => useScripts(undefined, mockWidgetId, mockCallback));
109+
110+
expect(useSubscription).toHaveBeenCalledWith(mockWidgetId, [], []);
111+
expect(executeDynamicScriptInSandbox).not.toHaveBeenCalled();
112+
});
113+
114+
it("should handle PVs with null values", async () => {
115+
const scripts = [
116+
{
117+
text: "return pvs[0]",
118+
pvs: [{ pvName: new PV("pv3") }]
119+
} as Partial<Script> as Script
120+
];
121+
122+
renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));
123+
124+
vi.useFakeTimers();
125+
await vi.runAllTimersAsync();
126+
127+
expect(executeDynamicScriptInSandbox).toHaveBeenCalledWith(
128+
"return pvs[0]",
129+
[undefined]
130+
);
131+
});
132+
133+
it("should handle multiple scripts", async () => {
134+
const scripts = [
135+
{
136+
text: "return pvs[0]",
137+
pvs: [{ pvName: new PV("pv1") }]
138+
} as Partial<Script> as Script,
139+
{
140+
text: "return pvs[0]",
141+
pvs: [{ pvName: new PV("pv2") }]
142+
} as Partial<Script> as Script
143+
];
144+
145+
renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));
146+
147+
vi.useFakeTimers();
148+
await vi.runAllTimersAsync();
149+
150+
expect(executeDynamicScriptInSandbox).toHaveBeenCalledTimes(2);
151+
expect(executeDynamicScriptInSandbox).toHaveBeenNthCalledWith(
152+
1,
153+
"return pvs[0]",
154+
[10]
155+
);
156+
expect(executeDynamicScriptInSandbox).toHaveBeenNthCalledWith(
157+
2,
158+
"return pvs[0]",
159+
[null]
160+
);
161+
});
162+
});

src/ui/hooks/useScripts.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import log from "loglevel";
2+
3+
import { useSubscription } from "./useSubscription";
4+
import { useSelector } from "react-redux";
5+
import { CsState } from "../../redux/csState";
6+
7+
import { PvArrayResults, pvStateSelector, pvStateComparator } from "./utils";
8+
import { DType } from "../../types/dtypes";
9+
import { SubscriptionType } from "../../connection/plugin";
10+
import {
11+
executeDynamicScriptInSandbox,
12+
ScriptResponse
13+
} from "../widgets/EmbeddedDisplay/scripts/scriptExecutor";
14+
import { Script } from "../../types/props";
15+
16+
export const useScripts = (
17+
scriptsProp: Script[],
18+
widgetId: string,
19+
callback: (scriptResponse: ScriptResponse) => void
20+
) => {
21+
const scripts = scriptsProp ?? [];
22+
const allPvs: string[] = [];
23+
const allTypes: SubscriptionType[] = [];
24+
25+
for (const script of scripts) {
26+
for (const pvMetadata of script.pvs) {
27+
allPvs.push(pvMetadata.pvName.qualifiedName());
28+
allTypes.push({ string: true, double: true });
29+
}
30+
}
31+
32+
// Subscribe to all PVs.
33+
useSubscription(widgetId, allPvs, allTypes);
34+
35+
// Get results from all PVs.
36+
const pvDataMap = useSelector(
37+
(state: CsState): PvArrayResults => pvStateSelector(allPvs, state),
38+
pvStateComparator
39+
);
40+
41+
for (const script of scripts) {
42+
const { pvs: pvMetadataList } = script;
43+
44+
// Build array of pv values
45+
const pvValues: (number | string | undefined)[] = [];
46+
for (const pvMetadata of pvMetadataList) {
47+
const pvDatum = pvDataMap[pvMetadata.pvName.qualifiedName()][0];
48+
49+
let value = undefined;
50+
51+
if (pvDatum?.value) {
52+
const doubleValue = pvDatum.value.getDoubleValue();
53+
const stringValue = DType.coerceString(pvDatum.value);
54+
value = doubleValue ?? stringValue;
55+
}
56+
57+
pvValues.push(value);
58+
}
59+
60+
log.debug(`Executing script:\n ${script.text}`);
61+
log.debug(`PV values ${pvValues}`);
62+
63+
executeDynamicScriptInSandbox(script.text, pvValues)
64+
.then(result => {
65+
log.debug(`Script completed execution`);
66+
log.debug(result);
67+
callback(result);
68+
})
69+
.catch(reason => {
70+
log.warn(reason);
71+
});
72+
}
73+
};

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { v4 as uuidv4 } from "uuid";
33

44
let iFrameSandboxScriptRunner: HTMLIFrameElement | null = null;
55

6+
export interface ScriptResponse {
7+
functionReturnValue: any;
8+
widgetProps: {
9+
[key: string]: any;
10+
};
11+
}
12+
613
// Define the IFrame HTML and javascript to handle execution of dynamic scripts.
714
// It also mocks/implements a small subset of the Phoebus script API sufficient for our PoC cases.
815
export const iFrameScriptExecutionHandlerCode = `
@@ -106,7 +113,7 @@ const buildSandboxIframe = async (): Promise<HTMLIFrameElement> => {
106113
setTimeout(() => {
107114
window.removeEventListener("message", onMessage);
108115
reject(new Error("The creation of a script execution iframe timed out"));
109-
}, 5000);
116+
}, 1000);
110117
});
111118
};
112119

@@ -121,10 +128,7 @@ const buildSandboxIframe = async (): Promise<HTMLIFrameElement> => {
121128
export const executeDynamicScriptInSandbox = async (
122129
dynamicScriptCode: string,
123130
pvs: any[]
124-
): Promise<{
125-
functionReturnValue: any;
126-
widgetProps: { [key: string]: any };
127-
}> => {
131+
): Promise<ScriptResponse> => {
128132
if (!iFrameSandboxScriptRunner) {
129133
await buildSandboxIframe();
130134
}
@@ -160,8 +164,8 @@ export const executeDynamicScriptInSandbox = async (
160164

161165
setTimeout(() => {
162166
window.removeEventListener("message", messageHandler);
163-
reject(new Error("Script execution timed out"));
164-
}, 5000);
167+
reject(new Error("Dynamic script execution timed out"));
168+
}, 1000);
165169

166170
// Send a message containing the script and pv values to the IFrame to trigger the execution of the script.
167171
iFrameSandboxScriptRunner?.contentWindow?.postMessage(

0 commit comments

Comments
 (0)