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
19 changes: 10 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^11.1.6",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.3.0",
"@types/d3": "^7.4.3",
"@types/jest": "^27.5.2",
"@types/node": "^22.9.0",
Expand Down
38 changes: 38 additions & 0 deletions src/types/position.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { CSSProperties } from "react";

export type Position = AbsolutePosition | RelativePosition;
export type PositionPropNames =
| "x"
| "y"
| "width"
| "height"
| "margin"
| "padding"
| "minWidth"
| "maxWidth"
| "minHeight";

function invalidSize(size?: string): boolean {
return size === "" || size === undefined;
Expand Down Expand Up @@ -67,6 +77,20 @@ export class AbsolutePosition {
public toString(): string {
return `AbsolutePosition (${this.x},${this.y},${this.width},${this.height})`;
}

public clone(): AbsolutePosition {
return new AbsolutePosition(
this.x,
this.y,
this.width,
this.height,
this.margin,
this.padding,
this.minWidth,
this.maxWidth,
this.minHeight
);
}
}

export class RelativePosition {
Expand Down Expand Up @@ -118,4 +142,18 @@ export class RelativePosition {
public toString(): string {
return `RelativePosition (${this.width},${this.height})`;
}

public clone(): RelativePosition {
return new RelativePosition(
this.x,
this.y,
this.width,
this.height,
this.margin,
this.padding,
this.minWidth,
this.maxWidth,
this.minHeight
);
}
}
162 changes: 162 additions & 0 deletions src/ui/hooks/useScripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useScripts } from "./useScripts";
import { useSubscription } from "./useSubscription";
import { useSelector } from "react-redux";
import { executeDynamicScriptInSandbox } from "../widgets/EmbeddedDisplay/scripts/scriptExecutor";
import { PV } from "../../types/pv";
import { Script } from "../../types/props";

vi.mock("./useSubscription");
vi.mock("react-redux");
vi.mock("../widgets/EmbeddedDisplay/scripts/scriptExecutor");

describe("useScripts", () => {
const mockCallback = vi.fn();
const mockWidgetId = "test-widget-id";

beforeEach(() => {
vi.clearAllMocks();

(useSelector as ReturnType<typeof vi.fn>).mockImplementation(selector => {
return {
"ca://pv1": [
{
value: {
getDoubleValue: () => 10,
getStringValue: () => "10",
getArrayValue: () => [10],
toString: () => "10"
}
}
],
"ca://pv2": [
{
value: {
getDoubleValue: () => null,
getStringValue: () => null,
getArrayValue: () => null,
toString: () => "test"
}
}
],
"ca://pv3": [{ value: null }]
};
});

(
executeDynamicScriptInSandbox as ReturnType<typeof vi.fn>
).mockResolvedValue({
success: true,
result: "test result"
});
});

afterEach(() => {
vi.resetAllMocks();
});

it("should subscribe to all PVs from scripts", () => {
const scripts = [
{
text: "return pvs[0] + pvs[1]",
pvs: [
{ pvName: new PV("pv1"), trigger: true },
{ pvName: new PV("pv2"), trigger: true }
]
} as Partial<Script> as Script
];

renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));

expect(useSubscription).toHaveBeenCalledWith(
mockWidgetId,
["ca://pv1", "ca://pv2"],
[
{ string: true, double: true },
{ string: true, double: true }
]
);
});

it("should execute scripts with PV values", async () => {
const scripts = [
{
text: "return pvs[0] + pvs[1]",
pvs: [{ pvName: new PV("pv1") }, { pvName: new PV("pv2") }]
} as Partial<Script> as Script
];

renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));

vi.useFakeTimers();
await vi.runAllTimersAsync();

expect(executeDynamicScriptInSandbox).toHaveBeenCalledWith(
"return pvs[0] + pvs[1]",
[10, null]
);
expect(mockCallback).toHaveBeenCalledWith({
success: true,
result: "test result"
});
});

it("should handle undefined scripts prop", () => {
// @ts-expect-error Testing undefined input
// eslint-disable-next-line
renderHook(() => useScripts(undefined, mockWidgetId, mockCallback));

expect(useSubscription).toHaveBeenCalledWith(mockWidgetId, [], []);
expect(executeDynamicScriptInSandbox).not.toHaveBeenCalled();
});

it("should handle PVs with null values", async () => {
const scripts = [
{
text: "return pvs[0]",
pvs: [{ pvName: new PV("pv3") }]
} as Partial<Script> as Script
];

renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));

vi.useFakeTimers();
await vi.runAllTimersAsync();

expect(executeDynamicScriptInSandbox).toHaveBeenCalledWith(
"return pvs[0]",
[undefined]
);
});

