From 6969489154c0ce126ff4088677d056eea3db592b Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Mon, 13 Jul 2026 17:15:37 +0200 Subject: [PATCH 1/7] chore(pusher-web): remove excessive logic --- .../pusher-web/src/Pusher.tsx | 20 +++---------------- .../pusher-web/src/utils/PusherListener.ts | 8 -------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/packages/pluggableWidgets/pusher-web/src/Pusher.tsx b/packages/pluggableWidgets/pusher-web/src/Pusher.tsx index 75da2947a0..bb61d63fc5 100644 --- a/packages/pluggableWidgets/pusher-web/src/Pusher.tsx +++ b/packages/pluggableWidgets/pusher-web/src/Pusher.tsx @@ -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"; @@ -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); diff --git a/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts b/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts index 695d6da042..637fb92176 100644 --- a/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts +++ b/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts @@ -38,9 +38,7 @@ export class PusherListener { } }); - // Setup connection event handlers this.pusher.connection.bind("error", this.handleConnectionError); - this.pusher.connection.bind("state_change", this.handleStateChange); } /** @@ -69,8 +67,6 @@ export class PusherListener { // Bind error handler this.currentChannel.bind("pusher:subscription_error", (error: unknown) => { if (isAuthError(error)) { - // 403 from auth endpoint — object not yet known to server, silent in happy flow - console.debug("[PusherListener] Channel auth returned 403, skipping subscription."); return; } console.error("[PusherListener] Subscription error:", error); @@ -121,10 +117,6 @@ export class PusherListener { private handleConnectionError = (error: unknown): void => { console.error("[PusherListener] Connection error:", error); }; - - private handleStateChange = (states: { previous: string; current: string }): void => { - console.debug(`[PusherListener] State changed: ${states.previous} → ${states.current}`); - }; } function isAuthError(error: unknown): boolean { From e13be5d7201b3f195b91b697acfc2fcfb329a694 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 14 Jul 2026 11:47:40 +0200 Subject: [PATCH 2/7] chore: add copying old widget step to Pusher --- automation/utils/src/steps.ts | 67 +++++++++++++++------- packages/modules/pusher/scripts/release.ts | 2 + 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/automation/utils/src/steps.ts b/automation/utils/src/steps.ts index f63667d7d2..67fa466e48 100644 --- a/automation/utils/src/steps.ts +++ b/automation/utils/src/steps.ts @@ -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, @@ -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 = (params: { info: Info; config: Config }) => Promise; @@ -164,36 +164,63 @@ export async function createModuleMpk({ info, config }: ModuleStepParams): Promi ); } -export async function addWidgetsToMpk({ config }: ModuleStepParams): Promise { - 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 { 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 { + 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 { + 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 { diff --git a/packages/modules/pusher/scripts/release.ts b/packages/modules/pusher/scripts/release.ts index d8ff81f729..f0d0b3d9f9 100644 --- a/packages/modules/pusher/scripts/release.ts +++ b/packages/modules/pusher/scripts/release.ts @@ -1,6 +1,7 @@ #!/usr/bin/env ts-node-script import { + addTestProjectWidgetsToMpk, addWidgetsToMpk, cloneTestProject, copyModuleLicense, @@ -23,6 +24,7 @@ async function main(): Promise { copyWidgetsToProject, createModuleMpk, addWidgetsToMpk, + addTestProjectWidgetsToMpk(["Pusher.mpk"]), moveModuleToDist ] }); From 9eda2f51179a2ae5a243ad19e376671d448eb430 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 14 Jul 2026 15:38:33 +0200 Subject: [PATCH 3/7] chore(pusher-web): remove dead onError callback from PusherListener SubscriptionConfig.onError was never set by any caller after handleError was removed from Pusher.tsx. Drop the interface field, private field, call site, and cleanup assignment to avoid misleading future readers. --- .../pusher-web/src/utils/PusherListener.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts b/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts index 637fb92176..e9a463acdf 100644 --- a/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts +++ b/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts @@ -15,7 +15,6 @@ export interface EventBinding { export interface SubscriptionConfig { channelName: string; eventBindings: EventBinding[]; - onError?: (error: Error) => void; } export class PusherListener { @@ -24,7 +23,6 @@ export class PusherListener { private currentChannelName: string | null = null; private eventHandlersMap: Map void> = new Map(); private globalCallback: ((eventName: string, data: unknown) => void) | null = null; - private onError?: (error: Error) => void; private destroyed = false; constructor(private config: PusherConfig) { @@ -70,7 +68,6 @@ export class PusherListener { return; } console.error("[PusherListener] Subscription error:", error); - this.onError?.(new Error(`Subscription error: ${String(error)}`)); }); } @@ -79,9 +76,6 @@ export class PusherListener { config.eventBindings.forEach(binding => { this.eventHandlersMap.set(binding.eventName, binding.onEvent); }); - - // Store error handler for reference - this.onError = config.onError; } /** @@ -97,7 +91,6 @@ export class PusherListener { this.currentChannelName = null; this.globalCallback = null; this.eventHandlersMap.clear(); - this.onError = undefined; } /** From 28e6ef20a9c1e9560cfd81555d4b8d703d0b48a3 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 14 Jul 2026 15:41:41 +0200 Subject: [PATCH 4/7] chore: explain legacy pusher copying reason --- packages/modules/pusher/scripts/release.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/modules/pusher/scripts/release.ts b/packages/modules/pusher/scripts/release.ts index f0d0b3d9f9..b137dcdac0 100644 --- a/packages/modules/pusher/scripts/release.ts +++ b/packages/modules/pusher/scripts/release.ts @@ -24,6 +24,8 @@ async function main(): Promise { copyWidgetsToProject, createModuleMpk, addWidgetsToMpk, + // Copy old legacy widget to the module, so we have both for easy migration. + // New widgets has different filename: com.mendix.widget.web.Pusher.mpk, they won't clash. addTestProjectWidgetsToMpk(["Pusher.mpk"]), moveModuleToDist ] From 7c771c8797ead2f171c5ad1b3066fb8e4149ff0c Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 14 Jul 2026 15:55:31 +0200 Subject: [PATCH 5/7] fix(pusher-web): remove effect cleanup that caused unnecessary resubscriptions on each render --- .../pusher-web/src/hooks/usePusherSubscribe.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/pluggableWidgets/pusher-web/src/hooks/usePusherSubscribe.ts b/packages/pluggableWidgets/pusher-web/src/hooks/usePusherSubscribe.ts index fa53f55d63..ace0a8988d 100644 --- a/packages/pluggableWidgets/pusher-web/src/hooks/usePusherSubscribe.ts +++ b/packages/pluggableWidgets/pusher-web/src/hooks/usePusherSubscribe.ts @@ -12,20 +12,21 @@ export function usePusherSubscribe(subscription?: SubscriptionConfig): void { useEffect(() => { const controller = new AbortController(); - let instance: PusherListener | null = null; + let listenerInstance: PusherListener | null = null; createPusherListener(controller.signal).then(result => { if (controller.signal.aborted) { result?.destroy(); return; } - instance = result; - setListener(result); + listenerInstance = result; + + setListener(listenerInstance); }); return () => { controller.abort(); - instance?.destroy(); + listenerInstance?.destroy(); setListener(null); }; }, []); @@ -34,14 +35,11 @@ export function usePusherSubscribe(subscription?: SubscriptionConfig): void { if (!listener) { return; } + if (!subscription) { listener.unsubscribe(); - return; + } else { + listener.subscribe(subscription); } - - listener.subscribe(subscription); - return () => { - listener.unsubscribe(); - }; }, [listener, subscription]); } From 3cdb16dbd01b84de6a1828c97836665d6cf4caea Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 14 Jul 2026 16:56:08 +0200 Subject: [PATCH 6/7] test(pusher-web): add unit tests for Pusher, getChannelName, fetchPusherConfig Cover the previously untested upper layers: widget subscription building logic in Pusher.tsx, channel name derivation, and config fetch error paths. --- .../pusher-web/src/__tests__/Pusher.spec.tsx | 74 ++++++++++++++++-- .../src/__tests__/PusherListener.spec.ts | 77 +++++++++++++++++++ .../src/__tests__/fetchPusherConfig.spec.ts | 70 +++++++++++++++++ .../src/__tests__/getChannelName.spec.ts | 36 +++++++++ .../src/__tests__/usePusherSubscribe.spec.ts | 57 ++++++++++++++ 5 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 packages/pluggableWidgets/pusher-web/src/__tests__/PusherListener.spec.ts create mode 100644 packages/pluggableWidgets/pusher-web/src/__tests__/fetchPusherConfig.spec.ts create mode 100644 packages/pluggableWidgets/pusher-web/src/__tests__/getChannelName.spec.ts create mode 100644 packages/pluggableWidgets/pusher-web/src/__tests__/usePusherSubscribe.spec.ts diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx index 1024b5d20e..2a6584e464 100644 --- a/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx @@ -1,10 +1,70 @@ +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 makeAction(canExecute = true): ActionValue { + return { canExecute, execute: jest.fn(), isExecuting: false } as unknown as ActionValue; +} + +function makeProps(channelName: string | undefined, handlers: Array<{ actionName: string; action: ActionValue }>) { + mockGetChannelName.mockReturnValue(channelName); + return { + name: "pusher", + class: "", + objectSource: {} as DynamicValue, + 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: makeAction() }]))); + + 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 = makeAction(); + 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 = makeAction(); + 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); }); }); diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/PusherListener.spec.ts b/packages/pluggableWidgets/pusher-web/src/__tests__/PusherListener.spec.ts new file mode 100644 index 0000000000..5c3a68e0e1 --- /dev/null +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/PusherListener.spec.ts @@ -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); + }); +}); diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/fetchPusherConfig.spec.ts b/packages/pluggableWidgets/pusher-web/src/__tests__/fetchPusherConfig.spec.ts new file mode 100644 index 0000000000..97641da564 --- /dev/null +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/fetchPusherConfig.spec.ts @@ -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(); + }); +}); diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/getChannelName.spec.ts b/packages/pluggableWidgets/pusher-web/src/__tests__/getChannelName.spec.ts new file mode 100644 index 0000000000..10d41d121d --- /dev/null +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/getChannelName.spec.ts @@ -0,0 +1,36 @@ +import { DynamicValue, ObjectItem } from "mendix"; +import { getChannelName } from "../utils/getChannelName"; + +function makeObjectSource(value?: ObjectItem): DynamicValue { + return { value } as DynamicValue; +} + +function makeObjectItem(entityName: string, guid: string): ObjectItem { + const mxObject = { getEntity: () => entityName }; + const sym = Symbol("mxObject"); + const item = { id: guid, [sym]: mxObject } as unknown as ObjectItem; + Object.defineProperty(item, sym, { value: mxObject, enumerable: false }); + // Place symbol as first own symbol so extractEntityName finds it + return item; +} + +describe("getChannelName", () => { + it("returns undefined when objectSource has no value", () => { + expect(getChannelName(makeObjectSource(undefined))).toBeUndefined(); + }); + + it("returns private channel name for valid object", () => { + const item = makeObjectItem("MyModule.MyEntity", "guid-123"); + expect(getChannelName(makeObjectSource(item))).toBe("private-MyModule.MyEntity.guid-123"); + }); + + it("throws when mxObject symbol is absent", () => { + const item = { id: "guid-123" } as unknown as ObjectItem; + expect(() => getChannelName(makeObjectSource(item))).toThrow("Unable to extract entity name"); + }); + + it("returns undefined when guid is empty", () => { + const item = makeObjectItem("MyModule.MyEntity", ""); + expect(getChannelName(makeObjectSource(item))).toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/usePusherSubscribe.spec.ts b/packages/pluggableWidgets/pusher-web/src/__tests__/usePusherSubscribe.spec.ts new file mode 100644 index 0000000000..96fe49531a --- /dev/null +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/usePusherSubscribe.spec.ts @@ -0,0 +1,57 @@ +import { act, renderHook } from "@testing-library/react"; +import { usePusherSubscribe } from "../hooks/usePusherSubscribe"; +import { createPusherListener } from "../utils/createPusherListener"; + +jest.mock("../utils/createPusherListener"); + +const mockListener = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + destroy: jest.fn() +}; + +const mockCreatePusherListener = createPusherListener as jest.MockedFunction; + +const stubSubscription = { + channelName: "private-Entity.123", + eventBindings: [{ eventName: "update", onEvent: jest.fn() }] +}; + +describe("usePusherSubscribe", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreatePusherListener.mockResolvedValue(mockListener as never); + }); + + it("calls subscribe when listener and subscription are ready", async () => { + renderHook(() => usePusherSubscribe(stubSubscription)); + + await act(async () => {}); + + expect(mockListener.subscribe).toHaveBeenCalledWith(stubSubscription); + }); + + it("calls unsubscribe when subscription is undefined", async () => { + renderHook(() => usePusherSubscribe(undefined)); + + await act(async () => {}); + + expect(mockListener.unsubscribe).toHaveBeenCalledTimes(1); + expect(mockListener.subscribe).not.toHaveBeenCalled(); + }); + + it("calls destroy on unmount, not unsubscribe directly", async () => { + const { unmount } = renderHook(() => usePusherSubscribe(stubSubscription)); + + await act(async () => {}); + + const unsubscribeCallsBefore = mockListener.unsubscribe.mock.calls.length; + + act(() => { + unmount(); + }); + + expect(mockListener.destroy).toHaveBeenCalledTimes(1); + expect(mockListener.unsubscribe.mock.calls.length).toBe(unsubscribeCallsBefore); + }); +}); From 2b63b4bbbf3eb77be0f42d22086b77d3d88940f3 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Wed, 15 Jul 2026 09:43:00 +0200 Subject: [PATCH 7/7] refactor(pusher-web): use actionValue() builder in tests; fix grammar in release script comment --- packages/modules/pusher/scripts/release.ts | 2 +- .../pusher-web/src/__tests__/Pusher.spec.tsx | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/modules/pusher/scripts/release.ts b/packages/modules/pusher/scripts/release.ts index b137dcdac0..5e07595ff4 100644 --- a/packages/modules/pusher/scripts/release.ts +++ b/packages/modules/pusher/scripts/release.ts @@ -25,7 +25,7 @@ async function main(): Promise { createModuleMpk, addWidgetsToMpk, // Copy old legacy widget to the module, so we have both for easy migration. - // New widgets has different filename: com.mendix.widget.web.Pusher.mpk, they won't clash. + // New widget has a different filename: com.mendix.widget.web.Pusher.mpk, they won't clash. addTestProjectWidgetsToMpk(["Pusher.mpk"]), moveModuleToDist ] diff --git a/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx index 2a6584e464..dba015ffe6 100644 --- a/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx @@ -1,3 +1,4 @@ +import { actionValue } from "@mendix/widget-plugin-test-utils"; import { render } from "@testing-library/react"; import { ActionValue, DynamicValue, ObjectItem } from "mendix"; import { createElement } from "react"; @@ -15,10 +16,6 @@ const mockGetChannelName = getChannelNameModule.getChannelName as jest.MockedFun >; const mockUsePusherSubscribe = usePusherSubscribeModule.usePusherSubscribe as jest.Mock; -function makeAction(canExecute = true): ActionValue { - return { canExecute, execute: jest.fn(), isExecuting: false } as unknown as ActionValue; -} - function makeProps(channelName: string | undefined, handlers: Array<{ actionName: string; action: ActionValue }>) { mockGetChannelName.mockReturnValue(channelName); return { @@ -35,7 +32,7 @@ describe("Pusher", () => { }); it("passes undefined subscription when channelName is undefined", () => { - render(createElement(Pusher, makeProps(undefined, [{ actionName: "update", action: makeAction() }]))); + render(createElement(Pusher, makeProps(undefined, [{ actionName: "update", action: actionValue() }]))); expect(mockUsePusherSubscribe).toHaveBeenCalledWith(undefined); }); @@ -47,7 +44,7 @@ describe("Pusher", () => { }); it("passes subscription with correct channelName and eventBindings", () => { - const action = makeAction(); + const action = actionValue(); render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }]))); expect(mockUsePusherSubscribe).toHaveBeenCalledWith( @@ -59,7 +56,7 @@ describe("Pusher", () => { }); it("calls executeAction when onEvent fires", () => { - const action = makeAction(); + const action = actionValue(); render(createElement(Pusher, makeProps("private-Entity.123", [{ actionName: "update", action }]))); const { eventBindings } = mockUsePusherSubscribe.mock.calls[0][0];