From 6e891b8f73137602bedf6a02462f72f10202b899 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Mon, 30 Mar 2026 11:44:54 +0100 Subject: [PATCH 01/12] feat add postMessage logic Signed-off-by: Ricardo Silva --- src/App.tsx | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index e463efe9..4fe9e232 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,16 @@ import { decompressSharedTd } from "./share"; import { editor } from "monaco-editor"; import BaseButton from "./components/TDViewer/base/BaseButton"; import ErrorDialog from "./components/Dialogs/ErrorDialog"; +import DialogTemplate from "./components/Dialogs/DialogTemplate"; + +type ReadyMessage = { + type: "EDITDOR_READY"; +}; + +type LoadTdMessage = { + type: "LOAD_TD"; + payload: string; +}; const GlobalStateWrapper = () => { return ( @@ -42,6 +52,8 @@ const BREAKPOINTS = { // This variable prevents the callback from being executed twice. let checkedUrl = false; +const APP_A_ORIGIN = "http://localhost:5173"; + const App: React.FC = () => { const context = useContext(ediTDorContext); @@ -49,6 +61,8 @@ const App: React.FC = () => { const [doShowJSON, setDoShowJSON] = useState(false); const [customBreakpointsState, setCustomBreakpointsState] = useState(0); const tdViewerRef = useRef(null); + const [pendingTd, setPendingTd] = useState(""); + const [isOpen, setIsOpen] = useState(false); const [errorDisplay, setErrorDisplay] = useState<{ state: boolean; @@ -135,7 +149,7 @@ const App: React.FC = () => { td = JSON.parse(td); context.updateOfflineTD(JSON.stringify(td, null, 2)); } catch (e) { - context.updateOfflineTD(td); + context.updateOfflineTD(td ?? ""); showError( `Tried to JSON parse the TD from local storage, but failed: ${e}` ); @@ -163,6 +177,56 @@ const App: React.FC = () => { return () => resizeObserver.disconnect(); }, []); + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== APP_A_ORIGIN) { + return; + } + + if (event.data?.type !== "LOAD_TD") { + return; + } + + if (typeof event.data.payload !== "string") { + return; + } + + try { + JSON.parse(event.data.payload); + setPendingTd(event.data.payload); + setIsOpen(true); + } catch { + showError("Received invalid JSON from the other application."); + } + }; + + window.addEventListener("message", handleMessage); + + if (window.opener) { + window.opener.postMessage( + { + type: "EDITDOR_READY", + }, + APP_A_ORIGIN + ); + } + + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + const onHandleEventRightButton = () => { + context.updateOfflineTD(pendingTd); + setPendingTd(""); + setIsOpen(false); + }; + + const onHandleEventLeftButton = () => { + setPendingTd(""); + setIsOpen(false); + }; + return (
@@ -207,6 +271,12 @@ const App: React.FC = () => { onClose={() => setErrorDisplay({ state: false, message: "" })} errorMessage={errorDisplay.message} /> + {isOpen && ( + + )}
); }; From 4f2578c57dc4d51094ad6c4cdc65eec13840a97c Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Tue, 31 Mar 2026 11:09:34 +0100 Subject: [PATCH 02/12] fix enhance commnunication and user experience Signed-off-by: Ricardo Silva --- src/App.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4fe9e232..b4da505f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ type ReadyMessage = { type LoadTdMessage = { type: "LOAD_TD"; + description: string; payload: string; }; @@ -52,7 +53,7 @@ const BREAKPOINTS = { // This variable prevents the callback from being executed twice. let checkedUrl = false; -const APP_A_ORIGIN = "http://localhost:5173"; +const APP_TMC_UI_ORIGIN = "http://localhost:5174"; const App: React.FC = () => { const context = useContext(ediTDorContext); @@ -61,7 +62,8 @@ const App: React.FC = () => { const [doShowJSON, setDoShowJSON] = useState(false); const [customBreakpointsState, setCustomBreakpointsState] = useState(0); const tdViewerRef = useRef(null); - const [pendingTd, setPendingTd] = useState(""); + const [pendingTd, setPendingTd] = useState(""); + const [pendingTitle, setPendingTitle] = useState(""); const [isOpen, setIsOpen] = useState(false); const [errorDisplay, setErrorDisplay] = useState<{ @@ -178,22 +180,27 @@ const App: React.FC = () => { }, []); useEffect(() => { + const readyMessage: ReadyMessage = { + type: "EDITDOR_READY", + }; + const handleMessage = (event: MessageEvent) => { - if (event.origin !== APP_A_ORIGIN) { + if (event.origin !== APP_TMC_UI_ORIGIN) { return; } - if (event.data?.type !== "LOAD_TD") { + if ((event.data as LoadTdMessage).type !== "LOAD_TD") { return; } - if (typeof event.data.payload !== "string") { + if (typeof (event.data as LoadTdMessage).payload !== "string") { return; } try { - JSON.parse(event.data.payload); - setPendingTd(event.data.payload); + JSON.parse((event.data as LoadTdMessage).payload); + setPendingTitle((event.data as LoadTdMessage).description); + setPendingTd((event.data as LoadTdMessage).payload); setIsOpen(true); } catch { showError("Received invalid JSON from the other application."); @@ -203,12 +210,7 @@ const App: React.FC = () => { window.addEventListener("message", handleMessage); if (window.opener) { - window.opener.postMessage( - { - type: "EDITDOR_READY", - }, - APP_A_ORIGIN - ); + window.opener.postMessage(readyMessage, APP_TMC_UI_ORIGIN); } return () => { @@ -273,7 +275,10 @@ const App: React.FC = () => { /> {isOpen && ( )} From 46511df47d452ddefb65dac8adefbcf6727baa3d Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Wed, 1 Apr 2026 11:22:43 +0100 Subject: [PATCH 03/12] feat improve error handling, typescript checks, rendering optimization Signed-off-by: Ricardo Silva --- src/App.tsx | 86 ++++++++++++++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b4da505f..f55eefb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,8 @@ import "./App.css"; import AppFooter from "./components/App/AppFooter"; import AppHeader from "./components/App/AppHeader"; import { Container, Section, Bar } from "@column-resizer/react"; -import { RefreshCw } from "react-feather"; import { decompressSharedTd } from "./share"; import { editor } from "monaco-editor"; -import BaseButton from "./components/TDViewer/base/BaseButton"; import ErrorDialog from "./components/Dialogs/ErrorDialog"; import DialogTemplate from "./components/Dialogs/DialogTemplate"; @@ -49,17 +47,12 @@ const BREAKPOINTS = { SMALL: 850, }; -// The useEffect hook for checking the URI was called twice somehow. -// This variable prevents the callback from being executed twice. -let checkedUrl = false; +const APP_TMC_UI_ORIGIN = "http://localhost:5175"; -const APP_TMC_UI_ORIGIN = "http://localhost:5174"; - -const App: React.FC = () => { +const App = () => { const context = useContext(ediTDorContext); const editorRef = useRef(null); - const [doShowJSON, setDoShowJSON] = useState(false); const [customBreakpointsState, setCustomBreakpointsState] = useState(0); const tdViewerRef = useRef(null); const [pendingTd, setPendingTd] = useState(""); @@ -90,8 +83,18 @@ const App: React.FC = () => { } }; - const handleToggleJSON = () => { - setDoShowJSON((prev) => !prev); + const isLoadTdMessage = (value: unknown): value is LoadTdMessage => { + if (typeof value !== "object" || value === null) { + return false; + } + + const message = value as Record; + + return ( + message.type === "LOAD_TD" && + typeof message.description === "string" && + typeof message.payload === "string" + ); }; useEffect(() => { @@ -109,24 +112,28 @@ const App: React.FC = () => { processedValue = value + "/"; } - localStorage.setItem(param, processedValue); + try { + localStorage.setItem(param, processedValue); + } catch { + showError("Failed to persist URL parameters to local storage."); + } } }); - }, [window.location.search]); + }, []); useEffect(() => { - if ( - checkedUrl || - (window.location.search.indexOf("td") <= -1 && - window.location.search.indexOf("proxyEndpoint") <= -1 && - window.location.search.indexOf("localstorage") <= -1 && - window.location.search.indexOf("southboundTdId") <= -1) - ) { + const url = new URL(window.location.href); + + const hasRelevantParam = + url.searchParams.has("td") || + url.searchParams.has("proxyEndpoint") || + url.searchParams.has("localstorage") || + url.searchParams.has("southboundTdId"); + + if (!hasRelevantParam) { return; } - checkedUrl = true; - const url = new URL(window.location.href); const compressedTd = url.searchParams.get("td"); if (compressedTd !== null) { const td = decompressSharedTd(compressedTd); @@ -141,23 +148,23 @@ const App: React.FC = () => { } if (url.searchParams.has("localstorage")) { - let td = localStorage.getItem("td"); - if (!td) { + const storedTd = localStorage.getItem("td"); + if (!storedTd) { showError("Request to read TD from local storage failed."); return; } try { - td = JSON.parse(td); - context.updateOfflineTD(JSON.stringify(td, null, 2)); - } catch (e) { - context.updateOfflineTD(td ?? ""); + const parsedTd: ThingDescription = JSON.parse(storedTd); + context.updateOfflineTD(JSON.stringify(parsedTd, null, 2)); + } catch (error) { + context.updateOfflineTD(storedTd); showError( - `Tried to JSON parse the TD from local storage, but failed: ${e}` + `Tried to JSON parse the TD from local storage, but failed: ${error}` ); } } - }, [context]); + }, []); useEffect(() => { if (!tdViewerRef.current) return; @@ -189,18 +196,18 @@ const App: React.FC = () => { return; } - if ((event.data as LoadTdMessage).type !== "LOAD_TD") { + if (event.source !== window.opener) { return; } - if (typeof (event.data as LoadTdMessage).payload !== "string") { + if (!isLoadTdMessage(event.data)) { return; } try { - JSON.parse((event.data as LoadTdMessage).payload); - setPendingTitle((event.data as LoadTdMessage).description); - setPendingTd((event.data as LoadTdMessage).payload); + JSON.parse(event.data.payload); + setPendingTitle(event.data.description); + setPendingTd(event.data.payload); setIsOpen(true); } catch { showError("Received invalid JSON from the other application."); @@ -253,15 +260,6 @@ const App: React.FC = () => {
- - - -
From 6d3580a1ca58aa37d653047de187305c2c6e5563 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Wed, 1 Apr 2026 18:47:07 +0100 Subject: [PATCH 04/12] feat add component testing for App.tsx Signed-off-by: Ricardo Silva --- package.json | 1 + src/App.test.tsx | 235 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/App.test.tsx diff --git a/package.json b/package.json index 1d0fa76e..492c3a05 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@babel/core": "^7.26.10", "@babel/eslint-parser": "^7.27.0", "@babel/preset-react": "^7.26.3", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@types/babel__core": "^7", diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 00000000..3bacd5f1 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,235 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { decompressSharedTd } from "./share"; +import App from "./App"; + +vi.mock("./components/Editor/JsonEditor", () => ({ + default: () =>
JsonEditor
, +})); + +vi.mock("./components/TDViewer/TDViewer", () => ({ + default: () =>
TDViewer
, +})); + +vi.mock("./components/App/AppHeader", () => ({ + default: () =>
AppHeader
, +})); + +vi.mock("./components/App/AppFooter", () => ({ + default: () =>
AppFooter
, +})); + +vi.mock("./components/Dialogs/ErrorDialog", () => ({ + default: ({ + isOpen, + errorMessage, + }: { + isOpen: boolean; + errorMessage: string; + }) => (isOpen ?
{errorMessage}
: null), +})); + +vi.mock("./components/Dialogs/DialogTemplate", () => ({ + default: ({ + title, + description, + onHandleEventRightButton, + onHandleEventLeftButton, + }: { + title: string; + description: string; + onHandleEventRightButton: () => void; + onHandleEventLeftButton: () => void; + }) => ( +
+
{title}
+
{description}
+ + +
+ ), +})); + +vi.mock("@column-resizer/react", () => ({ + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Bar: () =>
, +})); + +vi.mock("./share", () => ({ + decompressSharedTd: vi.fn(), +})); + +const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); + +const dispatchAppMessage = ({ + origin = "http://localhost:5175", + source = null, + data, +}: { + origin?: string; + source?: object | null; + data: unknown; +}) => { + const event = new MessageEvent("message", { + origin, + data, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: source, + }); + + window.dispatchEvent(event); +}; + +describe("App - component test", () => { + beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, "", "/"); + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: null, + }); + + class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + test("renders the basic app shell", () => { + render(); + + expect(screen.getByText("AppHeader")).toBeInTheDocument(); + expect(screen.getByText("TDViewer")).toBeInTheDocument(); + expect(screen.getByText("JsonEditor")).toBeInTheDocument(); + expect(screen.getByText("AppFooter")).toBeInTheDocument(); + }); + + test("shows an error when localstorage query param exists but td is missing", () => { + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + expect( + screen.getByText("Request to read TD from local storage failed.") + ).toBeInTheDocument(); + }); + test("shows an error when td query param cannot be decompressed", () => { + window.history.replaceState({}, "", "/?td=compressed-value"); + mockedDecompressSharedTd.mockReturnValue(undefined); + + render(); + + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); + expect( + screen.getByText( + "The lz compressed TD found in the URL couldn't be reconstructed." + ) + ).toBeInTheDocument(); + }); + + test("notifies the opener that editdor is ready", () => { + const postMessage = vi.fn(); + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: { postMessage }, + }); + + render(); + + expect(postMessage).toHaveBeenCalledWith( + { type: "EDITDOR_READY" }, + "http://localhost:5175" + ); + }); + + test("ignores messages from the wrong origin", () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + dispatchAppMessage({ + origin: "http://malicious.example", + source: openerRef, + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: '{"title":"Imported Thing"}', + }, + }); + + expect( + screen.queryByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).not.toBeInTheDocument(); + }); + + test("ignores messages from a source other than window.opener", () => { + const openerRef = { postMessage: vi.fn() }; + const otherSource = {}; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + dispatchAppMessage({ + source: otherSource, + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: '{"title":"Imported Thing"}', + }, + }); + + expect( + screen.queryByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).not.toBeInTheDocument(); + }); +}); From fcd05d85da6b83c800e28ceaecc33bc5527a5255 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Wed, 1 Apr 2026 18:47:59 +0100 Subject: [PATCH 05/12] feat add integration test for App.tsx Signed-off-by: Ricardo Silva --- src/tests/integration/App.test.tsx | 251 ++++++++++++++++++++++++++++ src/tests/integration/constants.tsx | 49 ++++++ yarn.lock | 66 +++++++- 3 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/tests/integration/App.test.tsx create mode 100644 src/tests/integration/constants.tsx diff --git a/src/tests/integration/App.test.tsx b/src/tests/integration/App.test.tsx new file mode 100644 index 00000000..b8f31f17 --- /dev/null +++ b/src/tests/integration/App.test.tsx @@ -0,0 +1,251 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from "@testing-library/react"; +import { useContext } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import App from "../../App"; +import ediTDorContext from "../../context/ediTDorContext"; +import { decompressSharedTd } from "../../share"; +import { + THING_DESCRIPTION_LAMP_V_STRING, + THING_DESCRIPTION_LAMP_JSON, +} from "./constants"; + +vi.mock("../../components/Editor/JsonEditor", () => ({ + default: () => { + const { offlineTD } = useContext(ediTDorContext); + + return
{offlineTD}
; + }, +})); + +vi.mock("../../components/TDViewer/TDViewer", () => ({ + default: () =>
TDViewer
, +})); + +vi.mock("../../share", () => ({ + decompressSharedTd: vi.fn(), +})); + +vi.mock("@column-resizer/react", () => ({ + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Bar: () =>
, +})); + +vi.mock("../../components/Dialogs/ConvertTmDialog", () => ({ + default: () => null, +})); + +vi.mock("../../components/Dialogs/CreateTdDialog", () => ({ + default: () => null, +})); + +vi.mock("../../components/Dialogs/ShareDialog", () => ({ + default: () => null, +})); + +vi.mock("../../components/Dialogs/ContributeToCatalogDialog", () => ({ + default: () => null, +})); + +vi.mock("../../components/Dialogs/SendTDDialog", () => ({ + default: () => null, +})); + +const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); + +describe("Integration test on params in the URI", () => { + beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, "", "/"); + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: null, + }); + + Object.defineProperty(window, "confirm", { + configurable: true, + writable: true, + value: vi.fn(() => true), + }); + + class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + mockedDecompressSharedTd.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + test("reads northbound, southbound and value path from url params and shows them in Settings", () => { + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080&southbound=http://localhost:9090&valuePath=/foo/bar" + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + const northboundInput = screen.getByLabelText(/target url northbound/i); + const southboundInput = screen.getByLabelText(/target url southbound/i); + const valuePathInput = screen.getByLabelText(/json pointer path/i); + + expect(northboundInput).toHaveValue("http://localhost:8080/"); + expect(southboundInput).toHaveValue("http://localhost:9090/"); + expect(valuePathInput).toHaveValue("/foo/bar"); + }); + test("reads northbound, southbound and value path with slashes from url params and shows them in Settings", () => { + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080/&southbound=http://localhost:9090/&valuePath=/foo/bar" + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + const northboundInput = screen.getByLabelText(/target url northbound/i); + const southboundInput = screen.getByLabelText(/target url southbound/i); + const valuePathInput = screen.getByLabelText(/json pointer path/i); + + expect(northboundInput).toHaveValue("http://localhost:8080/"); + expect(southboundInput).toHaveValue("http://localhost:9090/"); + expect(valuePathInput).toHaveValue("/foo/bar"); + }); + + test("loads a TD from the td query param into the editor state", async () => { + mockedDecompressSharedTd.mockReturnValue(THING_DESCRIPTION_LAMP_JSON); + + window.history.replaceState({}, "", "/?td=compressed-value"); + + render(); + + await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + + test("loads a TD from local storage when the localstorage query param is present", async () => { + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + + test("ignores proxyEndpoint and southboundTdId when no TD source is provided", async () => { + window.history.replaceState( + {}, + "", + "/?proxyEndpoint=http://localhost:3000&southboundTdId=device-7" + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect(mockedDecompressSharedTd).not.toHaveBeenCalled(); + expect( + screen.queryByText(/couldn't be reconstructed/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/request to read td from local storage failed/i) + ).not.toBeInTheDocument(); + }); + + test("still loads the td query param when proxyEndpoint and southboundTdId are also present", async () => { + mockedDecompressSharedTd.mockReturnValue(THING_DESCRIPTION_LAMP_JSON); + + window.history.replaceState( + {}, + "", + "/?td=combined&proxyEndpoint=http://localhost:3000&southboundTdId=device-7" + ); + + render(); + + await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("combined"); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + + test("still loads the local storage TD when southboundTdId is also present", async () => { + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState( + {}, + "", + "/?localstorage=1&southboundTdId=device-7" + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); +}); diff --git a/src/tests/integration/constants.tsx b/src/tests/integration/constants.tsx new file mode 100644 index 00000000..85263f4b --- /dev/null +++ b/src/tests/integration/constants.tsx @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +export const THING_DESCRIPTION_LAMP_JSON = { + "@context": "https://www.w3.org/ns/wot-next/td", + id: "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06", + title: "MyLampThing", + securityDefinitions: { + basic_sc: { scheme: "basic", in: "header" }, + }, + security: "basic_sc", + properties: { + status: { + type: "string", + forms: [{ href: "https://mylamp.example.com/status" }], + }, + }, + actions: { + toggle: { + forms: [{ href: "https://mylamp.example.com/toggle" }], + }, + }, + events: { + overheating: { + data: { type: "string" }, + forms: [ + { + href: "https://mylamp.example.com/oh", + subprotocol: "longpoll", + }, + ], + }, + }, +}; + +export const THING_DESCRIPTION_LAMP_V_STRING = JSON.stringify( + THING_DESCRIPTION_LAMP_JSON, + null, + 2 +); diff --git a/yarn.lock b/yarn.lock index 4d2c97a1..b3ad6b24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,6 +31,15 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" +"@babel/code-frame@^7.10.4": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" @@ -1088,6 +1097,20 @@ resolved "https://registry.yarnpkg.com/@servie/events/-/events-1.0.0.tgz#8258684b52d418ab7b86533e861186638ecc5dc1" integrity sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw== +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^6.8.0": version "6.9.1" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" @@ -1120,6 +1143,11 @@ wot-thing-model-types "^1.1.0-12-March-2025" wot-typescript-definitions "^0.8.0-SNAPSHOT.30" +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1432,6 +1460,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.1.0: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" @@ -1460,6 +1493,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + aria-query@^5.0.0: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" @@ -1943,6 +1983,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" @@ -1977,6 +2022,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-accessibility-api@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" @@ -3682,7 +3732,7 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== -picocolors@^1.0.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -3847,6 +3897,15 @@ prettier@^3.7.4: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -3942,6 +4001,11 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-papaparse@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/react-papaparse/-/react-papaparse-4.4.0.tgz#754b18c62240782d9b3b0bbc132b08ed6ec8ca13" From 9d3d8370e09fce74ad96895c21c2f6e4bba4b10e Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Mon, 6 Apr 2026 17:54:40 +0100 Subject: [PATCH 06/12] refactor integration tests, increase coverage Signed-off-by: Ricardo Silva --- src/App.test.tsx | 235 --------- .../App.test.tsx => App.integration.test.tsx} | 109 +++- src/tests/AppFooter.integration.test.tsx | 72 +++ src/tests/AppHeader.integration.test.tsx | 474 ++++++++++++++++++ src/tests/constants.tsx | 124 +++++ src/tests/integration/constants.tsx | 49 -- 6 files changed, 758 insertions(+), 305 deletions(-) delete mode 100644 src/App.test.tsx rename src/tests/{integration/App.test.tsx => App.integration.test.tsx} (75%) create mode 100644 src/tests/AppFooter.integration.test.tsx create mode 100644 src/tests/AppHeader.integration.test.tsx create mode 100644 src/tests/constants.tsx delete mode 100644 src/tests/integration/constants.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 3bacd5f1..00000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { decompressSharedTd } from "./share"; -import App from "./App"; - -vi.mock("./components/Editor/JsonEditor", () => ({ - default: () =>
JsonEditor
, -})); - -vi.mock("./components/TDViewer/TDViewer", () => ({ - default: () =>
TDViewer
, -})); - -vi.mock("./components/App/AppHeader", () => ({ - default: () =>
AppHeader
, -})); - -vi.mock("./components/App/AppFooter", () => ({ - default: () =>
AppFooter
, -})); - -vi.mock("./components/Dialogs/ErrorDialog", () => ({ - default: ({ - isOpen, - errorMessage, - }: { - isOpen: boolean; - errorMessage: string; - }) => (isOpen ?
{errorMessage}
: null), -})); - -vi.mock("./components/Dialogs/DialogTemplate", () => ({ - default: ({ - title, - description, - onHandleEventRightButton, - onHandleEventLeftButton, - }: { - title: string; - description: string; - onHandleEventRightButton: () => void; - onHandleEventLeftButton: () => void; - }) => ( -
-
{title}
-
{description}
- - -
- ), -})); - -vi.mock("@column-resizer/react", () => ({ - Container: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Section: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - Bar: () =>
, -})); - -vi.mock("./share", () => ({ - decompressSharedTd: vi.fn(), -})); - -const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); - -const dispatchAppMessage = ({ - origin = "http://localhost:5175", - source = null, - data, -}: { - origin?: string; - source?: object | null; - data: unknown; -}) => { - const event = new MessageEvent("message", { - origin, - data, - }); - - Object.defineProperty(event, "source", { - configurable: true, - value: source, - }); - - window.dispatchEvent(event); -}; - -describe("App - component test", () => { - beforeEach(() => { - localStorage.clear(); - window.history.replaceState({}, "", "/"); - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: null, - }); - - class ResizeObserverMock { - observe() {} - disconnect() {} - unobserve() {} - } - - vi.stubGlobal("ResizeObserver", ResizeObserverMock); - }); - - afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - localStorage.clear(); - window.history.replaceState({}, "", "/"); - }); - - test("renders the basic app shell", () => { - render(); - - expect(screen.getByText("AppHeader")).toBeInTheDocument(); - expect(screen.getByText("TDViewer")).toBeInTheDocument(); - expect(screen.getByText("JsonEditor")).toBeInTheDocument(); - expect(screen.getByText("AppFooter")).toBeInTheDocument(); - }); - - test("shows an error when localstorage query param exists but td is missing", () => { - window.history.replaceState({}, "", "/?localstorage=1"); - - render(); - - expect( - screen.getByText("Request to read TD from local storage failed.") - ).toBeInTheDocument(); - }); - test("shows an error when td query param cannot be decompressed", () => { - window.history.replaceState({}, "", "/?td=compressed-value"); - mockedDecompressSharedTd.mockReturnValue(undefined); - - render(); - - expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); - expect( - screen.getByText( - "The lz compressed TD found in the URL couldn't be reconstructed." - ) - ).toBeInTheDocument(); - }); - - test("notifies the opener that editdor is ready", () => { - const postMessage = vi.fn(); - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: { postMessage }, - }); - - render(); - - expect(postMessage).toHaveBeenCalledWith( - { type: "EDITDOR_READY" }, - "http://localhost:5175" - ); - }); - - test("ignores messages from the wrong origin", () => { - const openerRef = { postMessage: vi.fn() }; - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: openerRef, - }); - - render(); - - dispatchAppMessage({ - origin: "http://malicious.example", - source: openerRef, - data: { - type: "LOAD_TD", - description: "Imported TD", - payload: '{"title":"Imported Thing"}', - }, - }); - - expect( - screen.queryByText( - 'The Thing Description "Imported TD" was received from the other application.' - ) - ).not.toBeInTheDocument(); - }); - - test("ignores messages from a source other than window.opener", () => { - const openerRef = { postMessage: vi.fn() }; - const otherSource = {}; - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: openerRef, - }); - - render(); - - dispatchAppMessage({ - source: otherSource, - data: { - type: "LOAD_TD", - description: "Imported TD", - payload: '{"title":"Imported Thing"}', - }, - }); - - expect( - screen.queryByText( - 'The Thing Description "Imported TD" was received from the other application.' - ) - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/tests/integration/App.test.tsx b/src/tests/App.integration.test.tsx similarity index 75% rename from src/tests/integration/App.test.tsx rename to src/tests/App.integration.test.tsx index b8f31f17..ef82b1eb 100644 --- a/src/tests/integration/App.test.tsx +++ b/src/tests/App.integration.test.tsx @@ -19,15 +19,29 @@ import { } from "@testing-library/react"; import { useContext } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import App from "../../App"; -import ediTDorContext from "../../context/ediTDorContext"; -import { decompressSharedTd } from "../../share"; +import App from "../App"; +import ediTDorContext from "../context/ediTDorContext"; +import { decompressSharedTd } from "../share"; import { THING_DESCRIPTION_LAMP_V_STRING, THING_DESCRIPTION_LAMP_JSON, } from "./constants"; -vi.mock("../../components/Editor/JsonEditor", () => ({ +vi.mock("@node-wot/browser-bundle", () => ({ + Core: { + Servient: vi.fn().mockImplementation(() => ({ + addClientFactory: vi.fn(), + start: vi.fn().mockResolvedValue({ + consume: vi.fn(), + }), + })), + }, + Http: { + HttpClientFactory: vi.fn(), + }, +})); + +vi.mock("../components/Editor/JsonEditor", () => ({ default: () => { const { offlineTD } = useContext(ediTDorContext); @@ -35,11 +49,11 @@ vi.mock("../../components/Editor/JsonEditor", () => ({ }, })); -vi.mock("../../components/TDViewer/TDViewer", () => ({ +vi.mock("../components/TDViewer/TDViewer", () => ({ default: () =>
TDViewer
, })); -vi.mock("../../share", () => ({ +vi.mock("../share", () => ({ decompressSharedTd: vi.fn(), })); @@ -53,25 +67,78 @@ vi.mock("@column-resizer/react", () => ({ Bar: () =>
, })); -vi.mock("../../components/Dialogs/ConvertTmDialog", () => ({ - default: () => null, -})); +vi.mock("../components/Dialogs/ConvertTmDialog", async () => { + const React = await import("react"); -vi.mock("../../components/Dialogs/CreateTdDialog", () => ({ - default: () => null, -})); + const MockConvertTmDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); -vi.mock("../../components/Dialogs/ShareDialog", () => ({ - default: () => null, -})); + return null; + }); -vi.mock("../../components/Dialogs/ContributeToCatalogDialog", () => ({ - default: () => null, -})); + return { default: MockConvertTmDialog }; +}); -vi.mock("../../components/Dialogs/SendTDDialog", () => ({ - default: () => null, -})); +vi.mock("../components/Dialogs/CreateTdDialog", async () => { + const React = await import("react"); + + const MockCreateTdDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + })); + + return null; + }); + + return { default: MockCreateTdDialog }; +}); + +vi.mock("../components/Dialogs/ShareDialog", async () => { + const React = await import("react"); + + const MockShareDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + })); + + return null; + }); + + return { default: MockShareDialog }; +}); + +vi.mock("../components/Dialogs/ContributeToCatalogDialog", async () => { + const React = await import("react"); + + const MockContributeToCatalogDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + + return null; + }); + + return { default: MockContributeToCatalogDialog }; +}); + +vi.mock("../components/Dialogs/SendTDDialog", async () => { + const React = await import("react"); + + const MockSendTdDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + + return null; + }); + + return { default: MockSendTdDialog }; +}); const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); diff --git a/src/tests/AppFooter.integration.test.tsx b/src/tests/AppFooter.integration.test.tsx new file mode 100644 index 00000000..e2434266 --- /dev/null +++ b/src/tests/AppFooter.integration.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import AppFooter from "../components/App/AppFooter"; +import ediTDorContext from "../context/ediTDorContext"; +import { + THING_DESCRIPTION_LAMP_JSON, + THING_DESCRIPTION_LAMP_V_STRING, + createContextValue, +} from "./constants"; + +const renderWithContext = (contextOverrides: Partial = {}) => + render( + + + + ); + +describe("Integration test on rendering elements", () => { + test("renders counts, size, northbound state, version, and GitHub link from context", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + northboundConnection: { + message: "Connected", + northboundTd: THING_DESCRIPTION_LAMP_JSON, + }, + isModified: true, + }); + + expect(screen.getByRole("contentinfo")).toBeInTheDocument(); + expect(screen.getByText("Properties: 1")).toBeInTheDocument(); + expect(screen.getByText("| Actions: 1")).toBeInTheDocument(); + expect(screen.getByText("| Events: 1")).toBeInTheDocument(); + expect( + screen.getByText( + `| Size: ${THING_DESCRIPTION_LAMP_V_STRING.length} bytes` + ) + ).toBeInTheDocument(); + expect( + screen.getByText(/northbound state:\s*connected/i) + ).toBeInTheDocument(); + expect(screen.getByText(/you have unsaved changes/i)).toBeInTheDocument(); + + const githubLink = screen.getByRole("link"); + expect(githubLink).toHaveAttribute( + "href", + "https://github.com/eclipse-editdor/editdor" + ); + }); + + test("renders zero counts and unknown northbound state for an empty TD", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + northboundConnection: undefined, + isModified: false, + }); + + expect(screen.getByRole("contentinfo")).toBeInTheDocument(); + expect(screen.getByText("Properties: 0")).toBeInTheDocument(); + expect(screen.getByText("| Actions: 0")).toBeInTheDocument(); + expect(screen.getByText("| Events: 0")).toBeInTheDocument(); + expect(screen.getByText("| Size: 0 bytes")).toBeInTheDocument(); + expect( + screen.getByText(/northbound state:\s*unknown/i) + ).toBeInTheDocument(); + expect( + screen.queryByText(/you have unsaved changes/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/tests/AppHeader.integration.test.tsx b/src/tests/AppHeader.integration.test.tsx new file mode 100644 index 00000000..699d0a7b --- /dev/null +++ b/src/tests/AppHeader.integration.test.tsx @@ -0,0 +1,474 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import AppHeader from "../components/App/AppHeader"; +import ediTDorContext from "../context/ediTDorContext"; +import * as fileTdService from "../services/fileTdService"; +import { + THING_DESCRIPTION_LAMP_JSON, + THING_DESCRIPTION_LAMP_V_STRING, + createContextValue, +} from "./constants"; + +vi.mock("../services/fileTdService", () => ({ + readFromFile: vi.fn(), + saveToFile: vi.fn(), +})); + +vi.mock("../components/Dialogs/ErrorDialog", () => ({ + default: ({ + isOpen, + errorMessage, + onClose, + }: { + isOpen: boolean; + errorMessage: string; + onClose: () => void; + }) => + isOpen ? ( +
+

{errorMessage}

+ +
+ ) : null, +})); + +vi.mock("../components/Dialogs/ConvertTmDialog", async () => { + const React = await import("react"); + + const MockConvertTmDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Convert TM Dialog
: null; + }); + + return { default: MockConvertTmDialog }; +}); + +vi.mock("../components/Dialogs/CreateTdDialog", async () => { + const React = await import("react"); + + const MockCreateTdDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + })); + + return opened ?
Create TD Dialog
: null; + }); + + return { default: MockCreateTdDialog }; +}); + +vi.mock("../components/Dialogs/SettingsDialog", async () => { + const React = await import("react"); + + const MockSettingsDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Settings Dialog
: null; + }); + + return { default: MockSettingsDialog }; +}); + +vi.mock("../components/Dialogs/ShareDialog", async () => { + const React = await import("react"); + + const MockShareDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + })); + + return opened ?
Share Dialog
: null; + }); + + return { default: MockShareDialog }; +}); + +vi.mock("../components/Dialogs/ContributeToCatalogDialog", async () => { + const React = await import("react"); + + const MockContributeDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Contribute To Catalog Dialog
: null; + }); + + return { default: MockContributeDialog }; +}); + +vi.mock("../components/Dialogs/SendTDDialog", async () => { + const React = await import("react"); + + const MockSendTDDialog = React.forwardRef( + ({ currentTdId }: { currentTdId: string }, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Send TD Dialog for {currentTdId}
: null; + } + ); + + return { default: MockSendTDDialog }; +}); + +const mockedReadFromFile = vi.mocked(fileTdService.readFromFile); +const mockedSaveToFile = vi.mocked(fileTdService.saveToFile); + +const renderWithContext = (contextOverrides: Partial = {}) => { + const contextValue = createContextValue(contextOverrides); + + const view = render( + + + + ); + + return { + ...view, + contextValue, + }; +}; + +describe("Integration test on rendering elements, actions and errors", () => { + beforeEach(() => { + localStorage.clear(); + mockedReadFromFile.mockReset(); + mockedSaveToFile.mockReset(); + + Object.defineProperty(window, "confirm", { + configurable: true, + writable: true, + value: vi.fn(() => true), + }); + + Object.defineProperty(window, "open", { + configurable: true, + writable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + test("renders the header and primary actions", () => { + renderWithContext(); + + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /logo/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /send td/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /contribute to catalog/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^share$/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /settings/i }) + ).toBeInTheDocument(); + }); + + test("opens the project site when the logo button is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /logo/i })); + + expect(window.open).toHaveBeenCalledWith( + "https://eclipse-editdor.github.io/editdor/", + "_blank" + ); + }); + + test("shows the create dialog when Create is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /create/i })); + + expect(screen.getByText("Create TD Dialog")).toBeInTheDocument(); + }); + + test("shows the share dialog when Share is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /^share$/i })); + + expect(screen.getByText("Share Dialog")).toBeInTheDocument(); + }); + + test("shows the settings dialog when Settings is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + expect(screen.getByText("Settings Dialog")).toBeInTheDocument(); + }); + + test("shows an error when Send TD is clicked without a TD loaded", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(/no thing description available to send/i) + ).toBeInTheDocument(); + }); + + test("shows an error when Send TD is clicked without a configured southbound URL", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(/no southbound url available/i) + ).toBeInTheDocument(); + }); + + test("opens the Send TD dialog when TD, southbound URL, and id are available", () => { + localStorage.setItem("southbound", "http://localhost:8080"); + + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(`Send TD Dialog for ${THING_DESCRIPTION_LAMP_JSON.id}`) + ).toBeInTheDocument(); + }); + + test("shows an error when Contribute to Catalog is clicked without a loaded TM", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText(/please first load a thing model to be validated/i) + ).toBeInTheDocument(); + }); + + test("shows an error when Contribute to Catalog is clicked for a non-TM document", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText(/the tm must have the following pair key\/value/i) + ).toBeInTheDocument(); + }); + + test("opens the Contribute to Catalog dialog for a valid TM", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: { + ...THING_DESCRIPTION_LAMP_JSON, + "@type": "tm:ThingModel", + }, + validationMessage: { + report: { + json: null, + schema: "passed", + defaults: null, + jsonld: null, + additional: null, + }, + details: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + detailComments: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + validationErrors: { + json: "", + schema: "", + }, + customMessage: "", + }, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText("Contribute To Catalog Dialog") + ).toBeInTheDocument(); + }); + + test("renders the To TD button only for Thing Models", () => { + const { rerender } = render( + + + + ); + + expect( + screen.queryByRole("button", { name: /to td/i }) + ).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByRole("button", { name: /to td/i })).toBeInTheDocument(); + }); + + test("opens the Convert TM dialog when To TD is clicked", () => { + renderWithContext({ + parsedTD: { + ...THING_DESCRIPTION_LAMP_JSON, + "@type": "tm:ThingModel", + }, + }); + + fireEvent.click(screen.getByRole("button", { name: /to td/i })); + + expect(screen.getByText("Convert TM Dialog")).toBeInTheDocument(); + }); + + test("opens a TD from file and updates context state", async () => { + mockedReadFromFile.mockResolvedValue({ + td: THING_DESCRIPTION_LAMP_V_STRING, + fileName: "lamp.jsonld", + fileHandle: "mock-handle", + }); + + const { contextValue } = renderWithContext({ + isModified: false, + }); + + fireEvent.click(screen.getByRole("button", { name: /open/i })); + + await waitFor(() => { + expect(mockedReadFromFile).toHaveBeenCalled(); + }); + + expect(contextValue.updateOfflineTD).toHaveBeenCalledWith( + THING_DESCRIPTION_LAMP_V_STRING + ); + expect(contextValue.updateIsModified).toHaveBeenCalledWith(false); + expect(contextValue.setFileHandle).toHaveBeenCalledWith("mock-handle"); + expect(contextValue.updateLinkedTd).toHaveBeenCalledWith(undefined); + expect(contextValue.addLinkedTd).toHaveBeenCalledWith({ + "./lamp.jsonld": "mock-handle", + }); + }); + + test("asks for confirmation before opening a new file when the TD is modified", async () => { + mockedReadFromFile.mockResolvedValue({ + td: THING_DESCRIPTION_LAMP_V_STRING, + fileName: "lamp.jsonld", + fileHandle: "mock-handle", + }); + + renderWithContext({ + isModified: true, + }); + + fireEvent.click(screen.getByRole("button", { name: /open/i })); + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalledWith( + "Discard changes? All changes you made to your TD will be lost." + ); + }); + }); + + test("saves the current TD when Download is clicked", async () => { + mockedSaveToFile.mockResolvedValue("saved-handle"); + + const { contextValue } = renderWithContext({ + name: "lamp", + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + fileHandle: null, + }); + + fireEvent.click(screen.getByRole("button", { name: /download/i })); + + await waitFor(() => { + expect(mockedSaveToFile).toHaveBeenCalledWith( + "lamp", + undefined, + THING_DESCRIPTION_LAMP_V_STRING + ); + }); + + expect(contextValue.setFileHandle).toHaveBeenCalledWith("saved-handle"); + expect(contextValue.updateIsModified).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/tests/constants.tsx b/src/tests/constants.tsx new file mode 100644 index 00000000..574241c0 --- /dev/null +++ b/src/tests/constants.tsx @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { vi } from "vitest"; +export const THING_DESCRIPTION_LAMP_JSON = { + "@context": "https://www.w3.org/ns/wot-next/td", + id: "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06", + title: "MyLampThing", + securityDefinitions: { + basic_sc: { scheme: "basic", in: "header" }, + }, + security: "basic_sc", + properties: { + status: { + type: "string", + forms: [{ href: "https://mylamp.example.com/status" }], + }, + }, + actions: { + toggle: { + forms: [{ href: "https://mylamp.example.com/toggle" }], + }, + }, + events: { + overheating: { + data: { type: "string" }, + forms: [ + { + href: "https://mylamp.example.com/oh", + subprotocol: "longpoll", + }, + ], + }, + }, +}; + +export const THING_DESCRIPTION_LAMP_V_STRING = JSON.stringify( + THING_DESCRIPTION_LAMP_JSON, + null, + 2 +); + +export const createContextValue = ( + overrides: Partial = {} +): IEdiTDorContext => ({ + offlineTD: "", + isValidJSON: true, + parsedTD: {}, + isModified: false, + name: "", + fileHandle: null, + linkedTd: undefined, + validationMessage: { + report: { + json: null, + schema: null, + defaults: null, + jsonld: null, + additional: null, + }, + details: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + detailComments: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + validationErrors: { + json: "", + schema: "", + }, + customMessage: "", + }, + northboundConnection: { + message: "", + northboundTd: {}, + }, + contributeCatalog: { + model: "", + author: "", + manufacturer: "", + license: "", + copyrightYear: "", + holder: "", + tmCatalogEndpoint: "", + nameRepository: "", + dynamicValues: {}, + }, + updateOfflineTD: vi.fn(), + updateIsModified: vi.fn(), + setFileHandle: vi.fn(), + removeForm: vi.fn(), + addForm: vi.fn(), + removeLink: vi.fn(), + removeOneOfAKindReducer: vi.fn(), + addLinkedTd: vi.fn(), + updateLinkedTd: vi.fn(), + updateValidationMessage: vi.fn(), + updateNorthboundConnection: vi.fn(), + updateContributeCatalog: vi.fn(), + ...overrides, +}); diff --git a/src/tests/integration/constants.tsx b/src/tests/integration/constants.tsx deleted file mode 100644 index 85263f4b..00000000 --- a/src/tests/integration/constants.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2026 Contributors to the Eclipse Foundation - * - * See the NOTICE file(s) distributed with this work for additional - * information regarding copyright ownership. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and - * - * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 - ********************************************************************************/ -export const THING_DESCRIPTION_LAMP_JSON = { - "@context": "https://www.w3.org/ns/wot-next/td", - id: "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06", - title: "MyLampThing", - securityDefinitions: { - basic_sc: { scheme: "basic", in: "header" }, - }, - security: "basic_sc", - properties: { - status: { - type: "string", - forms: [{ href: "https://mylamp.example.com/status" }], - }, - }, - actions: { - toggle: { - forms: [{ href: "https://mylamp.example.com/toggle" }], - }, - }, - events: { - overheating: { - data: { type: "string" }, - forms: [ - { - href: "https://mylamp.example.com/oh", - subprotocol: "longpoll", - }, - ], - }, - }, -}; - -export const THING_DESCRIPTION_LAMP_V_STRING = JSON.stringify( - THING_DESCRIPTION_LAMP_JSON, - null, - 2 -); From 23e08702e866e795776744d7c6cceaa6fb42cb77 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Tue, 7 Apr 2026 11:34:53 +0100 Subject: [PATCH 07/12] add integration tests, fix bug found on integrationt test Signed-off-by: Ricardo Silva --- src/App.tsx | 5 +- src/tests/App.integration.test.tsx | 100 +++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f55eefb9..d01e2edc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,10 +125,7 @@ const App = () => { const url = new URL(window.location.href); const hasRelevantParam = - url.searchParams.has("td") || - url.searchParams.has("proxyEndpoint") || - url.searchParams.has("localstorage") || - url.searchParams.has("southboundTdId"); + url.searchParams.has("td") || url.searchParams.has("localstorage"); if (!hasRelevantParam) { return; diff --git a/src/tests/App.integration.test.tsx b/src/tests/App.integration.test.tsx index ef82b1eb..9deacf0d 100644 --- a/src/tests/App.integration.test.tsx +++ b/src/tests/App.integration.test.tsx @@ -142,7 +142,7 @@ vi.mock("../components/Dialogs/SendTDDialog", async () => { const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); -describe("Integration test on params in the URI", () => { +describe("App component URL bootstrapping logic", () => { beforeEach(() => { localStorage.clear(); window.history.replaceState({}, "", "/"); @@ -250,13 +250,38 @@ describe("Integration test on params in the URI", () => { ); }); }); + test("shows an error when localstorage query param exists but td is missing", async () => { + window.history.replaceState({}, "", "/?localstorage=1"); - test("ignores proxyEndpoint and southboundTdId when no TD source is provided", async () => { - window.history.replaceState( - {}, - "", - "/?proxyEndpoint=http://localhost:3000&southboundTdId=device-7" - ); + render(); + + await waitFor(() => { + expect( + screen.getByText(/request to read td from local storage failed/i) + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + test("loads invalid JSON from local storage as raw text and shows an error", async () => { + localStorage.setItem("td", "not-json"); + + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent("not-json"); + expect( + screen.getByText( + /tried to json parse the td from local storage, but failed/i + ) + ).toBeInTheDocument(); + }); + }); + test("does nothing when no relevant URL params are present", async () => { + window.history.replaceState({}, "", "/"); render(); @@ -272,41 +297,38 @@ describe("Integration test on params in the URI", () => { screen.queryByText(/request to read td from local storage failed/i) ).not.toBeInTheDocument(); }); + test("shows an error when the td query param cannot be decompressed", async () => { + mockedDecompressSharedTd.mockReturnValue(undefined); - test("still loads the td query param when proxyEndpoint and southboundTdId are also present", async () => { - mockedDecompressSharedTd.mockReturnValue(THING_DESCRIPTION_LAMP_JSON); - - window.history.replaceState( - {}, - "", - "/?td=combined&proxyEndpoint=http://localhost:3000&southboundTdId=device-7" - ); + window.history.replaceState({}, "", "/?td=broken-value"); render(); await waitFor(() => { - expect(mockedDecompressSharedTd).toHaveBeenCalledWith("combined"); - expect(screen.getByTestId("offline-td")).toHaveTextContent( - '"title": "MyLampThing"' - ); - expect(screen.getByTestId("offline-td")).toHaveTextContent( - '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' - ); + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("broken-value"); + expect( + screen.getByText( + /the lz compressed td found in the url couldn't be reconstructed/i + ) + ).toBeInTheDocument(); }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); }); + test("loads local storage after td when both query params are present", async () => { + mockedDecompressSharedTd.mockReturnValue({ + title: "FromCompressedTd", + id: "urn:compressed", + }); - test("still loads the local storage TD when southboundTdId is also present", async () => { localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); - window.history.replaceState( - {}, - "", - "/?localstorage=1&southboundTdId=device-7" - ); + window.history.replaceState({}, "", "/?td=compressed-value&localstorage=1"); render(); await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); expect(screen.getByTestId("offline-td")).toHaveTextContent( '"title": "MyLampThing"' ); @@ -314,5 +336,27 @@ describe("Integration test on params in the URI", () => { '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' ); }); + + expect(screen.getByTestId("offline-td")).not.toHaveTextContent( + '"title": "FromCompressedTd"' + ); + }); + test("does not read local storage when td decompression fails even if localstorage is present", async () => { + mockedDecompressSharedTd.mockReturnValue(undefined); + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState({}, "", "/?td=broken-value&localstorage=1"); + + render(); + + await waitFor(() => { + expect( + screen.getByText( + /the lz compressed td found in the url couldn't be reconstructed/i + ) + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); }); }); From 2f8849c7c5f5af3d2589ca688826da7ca4374a7c Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Tue, 7 Apr 2026 17:07:40 +0100 Subject: [PATCH 08/12] docs add postMessage Api communication instructions to readme Signed-off-by: Ricardo Silva --- README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 430e9634..9aebf61f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Please follow our [contribution guide](./CONTRIBUTING.md). In the development environment it is possible to use [react scan](https://react-scan.com/) to detect performance issues by analyzing the pop-up on the bottom right corner. The complete documentation is available [here](https://github.com/aidenybai/react-scan#readme). +## Features + ### Using the Catalog Contribution Feature You will need a [Thing Model Catalog](https://github.com/wot-oss/tmc) running somewhere. If you want to host it yourself, use the command-line interface to run one in the terminal using the following instructions: @@ -85,7 +87,7 @@ A local repository folder will be created inside the tm-catalog directory tmc repo remove ``` -### Send TD feature +### Using Send TD feature #### Northbound and Southbound URLs @@ -122,7 +124,7 @@ Afterwards, if the service proxies the TD, ediTDor can fetch the proxied TD cont The proxy uses the TD sent to its southbound API endpoint to communicate with a Thing. This way, you can interact with a non-HTTP Thing from your ediTDor. -### Automatically reading URL parameters +### Using URL query parameters feature The ediTDor has the functionality to automatically set the following list of variables from a URL with query parameters: @@ -147,6 +149,30 @@ Example of use: http://localhost:5173/?northbound=http://localhost:8080&southbound=http://github.com&valuePath=/value +### Using postMessage API communication feature + +The ediTDor can receive a Thing Description from another web application through the browser `postMessage` API (Documentation [here](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)). When ediTDor is opened by another application in a new window or tab, it sends a readiness message back to the opener: + +```json +{ "type": "EDITDOR_READY" } +``` + +After that, the parent application can send a Thing Description to ediTDor with a message in the following format: + +```json +{ + "type": "LOAD_TD", + "description": "Imported TD", + "payload": "{ \"@context\": \"https://www.w3.org/ns/wot-next/td\", \"title\": \"MyThing\" }" +} +``` + +- **type** must be LOAD_TD +- **description** is a string to show in the confirmation dialog, e.g. title, id +- **payload** must be a valid JSON string containing the Thing Description + +When a valid message is received, ediTDor shows a confirmation dialog before loading the TD into the editor. If the payload is not valid JSON, an error message is shown instead. + ## Implemented Features: - JSON Editor with JSON Schema support for TD (Autocompletion, JSON Schema Validation) From 1c9ed0172fe173034de314efb14a9081953cd960 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Wed, 8 Apr 2026 15:42:10 +0100 Subject: [PATCH 09/12] feat remove restrictionin origin Signed-off-by: Ricardo Silva --- src/App.tsx | 8 +- src/tests/App.integration.test.tsx | 329 ++++++++++++++++++++++++++--- 2 files changed, 300 insertions(+), 37 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d01e2edc..8a285431 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,8 +47,6 @@ const BREAKPOINTS = { SMALL: 850, }; -const APP_TMC_UI_ORIGIN = "http://localhost:5175"; - const App = () => { const context = useContext(ediTDorContext); @@ -189,10 +187,6 @@ const App = () => { }; const handleMessage = (event: MessageEvent) => { - if (event.origin !== APP_TMC_UI_ORIGIN) { - return; - } - if (event.source !== window.opener) { return; } @@ -214,7 +208,7 @@ const App = () => { window.addEventListener("message", handleMessage); if (window.opener) { - window.opener.postMessage(readyMessage, APP_TMC_UI_ORIGIN); + window.opener.postMessage(readyMessage, "*"); } return () => { diff --git a/src/tests/App.integration.test.tsx b/src/tests/App.integration.test.tsx index 9deacf0d..c5b91051 100644 --- a/src/tests/App.integration.test.tsx +++ b/src/tests/App.integration.test.tsx @@ -142,41 +142,41 @@ vi.mock("../components/Dialogs/SendTDDialog", async () => { const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); -describe("App component URL bootstrapping logic", () => { - beforeEach(() => { - localStorage.clear(); - window.history.replaceState({}, "", "/"); - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: null, - }); +beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, "", "/"); + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: null, + }); - Object.defineProperty(window, "confirm", { - configurable: true, - writable: true, - value: vi.fn(() => true), - }); + Object.defineProperty(window, "confirm", { + configurable: true, + writable: true, + value: vi.fn(() => true), + }); - class ResizeObserverMock { - observe() {} - disconnect() {} - unobserve() {} - } + class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} + } - vi.stubGlobal("ResizeObserver", ResizeObserverMock); - mockedDecompressSharedTd.mockReset(); - }); + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + mockedDecompressSharedTd.mockReset(); +}); - afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - localStorage.clear(); - window.history.replaceState({}, "", "/"); - }); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, "", "/"); +}); +describe("App component URL bootstrapping logic", () => { test("reads northbound, southbound and value path from url params and shows them in Settings", () => { window.history.replaceState( {}, @@ -359,4 +359,273 @@ describe("App component URL bootstrapping logic", () => { expect(screen.getByTestId("offline-td")).toHaveTextContent(""); }); + test("shows an error when persisting URL params to local storage fails", async () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("storage failed"); + }); + + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080&southbound=http://localhost:9090&valuePath=/foo/bar" + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/failed to persist url parameters to local storage/i) + ).toBeInTheDocument(); + }); + + setItemSpy.mockRestore(); + }); +}); + +describe("App component receive message from other application", () => { + test("notifies the opener that editdor is ready on mount", () => { + const openerRef = { + postMessage: vi.fn(), + }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + expect(openerRef.postMessage).toHaveBeenCalledWith( + { type: "EDITDOR_READY" }, + "http://localhost:5175" + ); + }); + test("loads a TD received from the other application after confirmation", async () => { + const openerRef = { + postMessage: vi.fn(), + }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + window.dispatchEvent(event); + + expect( + await screen.findByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + test("ignores LOAD_TD messages from the wrong origin", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://malicious.example", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); + test("ignores LOAD_TD messages from a source other than window.opener", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: {}, + }); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); + test("shows an error when the other application sends invalid JSON", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Broken TD", + payload: "not-json", + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + window.dispatchEvent(event); + + expect( + await screen.findByText( + /received invalid json from the other application/i + ) + ).toBeInTheDocument(); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + test("does not load the received TD when the user cancels", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + window.dispatchEvent(event); + + expect( + await screen.findByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); + test("ignores messages that are not valid LOAD_TD payloads", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { type: "LOAD_TD", payload: THING_DESCRIPTION_LAMP_V_STRING }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + window.dispatchEvent(event); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); }); From 14ca4192faa3a939c1b5179788319233451b714d Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Wed, 8 Apr 2026 15:45:13 +0100 Subject: [PATCH 10/12] test fix integration tests Signed-off-by: Ricardo Silva --- src/tests/App.integration.test.tsx | 41 +++--------------------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/src/tests/App.integration.test.tsx b/src/tests/App.integration.test.tsx index c5b91051..b4dd67de 100644 --- a/src/tests/App.integration.test.tsx +++ b/src/tests/App.integration.test.tsx @@ -399,8 +399,10 @@ describe("App component receive message from other application", () => { render(); expect(openerRef.postMessage).toHaveBeenCalledWith( - { type: "EDITDOR_READY" }, - "http://localhost:5175" + { + type: "EDITDOR_READY", + }, + "*" ); }); test("loads a TD received from the other application after confirmation", async () => { @@ -449,41 +451,6 @@ describe("App component receive message from other application", () => { ); }); }); - test("ignores LOAD_TD messages from the wrong origin", async () => { - const openerRef = { postMessage: vi.fn() }; - - Object.defineProperty(window, "opener", { - configurable: true, - writable: true, - value: openerRef, - }); - - render(); - - const event = new MessageEvent("message", { - origin: "http://malicious.example", - data: { - type: "LOAD_TD", - description: "Imported TD", - payload: THING_DESCRIPTION_LAMP_V_STRING, - }, - }); - - Object.defineProperty(event, "source", { - configurable: true, - value: openerRef, - }); - - window.dispatchEvent(event); - - await waitFor(() => { - expect(screen.getByTestId("offline-td")).toHaveTextContent(""); - }); - - expect( - screen.queryByText(/received from the other application/i) - ).not.toBeInTheDocument(); - }); test("ignores LOAD_TD messages from a source other than window.opener", async () => { const openerRef = { postMessage: vi.fn() }; From f33b7bc881626cd4b751dc231e8ec82dee51955b Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Thu, 9 Apr 2026 10:33:52 +0100 Subject: [PATCH 11/12] fix output of the yarn:test command Signed-off-by: Ricardo Silva --- src/context/editorReducers.ts | 2 +- src/utils/parser.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/context/editorReducers.ts b/src/context/editorReducers.ts index 511ed294..ef01e8ab 100644 --- a/src/context/editorReducers.ts +++ b/src/context/editorReducers.ts @@ -97,7 +97,7 @@ const updateOfflineTDReducer = ( try { parsedTD = JSON.parse(offlineTD); } catch (e) { - console.error((e as Error).message); + // console.error((e as Error).message); return { ...state, offlineTD: offlineTD, diff --git a/src/utils/parser.ts b/src/utils/parser.ts index a3a3055d..de79d158 100644 --- a/src/utils/parser.ts +++ b/src/utils/parser.ts @@ -85,9 +85,7 @@ export const parseCsv = ( dynamicTyping: false, transformHeader: (h) => h.trim(), transform: (value) => (typeof value === "string" ? value.trim() : value), - complete: (results) => { - console.log(results.data, results.errors, results.meta); - }, + complete: () => {}, }); if (res.errors.length) { From 2500150b0b1f573ce9ad795f04e9e6812d68ad20 Mon Sep 17 00:00:00 2001 From: Ricardo Silva Date: Thu, 9 Apr 2026 10:35:21 +0100 Subject: [PATCH 12/12] test add act for dispatchEvents Signed-off-by: Ricardo Silva --- src/tests/App.integration.test.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/tests/App.integration.test.tsx b/src/tests/App.integration.test.tsx index b4dd67de..bbe8be5e 100644 --- a/src/tests/App.integration.test.tsx +++ b/src/tests/App.integration.test.tsx @@ -11,6 +11,7 @@ * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ import { + act, render, screen, cleanup, @@ -432,7 +433,9 @@ describe("App component receive message from other application", () => { value: openerRef, }); - window.dispatchEvent(event); + await act(async () => { + window.dispatchEvent(event); + }); expect( await screen.findByText( @@ -476,7 +479,9 @@ describe("App component receive message from other application", () => { value: {}, }); - window.dispatchEvent(event); + await act(async () => { + window.dispatchEvent(event); + }); await waitFor(() => { expect(screen.getByTestId("offline-td")).toHaveTextContent(""); @@ -511,7 +516,9 @@ describe("App component receive message from other application", () => { value: openerRef, }); - window.dispatchEvent(event); + await act(async () => { + window.dispatchEvent(event); + }); expect( await screen.findByText( @@ -546,7 +553,9 @@ describe("App component receive message from other application", () => { value: openerRef, }); - window.dispatchEvent(event); + await act(async () => { + window.dispatchEvent(event); + }); expect( await screen.findByText( @@ -585,7 +594,9 @@ describe("App component receive message from other application", () => { value: openerRef, }); - window.dispatchEvent(event); + await act(async () => { + window.dispatchEvent(event); + }); await waitFor(() => { expect(screen.getByTestId("offline-td")).toHaveTextContent("");