it("should handle multiple scripts", async () => {
const scripts = [
{
text: "return pvs[0]",
pvs: [{ pvName: new PV("pv1") }]
} as Partial<Script> as Script,
{
text: "return pvs[0]",
pvs: [{ pvName: new PV("pv2") }]
} as Partial<Script> as Script
];

renderHook(() => useScripts(scripts, mockWidgetId, mockCallback));

vi.useFakeTimers();
await vi.runAllTimersAsync();

expect(executeDynamicScriptInSandbox).toHaveBeenCalledTimes(2);
expect(executeDynamicScriptInSandbox).toHaveBeenNthCalledWith(
1,
"return pvs[0]",
[10]
);
expect(executeDynamicScriptInSandbox).toHaveBeenNthCalledWith(
2,
"return pvs[0]",
[null]
);
});
});
73 changes: 73 additions & 0 deletions src/ui/hooks/useScripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import log from "loglevel";

import { useSubscription } from "./useSubscription";
import { useSelector } from "react-redux";
import { CsState } from "../../redux/csState";

import { PvArrayResults, pvStateSelector, pvStateComparator } from "./utils";
import { DType } from "../../types/dtypes";
import { SubscriptionType } from "../../connection/plugin";
import {
executeDynamicScriptInSandbox,
ScriptResponse
} from "../widgets/EmbeddedDisplay/scripts/scriptExecutor";
import { Script } from "../../types/props";

export const useScripts = (
scriptsProp: Script[],
widgetId: string,
callback: (scriptResponse: ScriptResponse) => void
) => {
const scripts = scriptsProp ?? [];
const allPvs: string[] = [];
const allTypes: SubscriptionType[] = [];

for (const script of scripts) {
for (const pvMetadata of script.pvs) {
allPvs.push(pvMetadata.pvName.qualifiedName());
allTypes.push({ string: true, double: true });
}
}

// Subscribe to all PVs.
useSubscription(widgetId, allPvs, allTypes);

// Get results from all PVs.
const pvDataMap = useSelector(
(state: CsState): PvArrayResults => pvStateSelector(allPvs, state),
pvStateComparator
);

for (const script of scripts) {
const { pvs: pvMetadataList } = script;

// Build array of pv values
const pvValues: (number | string | undefined)[] = [];
for (const pvMetadata of pvMetadataList) {
const pvDatum = pvDataMap[pvMetadata.pvName.qualifiedName()][0];

let value = undefined;

if (pvDatum?.value) {
const doubleValue = pvDatum.value.getDoubleValue();
const stringValue = DType.coerceString(pvDatum.value);
value = doubleValue ?? stringValue;
}

pvValues.push(value);
}

log.debug(`Executing script:\n ${script.text}`);
log.debug(`PV values ${pvValues}`);

executeDynamicScriptInSandbox(script.text, pvValues)
.then(result => {
log.debug(`Script completed execution`);
log.debug(result);
callback(result);
})
.catch(reason => {
log.warn(reason);
});
}
};
18 changes: 11 additions & 7 deletions src/ui/widgets/EmbeddedDisplay/scripts/scriptExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { v4 as uuidv4 } from "uuid";

let iFrameSandboxScriptRunner: HTMLIFrameElement | null = null;

export interface ScriptResponse {
functionReturnValue: any;
widgetProps: {
[key: string]: any;
};
}

// Define the IFrame HTML and javascript to handle execution of dynamic scripts.
// It also mocks/implements a small subset of the Phoebus script API sufficient for our PoC cases.
export const iFrameScriptExecutionHandlerCode = `
Expand Down Expand Up @@ -106,7 +113,7 @@ const buildSandboxIframe = async (): Promise<HTMLIFrameElement> => {
setTimeout(() => {
window.removeEventListener("message", onMessage);
reject(new Error("The creation of a script execution iframe timed out"));
}, 5000);
}, 1000);
});
};

Expand All @@ -121,10 +128,7 @@ const buildSandboxIframe = async (): Promise<HTMLIFrameElement> => {
export const executeDynamicScriptInSandbox = async (
dynamicScriptCode: string,
pvs: any[]
): Promise<{
functionReturnValue: any;
widgetProps: { [key: string]: any };
}> => {
): Promise<ScriptResponse> => {
if (!iFrameSandboxScriptRunner) {
await buildSandboxIframe();
}
Expand Down Expand Up @@ -160,8 +164,8 @@ export const executeDynamicScriptInSandbox = async (

setTimeout(() => {
window.removeEventListener("message", messageHandler);
reject(new Error("Script execution timed out"));
}, 5000);
reject(new Error("Dynamic script execution timed out"));
}, 1000);

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