diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 086fd0661..2140f78cf 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -24,6 +24,7 @@ "@homarr/definitions": "workspace:^0.1.0", "@homarr/icons": "workspace:^0.1.0", "@homarr/log": "workspace:^", + "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", diff --git a/apps/tasks/src/jobs.ts b/apps/tasks/src/jobs.ts index 181ce4590..5f62247dd 100644 --- a/apps/tasks/src/jobs.ts +++ b/apps/tasks/src/jobs.ts @@ -1,13 +1,15 @@ import { iconsUpdaterJob } from "~/jobs/icons-updater"; import { analyticsJob } from "./jobs/analytics"; +import { pingJob } from "./jobs/ping"; import { queuesJob } from "./jobs/queue"; import { createJobGroup } from "./lib/cron-job/group"; export const jobs = createJobGroup({ // Add your jobs here: + analytics: analyticsJob, + iconsUpdater: iconsUpdaterJob, + ping: pingJob, // This job is used to process queues. queues: queuesJob, - iconsUpdater: iconsUpdaterJob, - analytics: analyticsJob, }); diff --git a/apps/tasks/src/jobs/ping.ts b/apps/tasks/src/jobs/ping.ts new file mode 100644 index 000000000..ab808f06c --- /dev/null +++ b/apps/tasks/src/jobs/ping.ts @@ -0,0 +1,25 @@ +import { logger } from "@homarr/log"; +import { sendPingRequestAsync } from "@homarr/ping"; +import { pingChannel, pingUrlChannel } from "@homarr/redis"; + +import { EVERY_MINUTE } from "~/lib/cron-job/constants"; +import { createCronJob } from "~/lib/cron-job/creator"; + +export const pingJob = createCronJob(EVERY_MINUTE).withCallback(async () => { + const urls = await pingUrlChannel.getAllAsync(); + + for (const url of new Set(urls)) { + const pingResult = await sendPingRequestAsync(url); + + if ("statusCode" in pingResult) { + logger.debug(`executed ping for url ${url} with status code ${pingResult.statusCode}`); + } else { + logger.error(`Executing ping for url ${url} failed with error: ${pingResult.error}`); + } + + await pingChannel.publishAsync({ + url, + ...pingResult, + }); + } +}); diff --git a/apps/tasks/src/lib/cron-job/creator.ts b/apps/tasks/src/lib/cron-job/creator.ts index 1dcb59fcc..c59314485 100644 --- a/apps/tasks/src/lib/cron-job/creator.ts +++ b/apps/tasks/src/lib/cron-job/creator.ts @@ -1,6 +1,7 @@ import cron from "node-cron"; import type { MaybePromise } from "@homarr/common/types"; +import { logger } from "@homarr/log"; interface CreateCronJobOptions { runOnStart?: boolean; @@ -9,11 +10,22 @@ interface CreateCronJobOptions { export const createCronJob = (cronExpression: string, options: CreateCronJobOptions = { runOnStart: false }) => { return { withCallback: (callback: () => MaybePromise) => { + const catchingCallbackAsync = async () => { + try { + await callback(); + } catch (error) { + logger.error( + `apps/tasks/src/lib/cron-job/creator.ts: The callback of a cron job failed, expression ${cronExpression}, with error:`, + error, + ); + } + }; + if (options.runOnStart) { - void callback(); + void catchingCallbackAsync(); } - const task = cron.schedule(cronExpression, () => void callback(), { + const task = cron.schedule(cronExpression, () => void catchingCallbackAsync(), { scheduled: false, }); return { diff --git a/apps/tasks/src/lib/queue/client.ts b/apps/tasks/src/lib/queue/client.ts index b67cb968d..379fcdbcf 100644 --- a/apps/tasks/src/lib/queue/client.ts +++ b/apps/tasks/src/lib/queue/client.ts @@ -41,15 +41,14 @@ export const createQueueClient = (queues: TQueues) => { }; return acc; }, - {} as Record< - keyof TQueues, - ( - data: z.infer, + {} as { + [key in keyof TQueues]: ( + data: z.infer, props: { executionDate?: Date; } | void, - ) => Promise - >, + ) => Promise; + }, ), }; }; diff --git a/apps/tasks/src/lib/queue/worker.ts b/apps/tasks/src/lib/queue/worker.ts index 40d9dde9b..0e7577794 100644 --- a/apps/tasks/src/lib/queue/worker.ts +++ b/apps/tasks/src/lib/queue/worker.ts @@ -1,3 +1,4 @@ +import { logger } from "@homarr/log"; import { queueChannel } from "@homarr/redis"; import { queueRegistry } from "~/queues"; @@ -14,7 +15,18 @@ export const queueWorkerAsync = async () => { for (const execution of executions) { const queue = queueRegistry.get(execution.name); if (!queue) continue; - await queue.callback(execution.data); + + try { + await queue.callback(execution.data); + } catch (err) { + logger.error( + `apps/tasks/src/lib/queue/worker.ts: Error occured when executing queue ${execution.name} with data`, + execution.data, + "and error:", + err, + ); + } + await queueChannel.markAsDoneAsync(execution._id); } }; diff --git a/packages/api/package.json b/packages/api/package.json index 05186dafb..5024055bd 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,6 +25,7 @@ "@homarr/definitions": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^", + "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/tasks": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", diff --git a/packages/api/src/router/test/widgets/app.spec.ts b/packages/api/src/router/test/widgets/app.spec.ts new file mode 100644 index 000000000..a728f9a94 --- /dev/null +++ b/packages/api/src/router/test/widgets/app.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createDb } from "@homarr/db/test"; +import * as ping from "@homarr/ping"; + +import { appRouter } from "../../widgets/app"; + +// Mock the auth module to return an empty session +vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); +vi.mock("@homarr/ping", () => ({ sendPingRequestAsync: async () => await Promise.resolve(null) })); + +describe("ping should call sendPingRequestAsync with url and return result", () => { + test("ping with error response should return error and url", async () => { + // Arrange + const spy = vi.spyOn(ping, "sendPingRequestAsync"); + const url = "http://localhost"; + const db = createDb(); + const caller = appRouter.createCaller({ + db, + session: null, + }); + spy.mockImplementation(() => Promise.resolve({ error: "error" })); + + // Act + const result = await caller.ping({ url }); + + // Assert + expect(result.url).toBe(url); + expect("error" in result).toBe(true); + }); + + test("ping with success response should return statusCode and url", async () => { + // Arrange + const spy = vi.spyOn(ping, "sendPingRequestAsync"); + const url = "http://localhost"; + const db = createDb(); + const caller = appRouter.createCaller({ + db, + session: null, + }); + spy.mockImplementation(() => Promise.resolve({ statusCode: 200 })); + + // Act + const result = await caller.ping({ url }); + + // Assert + expect(result.url).toBe(url); + expect("statusCode" in result).toBe(true); + }); +}); diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts new file mode 100644 index 000000000..8bfc51868 --- /dev/null +++ b/packages/api/src/router/widgets/app.ts @@ -0,0 +1,42 @@ +import { observable } from "@trpc/server/observable"; + +import { sendPingRequestAsync } from "@homarr/ping"; +import { pingChannel, pingUrlChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; + +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const appRouter = createTRPCRouter({ + ping: publicProcedure.input(z.object({ url: z.string() })).query(async ({ input }) => { + const pingResult = await sendPingRequestAsync(input.url); + + return { + url: input.url, + ...pingResult, + }; + }), + updatedPing: publicProcedure + .input( + z.object({ + url: z.string(), + }), + ) + .subscription(async ({ input }) => { + await pingUrlChannel.addAsync(input.url); + + const pingResult = await sendPingRequestAsync(input.url); + + return observable<{ url: string; statusCode: number } | { url: string; error: string }>((emit) => { + emit.next({ url: input.url, ...pingResult }); + pingChannel.subscribe((message) => { + // Only emit if same url + if (message.url !== input.url) return; + emit.next(message); + }); + + return () => { + void pingUrlChannel.removeAsync(input.url); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index f2bd45078..903f43542 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -1,4 +1,5 @@ import { createTRPCRouter } from "../../trpc"; +import { appRouter } from "./app"; import { dnsHoleRouter } from "./dns-hole"; import { notebookRouter } from "./notebook"; import { weatherRouter } from "./weather"; @@ -6,5 +7,6 @@ import { weatherRouter } from "./weather"; export const widgetRouter = createTRPCRouter({ notebook: notebookRouter, weather: weatherRouter, + app: appRouter, dnsHole: dnsHoleRouter, }); diff --git a/packages/common/src/error.ts b/packages/common/src/error.ts new file mode 100644 index 000000000..ddc5f93ea --- /dev/null +++ b/packages/common/src/error.ts @@ -0,0 +1,11 @@ +export const extractErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + return "Unknown error"; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e3127eab3..d0bedb0d6 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -5,3 +5,4 @@ export * from "./array"; export * from "./stopwatch"; export * from "./hooks"; export * from "./number"; +export * from "./error"; diff --git a/packages/common/src/test/error.spec.ts b/packages/common/src/test/error.spec.ts new file mode 100644 index 000000000..c4c68d7f7 --- /dev/null +++ b/packages/common/src/test/error.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; + +import { extractErrorMessage } from "../error"; + +describe("error to resolve to correct message", () => { + test("error class to resolve to error message", () => { + // Arrange + const error = new Error("Message"); + + // Act + const message = extractErrorMessage(error); + + // Assert + expect(typeof message).toBe("string"); + expect(message).toBe("Message"); + }); + + test("error string to resolve to error message", () => { + // Arrange + const error = "Message"; + + // Act + const message = extractErrorMessage(error); + + // Assert + expect(typeof message).toBe("string"); + expect(message).toBe("Message"); + }); + + test("error whatever to resolve to unknown error message", () => { + // Arrange + const error = 5; + + // Act + const message = extractErrorMessage(error); + + // Assert + expect(typeof message).toBe("string"); + expect(message).toBe("Unknown error"); + }); +}); diff --git a/packages/ping/index.ts b/packages/ping/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/ping/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/ping/package.json b/packages/ping/package.json new file mode 100644 index 000000000..7279a3ef7 --- /dev/null +++ b/packages/ping/package.json @@ -0,0 +1,40 @@ +{ + "name": "@homarr/ping", + "private": true, + "version": "0.1.0", + "exports": { + ".": "./index.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "type": "module", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint .", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@homarr/common": "workspace:^0.1.0", + "@homarr/log": "workspace:^0.1.0" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^8.57.0", + "typescript": "^5.4.5" + }, + "eslintConfig": { + "extends": [ + "@homarr/eslint-config/base" + ] + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/ping/src/index.ts b/packages/ping/src/index.ts new file mode 100644 index 000000000..3dafac233 --- /dev/null +++ b/packages/ping/src/index.ts @@ -0,0 +1,13 @@ +import { extractErrorMessage } from "@homarr/common"; +import { logger } from "@homarr/log"; + +export const sendPingRequestAsync = async (url: string) => { + try { + return await fetch(url).then((response) => ({ statusCode: response.status })); + } catch (error) { + logger.error("packages/ping/src/index.ts:", error); + return { + error: extractErrorMessage(error), + }; + } +}; diff --git a/packages/ping/tsconfig.json b/packages/ping/tsconfig.json new file mode 100644 index 000000000..612bef8df --- /dev/null +++ b/packages/ping/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "types": ["node"], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index beb5f1c70..164d9a273 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -1,8 +1,12 @@ -import { createQueueChannel, createSubPubChannel } from "./lib/channel"; +import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel"; export { createCacheChannel } from "./lib/channel"; export const exampleChannel = createSubPubChannel<{ message: string }>("example"); +export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>( + "ping", +); +export const pingUrlChannel = createListChannel("ping-url"); export const queueChannel = createQueueChannel<{ name: string; executionDate: Date; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 89c89ed40..7a59e6fd7 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -51,7 +51,40 @@ export const createSubPubChannel = (name: string) => { }; }; -const cacheClient = createRedisConnection(); +const getSetClient = createRedisConnection(); + +/** + * Creates a new redis channel for a list + * @param name name of channel + * @returns list channel object + */ +export const createListChannel = (name: string) => { + const listChannelName = `list:${name}`; + return { + /** + * Get all items in list + * @returns an array of all items + */ + getAllAsync: async () => { + const items = await getSetClient.lrange(listChannelName, 0, -1); + return items.map((item) => superjson.parse(item)); + }, + /** + * Remove an item from the channels list by item + * @param item item to remove + */ + removeAsync: async (item: TItem) => { + await getSetClient.lrem(listChannelName, 0, superjson.stringify(item)); + }, + /** + * Add an item to the channels list + * @param item item to add + */ + addAsync: async (item: TItem) => { + await getSetClient.lpush(listChannelName, superjson.stringify(item)); + }, + }; +}; /** * Creates a new cache channel. @@ -68,7 +101,7 @@ export const createCacheChannel = (name: string, cacheDurationMs: number * @returns data or null if not found or expired */ getAsync: async () => { - const data = await cacheClient.get(cacheChannelName); + const data = await getSetClient.get(cacheChannelName); if (!data) return null; const parsedData = superjson.parse<{ data: TData; timestamp: Date }>(data); @@ -84,13 +117,13 @@ export const createCacheChannel = (name: string, cacheDurationMs: number * @returns data or new data if not present or expired */ consumeAsync: async (callback: () => Promise) => { - const data = await cacheClient.get(cacheChannelName); + const data = await getSetClient.get(cacheChannelName); const getNewDataAsync = async () => { logger.debug(`Cache miss for channel '${cacheChannelName}'`); const newData = await callback(); const result = { data: newData, timestamp: new Date() }; - await cacheClient.set(cacheChannelName, superjson.stringify(result)); + await getSetClient.set(cacheChannelName, superjson.stringify(result)); logger.debug(`Cache updated for channel '${cacheChannelName}'`); return result; }; @@ -115,14 +148,14 @@ export const createCacheChannel = (name: string, cacheDurationMs: number * Invalidate the cache channels data. */ invalidateAsync: async () => { - await cacheClient.del(cacheChannelName); + await getSetClient.del(cacheChannelName); }, /** * Set the data in the cache channel. * @param data data to be stored in the cache channel */ setAsync: async (data: TData) => { - await cacheClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() })); + await getSetClient.set(cacheChannelName, superjson.stringify({ data, timestamp: new Date() })); }, }; }; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 3118b9bc9..4355cf030 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -640,6 +640,9 @@ export default { showDescriptionTooltip: { label: "Show description tooltip", }, + pingEnabled: { + label: "Enable simple ping", + }, }, error: { notFound: { diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 85754a0cf..0c47588dc 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -1,9 +1,11 @@ "use client"; import type { PropsWithChildren } from "react"; -import { Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useState } from "react"; +import { Box, Center, Flex, Loader, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { IconDeviceDesktopX } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRegisterSpotlightActions } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; @@ -33,6 +35,19 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig }, ); + const [pingResult, setPingResult] = useState(null); + + const shouldRunPing = Boolean(app?.href) && options.pingEnabled; + clientApi.widget.app.updatedPing.useSubscription( + { url: app?.href ?? "" }, + { + enabled: shouldRunPing, + onData(data) { + setPingResult(data); + }, + }, + ); + useRegisterSpotlightActions( `app-${options.appId}`, app?.href @@ -77,7 +92,7 @@ export default function AppWidget({ options, serverData, isEditMode, width, heig return ( - + + + {shouldRunPing && } ); @@ -122,3 +139,31 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren { + return ( + + + = 500 + ? "red" + : "green", + }} + w={16} + h={16} + > + + + ); +}; diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index 4eb1d5440..8324b83c6 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -9,6 +9,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef appId: factory.app(), openInNewTab: factory.switch({ defaultValue: true }), showDescriptionTooltip: factory.switch({ defaultValue: false }), + pingEnabled: factory.switch({ defaultValue: false }), })), }) .withServerData(() => import("./serverData")) diff --git a/packages/widgets/src/app/serverData.ts b/packages/widgets/src/app/serverData.ts index a6df69678..d4d995f2c 100644 --- a/packages/widgets/src/app/serverData.ts +++ b/packages/widgets/src/app/serverData.ts @@ -1,14 +1,25 @@ "use server"; +import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import type { WidgetProps } from "../definition"; export default async function getServerDataAsync({ options }: WidgetProps<"app">) { + if (!options.appId) { + return { app: null, pingResult: null }; + } + try { const app = await api.app.byId({ id: options.appId }); - return { app }; + let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null; + + if (app.href && options.pingEnabled) { + pingResult = await api.widget.app.ping({ url: app.href }); + } + + return { app, pingResult }; } catch (error) { - return { app: null }; + return { app: null, pingResult: null }; } } diff --git a/packages/widgets/src/app/test/serverData.spec.ts b/packages/widgets/src/app/test/serverData.spec.ts new file mode 100644 index 000000000..2834eab58 --- /dev/null +++ b/packages/widgets/src/app/test/serverData.spec.ts @@ -0,0 +1,129 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, test, vi } from "vitest"; + +import type { RouterOutputs } from "@homarr/api"; +import { api } from "@homarr/api/server"; +import { objectKeys } from "@homarr/common"; + +import type { WidgetProps } from "../../definition"; +import getServerDataAsync from "../serverData"; + +const mockApp = (override: Partial) => + ({ + id: "1", + name: "Mock app", + iconUrl: "https://some.com/icon.png", + description: null, + href: "https://google.ch", + ...override, + }) satisfies RouterOutputs["app"]["byId"]; + +vi.mock("@homarr/api/server", () => ({ + api: { + app: { + byId: () => null, + }, + widget: { + app: { + ping: () => null, + }, + }, + }, +})); + +describe("getServerDataAsync should load app and ping result", () => { + test("when appId is empty it should return null for app and pingResult", async () => { + // Arrange + const options = { + appId: "", + pingEnabled: true, + }; + + // Act + const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); + + // Assert + expect(result.app).toBeNull(); + expect(result.pingResult).toBeNull(); + }); + + test("when app exists and ping is disabled it should return existing app and pingResult null", async () => { + // Arrange + const spy = vi.spyOn(api.app, "byId"); + const options = { + appId: "1", + pingEnabled: false, + }; + const mockedApp = mockApp({}); + spy.mockImplementation(() => Promise.resolve(mockedApp)); + + // Act + const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); + + // Assert + expect(result.pingResult).toBeNull(); + objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key])); + }); + + test("when app exists without href and ping enabled it should return existing app and pingResult null", async () => { + // Arrange + const spy = vi.spyOn(api.app, "byId"); + const options = { + appId: "1", + pingEnabled: true, + }; + const mockedApp = mockApp({ href: null }); + spy.mockImplementation(() => Promise.resolve(mockedApp)); + + // Act + const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); + + // Assert + expect(result.pingResult).toBeNull(); + objectKeys(mockedApp).forEach((key) => expect(result.app?.[key]).toBe(mockedApp[key])); + }); + + test("when app does not exist it should return for both null", async () => { + // Arrange + const spy = vi.spyOn(api.app, "byId"); + const options = { + appId: "1", + pingEnabled: true, + }; + spy.mockImplementation(() => + Promise.reject( + new TRPCError({ + code: "NOT_FOUND", + }), + ), + ); + + // Act + const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); + + // Assert + expect(result.pingResult).toBeNull(); + expect(result.app).toBeNull(); + }); + + test("when app found and ping enabled it should return existing app and pingResult", async () => { + // Arrange + const spyById = vi.spyOn(api.app, "byId"); + const spyPing = vi.spyOn(api.widget.app, "ping"); + const options = { + appId: "1", + pingEnabled: true, + }; + const mockedApp = mockApp({}); + const pingResult = { statusCode: 200, url: "http://localhost" }; + spyById.mockImplementation(() => Promise.resolve(mockedApp)); + spyPing.mockImplementation(() => Promise.resolve(pingResult)); + + // Act + const result = await getServerDataAsync({ options } as unknown as WidgetProps<"app">); + + // Assert + expect(result.pingResult).toBe(pingResult); + expect(result.app).toBe(mockedApp); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4560f55db..bfaa0f5f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@homarr/log': specifier: workspace:^ version: link:../../packages/log + '@homarr/ping': + specifier: workspace:^0.1.0 + version: link:../../packages/ping '@homarr/redis': specifier: workspace:^0.1.0 version: link:../../packages/redis @@ -427,6 +430,9 @@ importers: '@homarr/log': specifier: workspace:^ version: link:../log + '@homarr/ping': + specifier: workspace:^0.1.0 + version: link:../ping '@homarr/redis': specifier: workspace:^0.1.0 version: link:../redis @@ -783,6 +789,31 @@ importers: specifier: ^5.4.5 version: 5.4.5 + packages/ping: + dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/log': + specifier: workspace:^0.1.0 + version: link:../log + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^8.57.0 + version: 8.57.0 + typescript: + specifier: ^5.4.5 + version: 5.4.5 + packages/redis: dependencies: '@homarr/common':