Skip to content

Commit af7cab5

Browse files
authored
[WC-3452] Pusher module refine (#2323)
2 parents be86496 + 2b63b4b commit af7cab5

10 files changed

Lines changed: 366 additions & 69 deletions

File tree

automation/utils/src/steps.ts

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFile, writeFile } from "node:fs/promises";
22
import { dirname, join, parse, relative, resolve } from "path";
3+
import chalk from "chalk";
34
import {
45
CommonBuildConfig,
56
getModuleConfigs,
@@ -13,7 +14,6 @@ import { createModuleMpkInDocker } from "./mpk";
1314
import { ModuleInfo, PackageInfo, WidgetInfo } from "./package-info";
1415
import { addFilesToPackageXml, PackageType } from "./package-xml";
1516
import { chmod, cp, ensureFileExists, exec, find, mkdir, popd, pushd, rm, unzip, zip } from "./shell";
16-
import chalk from "chalk";
1717

1818
type Step<Info, Config> = (params: { info: Info; config: Config }) => Promise<void>;
1919

@@ -164,36 +164,63 @@ export async function createModuleMpk({ info, config }: ModuleStepParams): Promi
164164
);
165165
}
166166

167-
export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise<void> {
168-
logStep("Add widgets to mpk");
169-
170-
const mpk = config.output.files.modulePackage;
171-
const widgets = await getMpkPaths(config.dependencies);
167+
async function insertWidgetsIntoMpk(
168+
mpk: string,
169+
widgetPaths: string[],
170+
tmpDir: string,
171+
additionalFiles?: Array<{ src: string; destRelative: string }>
172+
): Promise<void> {
172173
const mpkEntry = parse(mpk);
173-
const target = join(mpkEntry.dir, "tmp");
174-
const widgetsOut = join(target, "widgets");
175-
const packageXml = join(target, "package.xml");
176-
const packageFilePaths = widgets.map(path => `widgets/${parse(path).base}`);
177-
178-
rm("-rf", target);
174+
const widgetsOut = join(tmpDir, "widgets");
175+
const packageXml = join(tmpDir, "package.xml");
176+
const packageFilePaths = widgetPaths.map(p => `widgets/${parse(p).base}`);
179177

178+
rm("-rf", tmpDir);
180179
console.info("Unzip module mpk");
181-
await unzip(mpk, target);
180+
await unzip(mpk, tmpDir);
182181
mkdir("-p", widgetsOut);
183-
chmod("-R", "a+rw", target);
182+
chmod("-R", "a+rw", tmpDir);
183+
184+
console.info(`Add ${widgetPaths.length} widgets to ${mpkEntry.base}`);
185+
cp(widgetPaths, widgetsOut);
184186

185-
console.info(`Add ${widgets.length} widgets to ${mpkEntry.base}`);
186-
cp(widgets, widgetsOut);
187+
if (additionalFiles) {
188+
for (const { src, destRelative } of additionalFiles) {
189+
cp(src, join(tmpDir, destRelative));
190+
}
191+
}
187192

188193
console.info(`Add file entries to package.xml`);
189194
await addFilesToPackageXml(packageXml, packageFilePaths, "modelerProject");
190-
console.log(`Copying License.txt...`);
191-
cp(join(config.paths.package, "LICENSE"), join(target, "License.txt"));
192195
rm(mpk);
193196

194197
console.info("Create module zip archive");
195-
await zip(target, mpk);
196-
rm("-rf", target);
198+
await zip(tmpDir, mpk);
199+
rm("-rf", tmpDir);
200+
}
201+
202+
export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise<void> {
203+
logStep("Add widgets to mpk");
204+
205+
const mpk = config.output.files.modulePackage;
206+
const widgets = await getMpkPaths(config.dependencies);
207+
const tmpDir = join(parse(mpk).dir, "tmp");
208+
209+
await insertWidgetsIntoMpk(mpk, widgets, tmpDir, [
210+
{ src: join(config.paths.package, "LICENSE"), destRelative: "License.txt" }
211+
]);
212+
}
213+
214+
export function addTestProjectWidgetsToMpk(widgetFileNames: string[]): (params: ModuleStepParams) => Promise<void> {
215+
return async ({ config }) => {
216+
logStep("Add test project widgets to mpk");
217+
218+
const mpk = config.output.files.modulePackage;
219+
const widgetPaths = widgetFileNames.map(name => join(config.output.dirs.widgets, name));
220+
const tmpDir = join(parse(mpk).dir, "tmp_local_widgets");
221+
222+
await insertWidgetsIntoMpk(mpk, widgetPaths, tmpDir);
223+
};
197224
}
198225

199226
export async function moveModuleToDist({ info, config }: ModuleStepParams): Promise<void> {

packages/modules/pusher/scripts/release.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env ts-node-script
22

33
import {
4+
addTestProjectWidgetsToMpk,
45
addWidgetsToMpk,
56
cloneTestProject,
67
copyModuleLicense,
@@ -23,6 +24,9 @@ async function main(): Promise<void> {
2324
copyWidgetsToProject,
2425
createModuleMpk,
2526
addWidgetsToMpk,
27+
// Copy old legacy widget to the module, so we have both for easy migration.
28+
// New widget has a different filename: com.mendix.widget.web.Pusher.mpk, they won't clash.
29+
addTestProjectWidgetsToMpk(["Pusher.mpk"]),
2630
moveModuleToDist
2731
]
2832
});

packages/pluggableWidgets/pusher-web/src/Pusher.tsx

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import classnames from "classnames";
2-
import { ReactElement, useCallback, useMemo } from "react";
2+
import { ReactElement, useMemo } from "react";
33
import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
44
import { PusherContainerProps } from "../typings/PusherProps";
55
import { usePusherSubscribe } from "./hooks/usePusherSubscribe";
@@ -9,40 +9,26 @@ import { getChannelName } from "./utils/getChannelName";
99
export default function Pusher(props: PusherContainerProps): ReactElement {
1010
const { class: className, objectSource, eventHandlers } = props;
1111

12-
// Error callback
13-
const handleError = useCallback((error: Error) => {
14-
console.error("[Pusher] Subscription error:", error.message);
15-
}, []);
16-
17-
// Build channel name based on the object
1812
const channelName = getChannelName(objectSource);
1913

20-
// Setup stable subscription config
2114
const subscription = useMemo(() => {
2215
if (!channelName) {
2316
return undefined;
2417
}
2518

26-
// Build event bindings from configured handlers
2719
const eventBindings = eventHandlers.map(handler => ({
2820
eventName: handler.actionName,
2921
onEvent: () => {
30-
console.debug(`[Pusher] Event received: ${handler.actionName}`);
3122
executeAction(handler.action);
3223
}
3324
}));
3425

35-
// If no valid handlers, return undefined (no subscription)
3626
if (eventBindings.length === 0) {
3727
return undefined;
3828
}
3929

40-
return {
41-
channelName,
42-
eventBindings,
43-
onError: handleError
44-
};
45-
}, [channelName, eventHandlers, handleError]);
30+
return { channelName, eventBindings };
31+
}, [channelName, eventHandlers]);
4632

