From 8b87188075aa420a49f4ee4e9c54060f48f5e964 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Mon, 19 May 2025 15:22:19 +0200 Subject: [PATCH] Add navigate command to the external bus (#25516) --- src/external_app/external_app_entrypoint.ts | 11 +- src/external_app/external_messaging.ts | 25 +- .../external_app_entrypoint.test.ts | 288 ++++++++++++++++++ 3 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 test/external_app/external_app_entrypoint.test.ts diff --git a/src/external_app/external_app_entrypoint.ts b/src/external_app/external_app_entrypoint.ts index e5aac787a8..66edfdb504 100644 --- a/src/external_app/external_app_entrypoint.ts +++ b/src/external_app/external_app_entrypoint.ts @@ -7,6 +7,7 @@ This is the entry point for providing external app stuff from app entrypoint. import { fireEvent } from "../common/dom/fire_event"; import { mainWindow } from "../common/dom/get_main_window"; +import { navigate } from "../common/navigate"; import { showAutomationEditor } from "../data/automation"; import type { HomeAssistantMain } from "../layouts/home-assistant-main"; import type { @@ -50,7 +51,7 @@ export const addExternalBarCodeListener = ( }; }; -const handleExternalMessage = ( +export const handleExternalMessage = ( hassMainEl: HomeAssistantMain, msg: EMIncomingMessageCommands ): boolean => { @@ -64,6 +65,14 @@ const handleExternalMessage = ( success: true, result: null, }); + } else if (msg.command === "navigate") { + navigate(msg.payload.path, msg.payload.options); + bus.fireMessage({ + id: msg.id, + type: "result", + success: true, + result: null, + }); } else if (msg.command === "notifications/show") { fireEvent(hassMainEl, "hass-show-notifications"); bus.fireMessage({ diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts index 773182c248..be25a5f223 100644 --- a/src/external_app/external_messaging.ts +++ b/src/external_app/external_messaging.ts @@ -1,3 +1,4 @@ +import type { NavigateOptions } from "../common/navigate"; import type { AutomationConfig } from "../data/automation"; const CALLBACK_EXTERNAL_BUS = "externalBus"; @@ -178,31 +179,40 @@ type EMOutgoingMessageWithoutAnswer = | EMOutgoingMessageImprovScan | EMOutgoingMessageImprovConfigureDevice; -interface EMIncomingMessageRestart { +export interface EMIncomingMessageRestart { id: number; type: "command"; command: "restart"; } +export interface EMIncomingMessageNavigate { + id: number; + type: "command"; + command: "navigate"; + payload: { + path: string; + options?: NavigateOptions; + }; +} -interface EMIncomingMessageShowNotifications { +export interface EMIncomingMessageShowNotifications { id: number; type: "command"; command: "notifications/show"; } -interface EMIncomingMessageToggleSidebar { +export interface EMIncomingMessageToggleSidebar { id: number; type: "command"; command: "sidebar/toggle"; } -interface EMIncomingMessageShowSidebar { +export interface EMIncomingMessageShowSidebar { id: number; type: "command"; command: "sidebar/show"; } -interface EMIncomingMessageShowAutomationEditor { +export interface EMIncomingMessageShowAutomationEditor { id: number; type: "command"; command: "automation/editor/show"; @@ -250,14 +260,14 @@ export interface ImprovDiscoveredDevice { name: string; } -interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage { +export interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage { id: number; type: "command"; command: "improv/discovered_device"; payload: ImprovDiscoveredDevice; } -interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { +export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { id: number; type: "command"; command: "improv/device_setup_done"; @@ -265,6 +275,7 @@ interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { export type EMIncomingMessageCommands = | EMIncomingMessageRestart + | EMIncomingMessageNavigate | EMIncomingMessageShowNotifications | EMIncomingMessageToggleSidebar | EMIncomingMessageShowSidebar diff --git a/test/external_app/external_app_entrypoint.test.ts b/test/external_app/external_app_entrypoint.test.ts new file mode 100644 index 0000000000..1236235da3 --- /dev/null +++ b/test/external_app/external_app_entrypoint.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { fireEvent } from "../../src/common/dom/fire_event"; +import { mainWindow } from "../../src/common/dom/get_main_window"; +import { navigate } from "../../src/common/navigate"; +import { + handleExternalMessage, + addExternalBarCodeListener, +} from "../../src/external_app/external_app_entrypoint"; +import { showAutomationEditor } from "../../src/data/automation"; +import type { + EMIncomingMessageRestart, + EMIncomingMessageNavigate, + EMIncomingMessageShowNotifications, + EMIncomingMessageToggleSidebar, + EMIncomingMessageShowSidebar, + EMIncomingMessageShowAutomationEditor, + EMIncomingMessageImprovDeviceDiscovered, + EMIncomingMessageImprovDeviceSetupDone, + EMIncomingMessageBarCodeScanResult, + EMIncomingMessageBarCodeScanAborted, +} from "../../src/external_app/external_messaging"; + +vi.mock("../../src/common/dom/fire_event", () => ({ + fireEvent: vi.fn(), +})); +vi.mock("../../src/common/navigate", () => ({ + navigate: vi.fn(), +})); +vi.mock("../../src/data/automation", () => ({ + showAutomationEditor: vi.fn(), +})); + +describe("handleExternalMessage", () => { + let hassMainEl: any; + let fireMessage: any; + let reconnect: any; + + beforeEach(() => { + fireMessage = vi.fn(); + reconnect = vi.fn(); + hassMainEl = { + hass: { + auth: { + external: { + fireMessage, + }, + }, + connection: { + reconnect, + }, + }, + }; + vi.clearAllMocks(); + }); + + it("handles restart command", () => { + const msg: EMIncomingMessageRestart = { + type: "command", + command: "restart", + id: 1, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(reconnect).toHaveBeenCalledWith(true); + expect(fireMessage).toHaveBeenCalledWith({ + id: 1, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles navigate command", () => { + const msg: EMIncomingMessageNavigate = { + type: "command", + command: "navigate", + id: 2, + payload: { path: "/test", options: { replace: true } }, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(navigate).toHaveBeenCalledWith("/test", { replace: true }); + expect(fireMessage).toHaveBeenCalledWith({ + id: 2, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles notifications/show command", () => { + const msg: EMIncomingMessageShowNotifications = { + type: "command", + command: "notifications/show", + id: 3, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).toHaveBeenCalledWith( + hassMainEl, + "hass-show-notifications" + ); + expect(fireMessage).toHaveBeenCalledWith({ + id: 3, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles sidebar/toggle command when dialog is open", () => { + vi.spyOn(mainWindow.history, "state", "get").mockReturnValue({ + open: true, + }); + const msg: EMIncomingMessageToggleSidebar = { + type: "command", + command: "sidebar/toggle", + id: 4, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).not.toHaveBeenCalled(); + expect(fireMessage).toHaveBeenCalledWith({ + id: 4, + type: "result", + success: false, + error: { code: "not_allowed", message: "dialog open" }, + }); + expect(result).toBe(true); + }); + + it("handles sidebar/toggle command when dialog is not open", () => { + vi.spyOn(mainWindow.history, "state", "get").mockReturnValue({ + open: false, + }); + const msg: EMIncomingMessageToggleSidebar = { + type: "command", + command: "sidebar/toggle", + id: 5, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).toHaveBeenCalledWith(hassMainEl, "hass-toggle-menu"); + expect(fireMessage).toHaveBeenCalledWith({ + id: 5, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles sidebar/show command when dialog is open", () => { + vi.spyOn(mainWindow.history, "state", "get").mockReturnValue({ + open: true, + }); + const msg: EMIncomingMessageShowSidebar = { + type: "command", + command: "sidebar/show", + id: 6, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).not.toHaveBeenCalled(); + expect(fireMessage).toHaveBeenCalledWith({ + id: 6, + type: "result", + success: false, + error: { code: "not_allowed", message: "dialog open" }, + }); + expect(result).toBe(true); + }); + + it("handles sidebar/show command when dialog is not open", () => { + vi.spyOn(mainWindow.history, "state", "get").mockReturnValue({ + open: false, + }); + const msg: EMIncomingMessageShowSidebar = { + type: "command", + command: "sidebar/show", + id: 7, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).toHaveBeenCalledWith(hassMainEl, "hass-toggle-menu", { + open: true, + }); + expect(fireMessage).toHaveBeenCalledWith({ + id: 7, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles automation/editor/show command", () => { + const msg: EMIncomingMessageShowAutomationEditor = { + type: "command", + command: "automation/editor/show", + id: 8, + payload: { config: { id: "42" } }, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(showAutomationEditor).toHaveBeenCalledWith({ id: "42" }); + expect(fireMessage).toHaveBeenCalledWith({ + id: 8, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles improv/discovered_device command", () => { + const msg: EMIncomingMessageImprovDeviceDiscovered = { + type: "command", + command: "improv/discovered_device", + id: 9, + payload: { name: "helloworld" }, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).toHaveBeenCalledWith(window, "improv-discovered-device", { + name: "helloworld", + }); + expect(fireMessage).toHaveBeenCalledWith({ + id: 9, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles improv/device_setup_done command", () => { + const msg: EMIncomingMessageImprovDeviceSetupDone = { + type: "command", + command: "improv/device_setup_done", + id: 10, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(fireEvent).toHaveBeenCalledWith(window, "improv-device-setup-done"); + expect(fireMessage).toHaveBeenCalledWith({ + id: 10, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles bar_code/scan_result command and notifies listeners", () => { + const listener = vi.fn(); + addExternalBarCodeListener(listener); + const msg: EMIncomingMessageBarCodeScanResult = { + type: "command", + command: "bar_code/scan_result", + id: 11, + payload: { rawValue: "123456789", format: "aztec" }, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(listener).toHaveBeenCalledWith(msg); + expect(fireMessage).toHaveBeenCalledWith({ + id: 11, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); + + it("handles bar_code/aborted command and notifies listeners", () => { + const listener = vi.fn(); + addExternalBarCodeListener(listener); + const msg: EMIncomingMessageBarCodeScanAborted = { + type: "command", + command: "bar_code/aborted", + id: 12, + payload: { reason: "canceled" }, + }; + const result = handleExternalMessage(hassMainEl, msg); + expect(listener).toHaveBeenCalledWith(msg); + expect(fireMessage).toHaveBeenCalledWith({ + id: 12, + type: "result", + success: true, + result: null, + }); + expect(result).toBe(true); + }); +});