Skip to content
Open
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: 47 additions & 20 deletions automation/utils/src/steps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join, parse, relative, resolve } from "path";
import chalk from "chalk";
import {
CommonBuildConfig,
getModuleConfigs,
Expand All @@ -13,7 +14,6 @@ import { createModuleMpkInDocker } from "./mpk";
import { ModuleInfo, PackageInfo, WidgetInfo } from "./package-info";
import { addFilesToPackageXml, PackageType } from "./package-xml";
import { chmod, cp, ensureFileExists, exec, find, mkdir, popd, pushd, rm, unzip, zip } from "./shell";
import chalk from "chalk";

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

Expand Down Expand Up @@ -164,36 +164,63 @@ export async function createModuleMpk({ info, config }: ModuleStepParams): Promi
);
}

export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise<void> {
logStep("Add widgets to mpk");

const mpk = config.output.files.modulePackage;
const widgets = await getMpkPaths(config.dependencies);
async function insertWidgetsIntoMpk(
mpk: string,
widgetPaths: string[],
tmpDir: string,
additionalFiles?: Array<{ src: string; destRelative: string }>
): Promise<void> {
const mpkEntry = parse(mpk);
const target = join(mpkEntry.dir, "tmp");
const widgetsOut = join(target, "widgets");
const packageXml = join(target, "package.xml");
const packageFilePaths = widgets.map(path => `widgets/${parse(path).base}`);

rm("-rf", target);
const widgetsOut = join(tmpDir, "widgets");
const packageXml = join(tmpDir, "package.xml");
const packageFilePaths = widgetPaths.map(p => `widgets/${parse(p).base}`);

rm("-rf", tmpDir);
console.info("Unzip module mpk");
await unzip(mpk, target);
await unzip(mpk, tmpDir);
mkdir("-p", widgetsOut);
chmod("-R", "a+rw", target);
chmod("-R", "a+rw", tmpDir);

console.info(`Add ${widgetPaths.length} widgets to ${mpkEntry.base}`);
cp(widgetPaths, widgetsOut);

console.info(`Add ${widgets.length} widgets to ${mpkEntry.base}`);
cp(widgets, widgetsOut);
if (additionalFiles) {
for (const { src, destRelative } of additionalFiles) {
cp(src, join(tmpDir, destRelative));
}
}

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

console.info("Create module zip archive");
await zip(target, mpk);
rm("-rf", target);
await zip(tmpDir, mpk);
rm("-rf", tmpDir);
}

export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise<void> {
logStep("Add widgets to mpk");

const mpk = config.output.files.modulePackage;
const widgets = await getMpkPaths(config.dependencies);
const tmpDir = join(parse(mpk).dir, "tmp");

await insertWidgetsIntoMpk(mpk, widgets, tmpDir, [
{ src: join(config.paths.package, "LICENSE"), destRelative: "License.txt" }
]);
}

export function addTestProjectWidgetsToMpk(widgetFileNames: string[]): (params: ModuleStepParams) => Promise<void> {
return async ({ config }) => {
logStep("Add test project widgets to mpk");

const mpk = config.output.files.modulePackage;
const widgetPaths = widgetFileNames.map(name => join(config.output.dirs.widgets, name));
const tmpDir = join(parse(mpk).dir, "tmp_local_widgets");

await insertWidgetsIntoMpk(mpk, widgetPaths, tmpDir);
};
}

export async function moveModuleToDist({ info, config }: ModuleStepParams): Promise<void> {
Expand Down
4 changes: 4 additions & 0 deletions packages/modules/pusher/scripts/release.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env ts-node-script

import {
addTestProjectWidgetsToMpk,
addWidgetsToMpk,
cloneTestProject,
copyModuleLicense,
Expand All @@ -23,6 +24,9 @@ async function main(): Promise<void> {
copyWidgetsToProject,
createModuleMpk,
addWidgetsToMpk,
// Copy old legacy widget to the module, so we have both for easy migration.
// New widget has a different filename: com.mendix.widget.web.Pusher.mpk, they won't clash.
addTestProjectWidgetsToMpk(["Pusher.mpk"]),
moveModuleToDist
]
});
Expand Down
20 changes: 3 additions & 17 deletions packages/pluggableWidgets/pusher-web/src/Pusher.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classnames from "classnames";
import { ReactElement, useCallback, useMemo } from "react";
import { ReactElement, useMemo } from "react";
import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action";
import { PusherContainerProps } from "../typings/PusherProps";
import { usePusherSubscribe } from "./hooks/usePusherSubscribe";
Expand All @@ -9,40 +9,26 @@ import { getChannelName } from "./utils/getChannelName";
export default function Pusher(props: PusherContainerProps): ReactElement {
const { class: className, objectSource, eventHandlers } = props;

// Error callback
const handleError = useCallback((error: Error) => {
console.error("[Pusher] Subscription error:", error.message);
}, []);

// Build channel name based on the object
const channelName = getChannelName(objectSource);

// Setup stable subscription config
const subscription = useMemo(() => {
if (!channelName) {
return undefined;
}

// Build event bindings from configured handlers
const eventBindings = eventHandlers.map(handler => ({
eventName: handler.actionName,
onEvent: () => {
console.debug(`[Pusher] Event received: ${handler.actionName}`);
executeAction(handler.action);
}
}));

// If no valid handlers, return undefined (no subscription)
if (eventBindings.length === 0) {
return undefined;
}

return {
channelName,
eventBindings,
onError: handleError
};
}, [channelName, eventHandlers, handleError]);
return { channelName, eventBindings };
}, [channelName, eventHandlers]);

