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..5e07595ff4 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,9 @@ async function main(): Promise { 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 ] }); 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/__tests__/Pusher.spec.tsx b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx index 1024b5d20e..dba015ffe6 100644 --- a/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx +++ b/packages/pluggableWidgets/pusher-web/src/__tests__/Pusher.spec.tsx @@ -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, + 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); }); }); 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); + }); +}); 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]); } diff --git a/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts b/packages/pluggableWidgets/pusher-web/src/utils/PusherListener.ts index 695d6da042..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) { @@ -38,9 +36,7 @@ export class PusherListener { } }); - // Setup connection event handlers this.pusher.connection.bind("error", this.handleConnectionError); - this.pusher.connection.bind("state_change", this.handleStateChange); } /** @@ -69,12 +65,9 @@ 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); - this.onError?.(new Error(`Subscription error: ${String(error)}`)); }); } @@ -83,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; } /** @@ -101,7 +91,6 @@ export class PusherListener { this.currentChannelName = null; this.globalCallback = null; this.eventHandlersMap.clear(); - this.onError = undefined; } /** @@ -121,10 +110,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 {