4733
usePusherSubscribe(subscription);
4834

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,67 @@
1+
import { actionValue } from "@mendix/widget-plugin-test-utils";
2+
import { render } from "@testing-library/react";
3+
import { ActionValue, DynamicValue, ObjectItem } from "mendix";
4+
import { createElement } from "react";
5+
import * as usePusherSubscribeModule from "../hooks/usePusherSubscribe";
6+
import Pusher from "../Pusher";
7+
import * as getChannelNameModule from "../utils/getChannelName";
8+
9+
jest.mock("../utils/getChannelName");
10+
jest.mock("../hooks/usePusherSubscribe", () => ({
11+
usePusherSubscribe: jest.fn()
12+
}));
13+
14+
const mockGetChannelName = getChannelNameModule.getChannelName as jest.MockedFunction<
15+
typeof getChannelNameModule.getChannelName
16+
>;
17+
const mockUsePusherSubscribe = usePusherSubscribeModule.usePusherSubscribe as jest.Mock;
18+
19+
function makeProps(channelName: string | undefined, handlers: Array<{ actionName: string; action: ActionValue }>) {
20+
mockGetChannelName.mockReturnValue(channelName);
21+
return {
22+
name: "pusher",
23+
class: "",
24+
objectSource: {} as DynamicValue<ObjectItem>,
25+
eventHandlers: handlers
26+
} as any;
27+
}
28+
129
describe("Pusher", () => {
2-
it("placeholder – tests to be implemented", () => {
3-
// TODO: Add comprehensive unit tests for:
4-
// - PusherListener class (connection, subscription, cleanup)
5-
// - usePusherConfig hook (fetching config)
6-
// - usePusherListener hook (React lifecycle integration)
7-
// - Event handling and action execution
8-
expect(true).toBe(true);
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it("passes undefined subscription when channelName is undefined", () => {
35+
render(createElement(Pusher, makeProps(undefined, [{ actionName: "update", action: actionValue() }])));
36+
37+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
38+
});
39+
40+
it("passes undefined subscription when eventHandlers is empty", () => {
41+
render(createElement(Pusher, makeProps("private-Entity.123", [])));
42+
43+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
44+
});
45+
46+
it("passes subscription with correct channelName and eventBindings", () => {
47+
const action = actionValue();
48+
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));
49+
50+
expect(mockUsePusherSubscribe).toHaveBeenCalledWith(
51+
expect.objectContaining({
52+
channelName: "private-Entity.123",
53+
eventBindings: [expect.objectContaining({ eventName: "update" })]
54+
})
55+
);
56+
});
57+
58+
it("calls executeAction when onEvent fires", () => {
59+
const action = actionValue();
60+
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));
61+
62+
const { eventBindings } = mockUsePusherSubscribe.mock.calls[0][0];
63+
eventBindings[0].onEvent();
64+
65+
expect(action.execute).toHaveBeenCalledTimes(1);
966
});
1067
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { PusherListener } from "../utils/PusherListener";
2+
3+
const mockChannel = {
4+
bind_global: jest.fn(),
5+
unbind_all: jest.fn(),
6+
bind: jest.fn()
7+
};
8+
9+
const mockPusherInstance = {
10+
subscribe: jest.fn().mockReturnValue(mockChannel),
11+
unsubscribe: jest.fn(),
12+
disconnect: jest.fn(),
13+
connection: { bind: jest.fn(), unbind: jest.fn() }
14+
};
15+
16+
jest.mock("pusher-js", () => jest.fn().mockImplementation(() => mockPusherInstance));
17+
18+
const stubConfig = { key: "key", cluster: "eu", authEndpoint: "/auth", csrfToken: "tok" };
19+
const stubSubscription = {
20+
channelName: "private-Entity.123",
21+
eventBindings: [{ eventName: "update", onEvent: jest.fn() }]
22+
};
23+
24+
describe("PusherListener", () => {
25+
let listener: PusherListener;
26+
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
mockPusherInstance.subscribe.mockReturnValue(mockChannel);
30+
listener = new PusherListener(stubConfig);
31+
});
32+
33+
it("subscribes to channel and binds global handler", () => {
34+
listener.subscribe(stubSubscription);
35+
36+
expect(mockPusherInstance.subscribe).toHaveBeenCalledWith(stubSubscription.channelName);
37+
expect(mockChannel.bind_global).toHaveBeenCalledTimes(1);
38+
});
39+
40+
it("skips Pusher resubscription on same channel name", () => {
41+
listener.subscribe(stubSubscription);
42+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "other", onEvent: jest.fn() }] });
43+
44+
expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
45+
});
46+
47+
it("resubscribes when channel name changes", () => {
48+
listener.subscribe(stubSubscription);
49+
listener.subscribe({ ...stubSubscription, channelName: "private-Entity.456" });
50+
51+
expect(mockPusherInstance.unsubscribe).toHaveBeenCalledWith(stubSubscription.channelName);
52+
expect(mockPusherInstance.subscribe).toHaveBeenCalledWith("private-Entity.456");
53+
});
54+
55+
it("updates handler map so new handler fires without resubscribing", () => {
56+
const firstHandler = jest.fn();
57+
const secondHandler = jest.fn();
58+
59+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: firstHandler }] });
60+
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: secondHandler }] });
61+
62+
const globalCb = mockChannel.bind_global.mock.calls[0][0] as (name: string, data: unknown) => void;
63+
globalCb("update", {});
64+
65+
expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
66+
expect(firstHandler).not.toHaveBeenCalled();
67+
expect(secondHandler).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("destroy disconnects and is idempotent", () => {
71+
listener.subscribe(stubSubscription);
72+
listener.destroy();
73+
listener.destroy();
74+
75+
expect(mockPusherInstance.disconnect).toHaveBeenCalledTimes(1);
76+
});
77+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { fetchPusherConfig } from "../utils/fetchPusherConfig";
2+
3+
function mockFetch(status: number, body: unknown): void {
4+
global.fetch = jest.fn().mockResolvedValue({
5+
status,
6+
json: () => Promise.resolve(body)
7+
});
8+
}
9+
10+
beforeEach(() => {
11+
(window as any).mx = {
12+
remoteUrl: "https://app.example.com/",
13+
sessionData: { csrftoken: "test-csrf" }
14+
};
15+
});
16+
17+
afterEach(() => {
18+
delete (window as any).mx;
19+
jest.resetAllMocks();
20+
});
21+
22+
describe("fetchPusherConfig", () => {
23+
it("returns config on successful response", async () => {
24+
mockFetch(200, { key: "app-key", cluster: "eu" });
25+
26+
const result = await fetchPusherConfig(new AbortController().signal);
27+
28+
expect(result).toEqual({
29+
key: "app-key",
30+
cluster: "eu",
31+
authEndpoint: "https://app.example.com/rest/pusher/auth",
32+
csrfToken: "test-csrf"
33+
});
34+
});
35+
36+
it("returns null on non-200 response", async () => {
37+
mockFetch(403, {});
38+
39+
const result = await fetchPusherConfig(new AbortController().signal);
40+
41+
expect(result).toBeNull();
42+
});
43+
44+
it("returns null on network error", async () => {
45+
global.fetch = jest.fn().mockRejectedValue(new Error("Network failure"));
46+
47+
const result = await fetchPusherConfig(new AbortController().signal);
48+
49+
expect(result).toBeNull();
50+
});
51+
52+
it("returns null when signal is already aborted", async () => {
53+
const controller = new AbortController();
54+
const abortError = new DOMException("Aborted", "AbortError");
55+
global.fetch = jest.fn().mockRejectedValue(abortError);
56+
controller.abort();
57+
58+
const result = await fetchPusherConfig(controller.signal);
59+
60+
expect(result).toBeNull();
61+
});
62+
63+
it("returns null when response is missing required fields", async () => {
64+
mockFetch(200, { key: "app-key" }); // missing cluster
65+
66+
const result = await fetchPusherConfig(new AbortController().signal);
67+
68+
expect(result).toBeNull();
69+
});
70+
});

0 commit comments

Comments
 (0)