usePusherSubscribe(subscription);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,67 @@
import { actionValue } from "@mendix/widget-plugin-test-utils";
import { render } from "@testing-library/react";
import { ActionValue, DynamicValue, ObjectItem } from "mendix";
import { createElement } from "react";
import * as usePusherSubscribeModule from "../hooks/usePusherSubscribe";
import Pusher from "../Pusher";
import * as getChannelNameModule from "../utils/getChannelName";

jest.mock("../utils/getChannelName");
jest.mock("../hooks/usePusherSubscribe", () => ({
usePusherSubscribe: jest.fn()
}));

const mockGetChannelName = getChannelNameModule.getChannelName as jest.MockedFunction<
typeof getChannelNameModule.getChannelName
>;
const mockUsePusherSubscribe = usePusherSubscribeModule.usePusherSubscribe as jest.Mock;

function makeProps(channelName: string | undefined, handlers: Array<{ actionName: string; action: ActionValue }>) {
mockGetChannelName.mockReturnValue(channelName);
return {
name: "pusher",
class: "",
objectSource: {} as DynamicValue<ObjectItem>,
eventHandlers: handlers
} as any;
}

describe("Pusher", () => {
it("placeholder – tests to be implemented", () => {
// TODO: Add comprehensive unit tests for:
// - PusherListener class (connection, subscription, cleanup)
// - usePusherConfig hook (fetching config)
// - usePusherListener hook (React lifecycle integration)
// - Event handling and action execution
expect(true).toBe(true);
beforeEach(() => {
jest.clearAllMocks();
});

it("passes undefined subscription when channelName is undefined", () => {
render(createElement(Pusher, makeProps(undefined, [{ actionName: "update", action: actionValue() }])));

expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
});

it("passes undefined subscription when eventHandlers is empty", () => {
render(createElement(Pusher, makeProps("private-Entity.123", [])));

expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined);
});

it("passes subscription with correct channelName and eventBindings", () => {
const action = actionValue();
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));

expect(mockUsePusherSubscribe).toHaveBeenCalledWith(
expect.objectContaining({
channelName: "private-Entity.123",
eventBindings: [expect.objectContaining({ eventName: "update" })]
})
);
});

it("calls executeAction when onEvent fires", () => {
const action = actionValue();
render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }])));

const { eventBindings } = mockUsePusherSubscribe.mock.calls[0][0];
eventBindings[0].onEvent();

expect(action.execute).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { PusherListener } from "../utils/PusherListener";

const mockChannel = {
bind_global: jest.fn(),
unbind_all: jest.fn(),
bind: jest.fn()
};

