mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -1,186 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { WebEndpoint, WebUiConfig } from "@/types/index";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HostEditorWebUiSection } from "../../src/frontend/HostEditorWebUiSection";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "external",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
config: WebUiConfig = { endpoints: [] },
|
||||
{
|
||||
enableWebUi = true,
|
||||
tunnelAvailable = true,
|
||||
}: { enableWebUi?: boolean; tunnelAvailable?: boolean } = {},
|
||||
) {
|
||||
const setField = vi.fn();
|
||||
render(
|
||||
<HostEditorWebUiSection
|
||||
enableWebUi={enableWebUi}
|
||||
webUiConfig={config}
|
||||
tunnelAvailable={tunnelAvailable}
|
||||
setField={setField}
|
||||
/>,
|
||||
);
|
||||
return setField;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isElectron.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("HostEditorWebUiSection", () => {
|
||||
it("hides the endpoint list until the feature is enabled", () => {
|
||||
setup({ endpoints: [endpoint()] }, { enableWebUi: false });
|
||||
expect(screen.queryByLabelText("hosts.webUiLabel")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiAddEndpoint"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an endpoint with a non-colliding label", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint({ label: "hosts.webUiNewEndpointLabel" })],
|
||||
});
|
||||
fireEvent.click(screen.getByText("hosts.webUiAddEndpoint"));
|
||||
|
||||
const [, value] = setField.mock.calls[0];
|
||||
const added = (value as WebUiConfig).endpoints[1];
|
||||
// Labels identify an endpoint in the sidebar picker, so a fresh row must
|
||||
// not silently duplicate one already there.
|
||||
expect(added.label).toBe("hosts.webUiNewEndpointLabel 2");
|
||||
expect(added.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stops adding at the cap", () => {
|
||||
const many = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `E${i}` }),
|
||||
);
|
||||
setup({ endpoints: many });
|
||||
expect(screen.getByText("hosts.webUiAddEndpoint")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses to commit a port the normalizer would drop", () => {
|
||||
// Number("") is 0, which the normalizer rejects -- committing it would
|
||||
// silently discard the endpoint on save with no error shown.
|
||||
const setField = setup({ endpoints: [endpoint()] });
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
expect(setField).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("hosts.webUiPort"), {
|
||||
target: { value: "9000" },
|
||||
});
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].port).toBe(
|
||||
9000,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the bind-host fields only for a tunnel endpoint", () => {
|
||||
setup({ endpoints: [endpoint({ access: "direct" })] });
|
||||
expect(screen.queryByLabelText("hosts.bindHost")).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
expect(screen.getByLabelText("hosts.bindHost")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("hosts.webUiLocalPort")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns that a non-loopback bind is unauthenticated exposure", () => {
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(screen.getByText("hosts.webUiBindHostExposed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not warn about exposure for a loopback bind", () => {
|
||||
setup({
|
||||
endpoints: [endpoint({ access: "tunnel", bindHost: "127.0.0.1" })],
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("hosts.webUiBindHostExposed"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time that a loopback bind is unreachable from a browser", () => {
|
||||
isElectron.mockReturnValue(false);
|
||||
setup({ endpoints: [endpoint({ access: "tunnel" })] });
|
||||
// Said while configuring, not only when the tab fails to load.
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostUnreachable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns at config time about the session-cookie collision", () => {
|
||||
// The page host in jsdom is "localhost", which HAS a loopback alias, so
|
||||
// force a host that does not to reach the second refusal.
|
||||
isElectron.mockReturnValue(false);
|
||||
const realLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: "termix.example.com" }),
|
||||
});
|
||||
|
||||
setup({ endpoints: [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })] });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiBindHostSharesSessionCookie"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains why tunnelling is unavailable rather than leaving a dead option", () => {
|
||||
// A disabled control with no stated reason reads as the dropdown being
|
||||
// broken.
|
||||
setup({ endpoints: [endpoint()] }, { tunnelAvailable: false });
|
||||
expect(
|
||||
screen.getByText("hosts.webUiAccessTunnelUnavailable"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a row the normalizer would drop", () => {
|
||||
setup({ endpoints: [endpoint({ label: " " })] });
|
||||
expect(
|
||||
screen.getByText(/hosts.webUiErrorLabelRequired/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes an endpoint", () => {
|
||||
const setField = setup({
|
||||
endpoints: [endpoint(), endpoint({ id: "e2", label: "NAS" })],
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("hosts.webUiRemoveEndpoint")[0]);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect((setField.mock.calls[0][1] as WebUiConfig).endpoints[0].id).toBe(
|
||||
"e2",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
|
||||
@@ -59,7 +59,7 @@ function host(
|
||||
return {
|
||||
id: "7",
|
||||
ip: "192.168.1.10",
|
||||
webUiConfig: { endpoints },
|
||||
pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { WebEndpoint } from "@/types";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
@@ -25,13 +27,19 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
};
|
||||
}
|
||||
|
||||
function host(overrides: Partial<Host> = {}): Host {
|
||||
function host(
|
||||
endpoints: WebEndpoint[] | undefined,
|
||||
overrides: Partial<Host> = {},
|
||||
): Host {
|
||||
return {
|
||||
id: "7",
|
||||
ip: "10.0.0.5",
|
||||
name: "nas",
|
||||
enableSsh: false,
|
||||
enableWebUi: false,
|
||||
pluginSettings:
|
||||
endpoints === undefined
|
||||
? {}
|
||||
: { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } },
|
||||
...overrides,
|
||||
} as unknown as Host;
|
||||
}
|
||||
@@ -58,37 +66,32 @@ describe("web-endpoint activate", () => {
|
||||
});
|
||||
|
||||
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
|
||||
expect(
|
||||
await webAction(
|
||||
host({ enableWebUi: false, webUiConfig: { endpoints: [endpoint()] } }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
const target = host([endpoint()], {
|
||||
pluginSettings: {
|
||||
"web-endpoint": {
|
||||
enableWebUi: false,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await webAction(target)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers no entry when enabled but no endpoints exist", async () => {
|
||||
expect(
|
||||
await webAction(
|
||||
host({ enableWebUi: true, webUiConfig: { endpoints: [] } }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(await webAction(host([]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers no entry when the host has no web-endpoint settings at all", async () => {
|
||||
expect(await webAction(host(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appears without SSH", async () => {
|
||||
const action = await webAction(
|
||||
host({
|
||||
enableSsh: false,
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
}),
|
||||
);
|
||||
const action = await webAction(host([endpoint()], { enableSsh: false }));
|
||||
expect(action).toBeDefined();
|
||||
});
|
||||
|
||||
it("labels the entry with the endpoint when there is only one", async () => {
|
||||
const target = host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint({ label: "Proxmox" })] },
|
||||
});
|
||||
const target = host([endpoint({ label: "Proxmox" })]);
|
||||
const action = await webAction(target);
|
||||
expect(action?.label?.(target)).toBe("Proxmox");
|
||||
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
|
||||
@@ -98,17 +101,14 @@ describe("web-endpoint activate", () => {
|
||||
const endpoints = Array.from({ length: 16 }, (_, i) =>
|
||||
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
|
||||
);
|
||||
const target = host({ enableWebUi: true, webUiConfig: { endpoints } });
|
||||
const target = host(endpoints);
|
||||
const action = await webAction(target);
|
||||
expect(action?.label?.(target)).toBeUndefined();
|
||||
expect(action?.items?.(target)).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("opens an embedded endpoint as a tab carrying its id", async () => {
|
||||
const target = host({
|
||||
enableWebUi: true,
|
||||
webUiConfig: { endpoints: [endpoint()] },
|
||||
});
|
||||
const target = host([endpoint()]);
|
||||
const action = await webAction(target);
|
||||
const calls: unknown[][] = [];
|
||||
const shell = {
|
||||
@@ -124,3 +124,36 @@ describe("web-endpoint activate", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host editor section", () => {
|
||||
it("writes into the form's web-endpoint plugin settings", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
let form: Record<string, unknown> = {
|
||||
name: "nas",
|
||||
pluginSettings: { "web-endpoint": { enableWebUi: false } },
|
||||
};
|
||||
const updateForm = vi.fn(
|
||||
(
|
||||
patch: (current: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => {
|
||||
form = patch(form);
|
||||
},
|
||||
);
|
||||
|
||||
const section = rendered.renderHostEditorSection("web-ui", {
|
||||
form,
|
||||
setField: vi.fn(),
|
||||
updateForm,
|
||||
protocols: { enableSsh: true },
|
||||
});
|
||||
|
||||
// The enable switch is the only button on the section before any
|
||||
// endpoint rows exist (which only render once enabled).
|
||||
fireEvent.click(section.querySelector("button") as HTMLButtonElement);
|
||||
|
||||
const settings = (form.pluginSettings as Record<string, unknown>)[
|
||||
"web-endpoint"
|
||||
] as { enableWebUi: boolean };
|
||||
expect(settings.enableWebUi).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
const isElectron = vi.hoisted(() => vi.fn(() => false));
|
||||
vi.mock("@/lib/electron", () => ({ isElectron }));
|
||||
|
||||
const tunnelPost = vi.hoisted(() => vi.fn());
|
||||
const pluginPost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/main-axios", () => ({
|
||||
handleApiError: (error: unknown) => {
|
||||
throw new Error(`generic: ${String(error)}`);
|
||||
@@ -27,32 +27,17 @@ function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
|
||||
const host = { id: "7", ip: "192.168.1.10" };
|
||||
|
||||
let pageHostname = "localhost";
|
||||
const realLocation = window.location;
|
||||
const windowOpen = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
const { setWebEndpointApi } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
setWebEndpointApi({ post: tunnelPost } as never);
|
||||
pageHostname = "localhost";
|
||||
setWebEndpointApi({ post: pluginPost } as never);
|
||||
isElectron.mockReturnValue(false);
|
||||
tunnelPost.mockReset();
|
||||
tunnelPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
get: () => ({ ...realLocation, hostname: pageHostname }),
|
||||
});
|
||||
vi.stubGlobal("open", windowOpen);
|
||||
pluginPost.mockReset();
|
||||
pluginPost.mockResolvedValue({ data: { port: 41234 } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: realLocation,
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
windowOpen.mockReset();
|
||||
});
|
||||
|
||||
describe("openWebEndpointTunnel", () => {
|
||||
@@ -63,7 +48,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointTunnel(7, "e1");
|
||||
|
||||
expect(tunnelPost).toHaveBeenCalledWith("/open", {
|
||||
expect(pluginPost).toHaveBeenCalledWith("/open", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
@@ -86,7 +71,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
data: { error: "Timed out reaching the endpoint port" },
|
||||
},
|
||||
});
|
||||
tunnelPost.mockRejectedValue(axiosError);
|
||||
pluginPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointTunnel, WebEndpointTunnelError } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
|
||||
@@ -101,7 +86,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
it("falls back to the shared handler when the body carries no string reason", async () => {
|
||||
// A body shaped { error: <object> } would otherwise render as
|
||||
// "[object Object]" to the user.
|
||||
tunnelPost.mockRejectedValue(
|
||||
pluginPost.mockRejectedValue(
|
||||
Object.assign(new Error("boom"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { error: { nested: true } } },
|
||||
@@ -113,7 +98,7 @@ describe("openWebEndpointTunnel", () => {
|
||||
});
|
||||
|
||||
it("treats a missing port as a failure rather than returning undefined", async () => {
|
||||
tunnelPost.mockResolvedValue({ data: {} });
|
||||
pluginPost.mockResolvedValue({ data: {} });
|
||||
const { openWebEndpointTunnel } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
|
||||
@@ -132,35 +117,45 @@ describe("requireNumericHostId", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open externally" is not the safer path. The cookie jar belongs to the
|
||||
* browser either way, so a tunnel URL on the host string serving Termix hands
|
||||
* the tunnelled service this session exactly as an embedded frame would.
|
||||
* The backend now resolves and validates the target URL itself (host address,
|
||||
* tunnel port, loopback/session-cookie checks) before ever calling
|
||||
* ctx.desktop.openIsolatedWindow, so this call is a thin ask-and-report over
|
||||
* the plugin's own /open-window route rather than a client-side URL builder.
|
||||
*/
|
||||
describe("openWebEndpointExternally", () => {
|
||||
it("refuses shared-cookie browser windows before opening a tunnel", async () => {
|
||||
it("refuses outside the desktop app before calling the backend", async () => {
|
||||
const { openWebEndpointExternally } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
|
||||
/desktop app/,
|
||||
);
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
expect(tunnelPost).not.toHaveBeenCalled();
|
||||
expect(pluginPost).not.toHaveBeenCalled();
|
||||
});
|
||||
it("opens desktop endpoints through the isolated-window bridge", async () => {
|
||||
|
||||
it("posts hostId, endpointId and ignoreCert to /open-window", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const invoke = vi.fn().mockResolvedValue({ success: true });
|
||||
Object.defineProperty(window, "electronAPI", {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
});
|
||||
pluginPost.mockResolvedValue({ data: { success: true } });
|
||||
const { openWebEndpointExternally } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
|
||||
expect(invoke).toHaveBeenCalledWith("open-isolated-web-endpoint", {
|
||||
url: "https://127.0.0.1:41234/",
|
||||
expect(pluginPost).toHaveBeenCalledWith("/open-window", {
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
ignoreCert: true,
|
||||
});
|
||||
expect(windowOpen).not.toHaveBeenCalled();
|
||||
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||
});
|
||||
|
||||
it("preserves the backend's reason instead of the generic message", async () => {
|
||||
isElectron.mockReturnValue(true);
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
isAxiosError: true,
|
||||
response: { status: 502, data: { error: "No window to attach to" } },
|
||||
});
|
||||
pluginPost.mockRejectedValue(axiosError);
|
||||
const { openWebEndpointExternally, WebEndpointTunnelError } =
|
||||
await import("../../src/frontend/web-endpoint-api");
|
||||
await expect(
|
||||
openWebEndpointExternally(host, endpoint()),
|
||||
).rejects.toBeInstanceOf(WebEndpointTunnelError);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sharesCookieSiteWithPage,
|
||||
webEndpointRefusalReason,
|
||||
} from "../../src/frontend/web-endpoint-url";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
webEndpointErrorKey,
|
||||
webEndpointRowError,
|
||||
} from "../../src/frontend/web-endpoint-validation";
|
||||
import { normalizeWebEndpoints } from "../../../../src/backend/database/routes/host-web-endpoints.js";
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config";
|
||||
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
|
||||
|
||||
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user