const mockPusherInstance = {
subscribe: jest.fn().mockReturnValue(mockChannel),
unsubscribe: jest.fn(),
disconnect: jest.fn(),
connection: { bind: jest.fn(), unbind: jest.fn() }
};

jest.mock("pusher-js", () => jest.fn().mockImplementation(() => mockPusherInstance));

const stubConfig = { key: "key", cluster: "eu", authEndpoint: "/auth", csrfToken: "tok" };
const stubSubscription = {
channelName: "private-Entity.123",
eventBindings: [{ eventName: "update", onEvent: jest.fn() }]
};

describe("PusherListener", () => {
let listener: PusherListener;

beforeEach(() => {
jest.clearAllMocks();
mockPusherInstance.subscribe.mockReturnValue(mockChannel);
listener = new PusherListener(stubConfig);
});

it("subscribes to channel and binds global handler", () => {
listener.subscribe(stubSubscription);

expect(mockPusherInstance.subscribe).toHaveBeenCalledWith(stubSubscription.channelName);
expect(mockChannel.bind_global).toHaveBeenCalledTimes(1);
});

it("skips Pusher resubscription on same channel name", () => {
listener.subscribe(stubSubscription);
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "other", onEvent: jest.fn() }] });

expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
});

it("resubscribes when channel name changes", () => {
listener.subscribe(stubSubscription);
listener.subscribe({ ...stubSubscription, channelName: "private-Entity.456" });

expect(mockPusherInstance.unsubscribe).toHaveBeenCalledWith(stubSubscription.channelName);
expect(mockPusherInstance.subscribe).toHaveBeenCalledWith("private-Entity.456");
});

it("updates handler map so new handler fires without resubscribing", () => {
const firstHandler = jest.fn();
const secondHandler = jest.fn();

listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: firstHandler }] });
listener.subscribe({ ...stubSubscription, eventBindings: [{ eventName: "update", onEvent: secondHandler }] });

const globalCb = mockChannel.bind_global.mock.calls[0][0] as (name: string, data: unknown) => void;
globalCb("update", {});

expect(mockPusherInstance.subscribe).toHaveBeenCalledTimes(1);
expect(firstHandler).not.toHaveBeenCalled();
expect(secondHandler).toHaveBeenCalledTimes(1);
});

it("destroy disconnects and is idempotent", () => {
listener.subscribe(stubSubscription);
listener.destroy();
listener.destroy();

expect(mockPusherInstance.disconnect).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { fetchPusherConfig } from "../utils/fetchPusherConfig";

function mockFetch(status: number, body: unknown): void {
global.fetch = jest.fn().mockResolvedValue({
status,
json: () => Promise.resolve(body)
});
}

beforeEach(() => {
(window as any).mx = {
remoteUrl: "https://app.example.com/",
sessionData: { csrftoken: "test-csrf" }
};
});

afterEach(() => {
delete (window as any).mx;
jest.resetAllMocks();
});

describe("fetchPusherConfig", () => {
it("returns config on successful response", async () => {
mockFetch(200, { key: "app-key", cluster: "eu" });

const result = await fetchPusherConfig(new AbortController().signal);

expect(result).toEqual({
key: "app-key",
cluster: "eu",
authEndpoint: "https://app.example.com/rest/pusher/auth",
csrfToken: "test-csrf"
});
});

it("returns null on non-200 response", async () => {
mockFetch(403, {});

const result = await fetchPusherConfig(new AbortController().signal);

expect(result).toBeNull();
});

it("returns null on network error", async () => {
global.fetch = jest.fn().mockRejectedValue(new Error("Network failure"));

const result = await fetchPusherConfig(new AbortController().signal);

expect(result).toBeNull();
});

it("returns null when signal is already aborted", async () => {
const controller = new AbortController();
const abortError = new DOMException("Aborted", "AbortError");
global.fetch = jest.fn().mockRejectedValue(abortError);
controller.abort();

const result = await fetchPusherConfig(controller.signal);

expect(result).toBeNull();
});

it("returns null when response is missing required fields", async () => {
mockFetch(200, { key: "app-key" }); // missing cluster

const result = await fetchPusherConfig(new AbortController().signal);

expect(result).toBeNull();
});
});
Loading
Loading