Merge pull request #54 from offendingcommit/feat/web-api-proxy
feat(web): eliminate browser CORS via header-driven /api proxy
This commit is contained in:
@@ -1,21 +1,12 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import { loadConfig } from "@/lib/config";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { dispatchFor } from "@/lib/dispatch";
|
||||
import type { paths } from "./schema.d.ts";
|
||||
|
||||
export function createHonchoClient() {
|
||||
const config = loadConfig();
|
||||
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
|
||||
const token = config?.token ?? "";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
|
||||
const config = loadConfig() ?? { baseUrl: "http://localhost:8000", token: "" };
|
||||
const { baseUrl, headers, fetch } = dispatchFor(config);
|
||||
return createClient<paths>({ baseUrl, headers, fetch });
|
||||
}
|
||||
|
||||
export const client = {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { dispatchFor } from "@/lib/dispatch";
|
||||
import type { paths } from "./schema.d.ts";
|
||||
|
||||
export type ScopedClient = ReturnType<typeof createClient<paths>>;
|
||||
|
||||
/**
|
||||
* Create an openapi-fetch client bound to a specific instance. Use for views
|
||||
* that need to query non-active instances (e.g. side-by-side comparison).
|
||||
* For single-instance access, prefer `client.current` which tracks the active
|
||||
* instance via localStorage.
|
||||
* Create an openapi-fetch client bound to a specific instance. Use for views that
|
||||
* query non-active instances (e.g. the Fleet side-by-side comparison). Each scoped
|
||||
* client self-routes via its own X-Honcho-Upstream header in web mode.
|
||||
*/
|
||||
export function createScopedClient(instance: Instance): ScopedClient {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
||||
return createClient<paths>({ baseUrl: instance.baseUrl, headers, fetch: httpFetch });
|
||||
const { baseUrl, headers, fetch } = dispatchFor(instance);
|
||||
return createClient<paths>({ baseUrl, headers, fetch });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { dispatchFor, PROXY_REJECT_HEADER } from "@/lib/dispatch";
|
||||
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||
|
||||
const LEGACY_KEY = "openconcho:config";
|
||||
@@ -7,6 +7,13 @@ const STORE_KEY = "openconcho:instances";
|
||||
|
||||
export const HONCHO_CLOUD_URL = "https://api.honcho.dev";
|
||||
|
||||
/**
|
||||
* Connection-test timeout. Generous because a cold/idle self-hosted Honcho (DB
|
||||
* pool spin-up, tunnel wake) can take several seconds on its first request — a
|
||||
* tight 5s budget reported live-and-reachable instances as "Connection timed out".
|
||||
*/
|
||||
export const CONNECTION_TIMEOUT_MS = 15_000;
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
@@ -16,7 +23,7 @@ export function isCloudInstance(instance: Pick<Instance, "baseUrl">): boolean {
|
||||
}
|
||||
|
||||
export const configSchema = z.object({
|
||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||
baseUrl: z.url({ message: "Must be a valid URL" }),
|
||||
token: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
@@ -25,7 +32,7 @@ export type Config = z.infer<typeof configSchema>;
|
||||
export const instanceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||
baseUrl: z.url({ message: "Must be a valid URL" }),
|
||||
token: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
@@ -162,21 +169,21 @@ export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
||||
export async function checkConnection(
|
||||
baseUrl: string,
|
||||
token?: string,
|
||||
): Promise<{
|
||||
status: HealthStatus;
|
||||
message: string;
|
||||
}> {
|
||||
timeoutMs: number = CONNECTION_TIMEOUT_MS,
|
||||
): Promise<{ status: HealthStatus; message: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list`, {
|
||||
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl, token });
|
||||
const res = await fetch(`${base}/v3/workspaces/list`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const reject = res.headers.get(PROXY_REJECT_HEADER);
|
||||
if (reject) {
|
||||
return { status: "unreachable", message: `Proxy refused upstream (${reject})` };
|
||||
}
|
||||
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { dispatchFor } from "@/lib/dispatch";
|
||||
import { isTauri } from "@/lib/platform";
|
||||
|
||||
export { isTauri } from "@/lib/platform";
|
||||
|
||||
export interface DiscoveredInstance {
|
||||
port: number;
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe localhost ports for running Honcho instances. Desktop-only — the web
|
||||
* build can't port-scan due to CORS, so this returns an empty list when not
|
||||
@@ -38,9 +37,10 @@ export function deriveNameFromWorkspaceId(workspaceId: string): string {
|
||||
*/
|
||||
export async function suggestNameForInstance(baseUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await httpFetch(`${baseUrl}/v3/workspaces/list?page=1&page_size=1`, {
|
||||
const { baseUrl: base, headers, fetch } = dispatchFor({ baseUrl });
|
||||
const res = await fetch(`${base}/v3/workspaces/list?page=1&page_size=1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
47
packages/web/src/lib/dispatch.ts
Normal file
47
packages/web/src/lib/dispatch.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { isTauri } from "@/lib/platform";
|
||||
|
||||
/** Same-origin path prefix the web build issues all Honcho calls through. */
|
||||
export const API_PREFIX = "/api";
|
||||
/** Request header naming the real Honcho upstream for the proxy to forward to. */
|
||||
export const UPSTREAM_HEADER = "X-Honcho-Upstream";
|
||||
/** Response header the proxy sets on its OWN refusals (so they aren't read as upstream auth). */
|
||||
export const PROXY_REJECT_HEADER = "X-Honcho-Proxy-Reject";
|
||||
|
||||
export interface Dispatch {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function normalizeUpstream(url: string): string {
|
||||
return url.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute same-origin base for the web proxy. Absolute (origin + `/api`), not the
|
||||
* bare relative `/api`, so openapi-fetch and node/undici can construct a Request
|
||||
* without an ambient document base — and so it resolves identically in the browser,
|
||||
* behind a tunnel, and under jsdom.
|
||||
*/
|
||||
function webApiBase(): string {
|
||||
const origin = typeof location !== "undefined" ? location.origin : "";
|
||||
return `${origin}${API_PREFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to issue a request for an instance.
|
||||
* - Web: same-origin `/api` + `X-Honcho-Upstream` header (proxy forwards server-side, no CORS).
|
||||
* - Tauri: the absolute instance URL via reqwest (no browser same-origin policy).
|
||||
*/
|
||||
export function dispatchFor(instance: { baseUrl: string; token?: string }): Dispatch {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (instance.token) headers.Authorization = `Bearer ${instance.token}`;
|
||||
|
||||
if (isTauri()) {
|
||||
return { baseUrl: instance.baseUrl, headers, fetch: httpFetch };
|
||||
}
|
||||
|
||||
headers[UPSTREAM_HEADER] = normalizeUpstream(instance.baseUrl);
|
||||
return { baseUrl: webApiBase(), headers, fetch: httpFetch };
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { isTauri } from "@/lib/platform";
|
||||
|
||||
// Route fetch through Rust (reqwest) when running in Tauri — bypasses WebView CORS enforcement.
|
||||
// Falls back to native browser fetch during plain web dev (`pnpm dev:web`).
|
||||
const isTauri = Boolean(
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__,
|
||||
);
|
||||
|
||||
export const httpFetch: typeof globalThis.fetch = isTauri
|
||||
// Falls back to native browser fetch during plain web dev.
|
||||
export const httpFetch: typeof globalThis.fetch = isTauri()
|
||||
? (tauriFetch as typeof globalThis.fetch)
|
||||
: globalThis.fetch;
|
||||
|
||||
4
packages/web/src/lib/platform.ts
Normal file
4
packages/web/src/lib/platform.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** True when running inside the Tauri desktop shell (WebView with injected internals). */
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
|
||||
const SAME_ORIGIN = "same-origin";
|
||||
|
||||
/**
|
||||
* Runtime-injected default Honcho base URL for container deployments.
|
||||
*
|
||||
* The Docker image writes `/config.js` from the `OPENCONCHO_DEFAULT_HONCHO_URL`
|
||||
* env at container start, so one prebuilt image can target any backend without
|
||||
* a rebuild (Vite envs are baked at build time and can't do this).
|
||||
* The Docker image writes `/config.js` from `OPENCONCHO_DEFAULT_HONCHO_URL` at
|
||||
* container start, so one prebuilt image can target any backend without a rebuild.
|
||||
* The web build proxies this URL via the same-origin `/api` reverse proxy (no CORS).
|
||||
*
|
||||
* - `"same-origin"` → the page's own origin (pairs with the nginx `/v3` reverse
|
||||
* proxy, so the browser makes same-origin requests and CORS never applies)
|
||||
* - an absolute URL → that URL
|
||||
* - empty / unset → `null` (no default; the user configures in Settings)
|
||||
* - an absolute URL → that URL (seeds the first instance)
|
||||
* - empty / unset → null (no default; the user configures in Settings)
|
||||
*/
|
||||
export function runtimeDefaultBaseUrl(): string | null {
|
||||
const raw = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
const value = raw.trim();
|
||||
if (value === SAME_ORIGIN) {
|
||||
return typeof location !== "undefined" ? location.origin : null;
|
||||
}
|
||||
return value;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
74
packages/web/src/test/check-connection.test.ts
Normal file
74
packages/web/src/test/check-connection.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the platform predicate (web mode) and the fetch boundary. We mock
|
||||
// @/lib/http — NOT globalThis.fetch — because httpFetch captures the fetch
|
||||
// reference at module load, so vi.stubGlobal would not be observed by dispatchFor.
|
||||
const { mockIsTauri, httpFetchMock } = vi.hoisted(() => ({
|
||||
mockIsTauri: vi.fn(() => false),
|
||||
httpFetchMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||
vi.mock("@/lib/http", () => ({ httpFetch: httpFetchMock }));
|
||||
|
||||
import { checkConnection } from "@/lib/config";
|
||||
|
||||
afterEach(() => {
|
||||
httpFetchMock.mockReset();
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("checkConnection — web proxy mode", () => {
|
||||
it("calls the absolute same-origin /api path with the upstream header", async () => {
|
||||
httpFetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const res = await checkConnection("https://honcho.example.net", "sk-1");
|
||||
expect(res.status).toBe("ok");
|
||||
const [url, init] = httpFetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe(`${location.origin}/api/v3/workspaces/list`);
|
||||
expect((init.headers as Record<string, string>)["X-Honcho-Upstream"]).toBe(
|
||||
"https://honcho.example.net",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer sk-1");
|
||||
});
|
||||
|
||||
it("maps an upstream 401 to auth-required", async () => {
|
||||
httpFetchMock.mockResolvedValue(new Response("{}", { status: 401 }));
|
||||
const res = await checkConnection("https://honcho.example.net");
|
||||
expect(res.status).toBe("auth-required");
|
||||
});
|
||||
|
||||
it("treats a proxy reject as unreachable, not auth-required", async () => {
|
||||
httpFetchMock.mockResolvedValue(
|
||||
new Response("", { status: 403, headers: { "X-Honcho-Proxy-Reject": "allowlist" } }),
|
||||
);
|
||||
const res = await checkConnection("https://blocked.example.net");
|
||||
expect(res.status).toBe("unreachable");
|
||||
expect(res.message).toMatch(/allowlist/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkConnection — timeout budget", () => {
|
||||
// A fetch that resolves after `ms`, but rejects early if the abort signal fires —
|
||||
// mirrors how a real slow upstream interacts with AbortSignal.timeout.
|
||||
function delayedFetch(ms: number) {
|
||||
return (_url: string, init: { signal?: AbortSignal }) =>
|
||||
new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve(new Response("{}", { status: 200 })), ms);
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("The operation timed out", "TimeoutError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("reports unreachable when the upstream is slower than the timeout budget", async () => {
|
||||
httpFetchMock.mockImplementation(delayedFetch(80));
|
||||
const res = await checkConnection("https://slow.example.net", undefined, 20);
|
||||
expect(res.status).toBe("unreachable");
|
||||
});
|
||||
|
||||
it("succeeds when a slow upstream responds within the (cold-start) budget", async () => {
|
||||
httpFetchMock.mockImplementation(delayedFetch(20));
|
||||
const res = await checkConnection("https://slow.example.net", undefined, 200);
|
||||
expect(res.status).toBe("ok");
|
||||
});
|
||||
});
|
||||
40
packages/web/src/test/dispatch.test.ts
Normal file
40
packages/web/src/test/dispatch.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockIsTauri } = vi.hoisted(() => ({ mockIsTauri: vi.fn() }));
|
||||
vi.mock("@/lib/platform", () => ({ isTauri: () => mockIsTauri() }));
|
||||
|
||||
import { API_PREFIX, dispatchFor, PROXY_REJECT_HEADER, UPSTREAM_HEADER } from "@/lib/dispatch";
|
||||
|
||||
afterEach(() => mockIsTauri.mockReset());
|
||||
|
||||
describe("dispatchFor — web mode", () => {
|
||||
it("targets the absolute same-origin /api base and carries the upstream header", () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
const d = dispatchFor({ baseUrl: "https://honcho.example.net/", token: "" });
|
||||
expect(d.baseUrl).toBe(`${location.origin}${API_PREFIX}`);
|
||||
expect(d.headers[UPSTREAM_HEADER]).toBe("https://honcho.example.net");
|
||||
expect(d.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds Authorization only when a token is present", () => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchFor — tauri mode", () => {
|
||||
it("targets the absolute URL with no upstream header", () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
const d = dispatchFor({ baseUrl: "https://honcho.example.net", token: "sk-1" });
|
||||
expect(d.baseUrl).toBe("https://honcho.example.net");
|
||||
expect(d.headers[UPSTREAM_HEADER]).toBeUndefined();
|
||||
expect(d.headers.Authorization).toBe("Bearer sk-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy reject header constant", () => {
|
||||
it("is the agreed sentinel name", () => {
|
||||
expect(PROXY_REJECT_HEADER).toBe("X-Honcho-Proxy-Reject");
|
||||
});
|
||||
});
|
||||
@@ -103,23 +103,25 @@ describe("scoped option builders", () => {
|
||||
(httpFetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
});
|
||||
|
||||
it("scopes queue status requests by instance baseUrl and token", async () => {
|
||||
it("scopes queue status requests via the same-origin proxy, upstream header, and token", async () => {
|
||||
const { httpFetch } = await import("@/lib/http");
|
||||
const opts = scopedQueueStatusOptions(neo, "ws-1");
|
||||
await opts.queryFn();
|
||||
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
||||
expect(req.url).toBe("http://localhost:8001/v3/workspaces/ws-1/queue/status");
|
||||
expect(req.url).toBe(`${location.origin}/api/v3/workspaces/ws-1/queue/status`);
|
||||
expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8001");
|
||||
expect(req.headers.get("Authorization")).toBe("Bearer neo-token");
|
||||
});
|
||||
|
||||
it("scopes conclusions-count requests by instance baseUrl and token", async () => {
|
||||
it("scopes conclusions-count requests via the same-origin proxy, upstream header, and token", async () => {
|
||||
const { httpFetch } = await import("@/lib/http");
|
||||
const opts = scopedConclusionsCountOptions(iris, "ws-9");
|
||||
await opts.queryFn();
|
||||
const req = (httpFetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as Request;
|
||||
expect(req.url.startsWith("http://localhost:8002/v3/workspaces/ws-9/conclusions/list")).toBe(
|
||||
expect(req.url.startsWith(`${location.origin}/api/v3/workspaces/ws-9/conclusions/list`)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(req.headers.get("X-Honcho-Upstream")).toBe("http://localhost:8002");
|
||||
expect(req.headers.get("Authorization")).toBe("Bearer iris-token");
|
||||
});
|
||||
|
||||
|
||||
125
packages/web/src/test/instances.test.ts
Normal file
125
packages/web/src/test/instances.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addInstance,
|
||||
deleteInstance,
|
||||
getActiveInstance,
|
||||
loadConfig,
|
||||
loadStore,
|
||||
setActiveInstance,
|
||||
updateInstance,
|
||||
} from "@/lib/config";
|
||||
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
const LEGACY_KEY = "openconcho:config";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("instance store — add + active selection", () => {
|
||||
it("makes the first added instance active", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
expect(loadStore().activeId).toBe(a.id);
|
||||
});
|
||||
|
||||
it("does not steal active focus when adding more instances", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||
expect(loadStore().activeId).toBe(a.id);
|
||||
});
|
||||
|
||||
it("appends instances in insertion order", () => {
|
||||
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||
expect(loadStore().instances.map((i) => i.name)).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance store — switching active", () => {
|
||||
it("switches the active instance", () => {
|
||||
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||
setActiveInstance(b.id);
|
||||
expect(getActiveInstance()?.id).toBe(b.id);
|
||||
});
|
||||
|
||||
it("ignores an unknown id", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
setActiveInstance("does-not-exist");
|
||||
expect(getActiveInstance()?.id).toBe(a.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance store — deletion", () => {
|
||||
it("falls back to the first remaining when the active instance is deleted", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||
setActiveInstance(b.id);
|
||||
deleteInstance(b.id);
|
||||
expect(loadStore().activeId).toBe(a.id);
|
||||
});
|
||||
|
||||
it("leaves the active id unchanged when a non-active instance is deleted", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
const b = addInstance({ name: "B", baseUrl: "https://b.example.net", token: "" });
|
||||
deleteInstance(b.id);
|
||||
expect(getActiveInstance()?.id).toBe(a.id);
|
||||
});
|
||||
|
||||
it("clears the active id when the last instance is removed", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
deleteInstance(a.id);
|
||||
expect(loadStore().activeId).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null config once every instance is gone", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
deleteInstance(a.id);
|
||||
expect(loadConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance store — update", () => {
|
||||
it("patches the named fields", () => {
|
||||
const a = addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
updateInstance(a.id, { name: "Renamed", token: "sk-1" });
|
||||
expect(loadStore().instances[0]).toMatchObject({ name: "Renamed", token: "sk-1" });
|
||||
});
|
||||
|
||||
it("no-ops on an unknown id", () => {
|
||||
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "" });
|
||||
updateInstance("nope", { name: "X" });
|
||||
expect(loadStore().instances[0].name).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance store — active config", () => {
|
||||
it("reflects the active instance's url and token", () => {
|
||||
addInstance({ name: "A", baseUrl: "https://a.example.net", token: "sk-a" });
|
||||
expect(loadConfig()).toEqual({ baseUrl: "https://a.example.net", token: "sk-a" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance store — legacy migration", () => {
|
||||
it("migrates the legacy single-config key into the instances store", () => {
|
||||
localStorage.setItem(
|
||||
LEGACY_KEY,
|
||||
JSON.stringify({ baseUrl: "https://legacy.example.net", token: "sk-legacy" }),
|
||||
);
|
||||
const store = loadStore();
|
||||
expect(store.instances[0]).toMatchObject({
|
||||
name: "Default",
|
||||
baseUrl: "https://legacy.example.net",
|
||||
token: "sk-legacy",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the legacy key after migrating", () => {
|
||||
localStorage.setItem(
|
||||
LEGACY_KEY,
|
||||
JSON.stringify({ baseUrl: "https://legacy.example.net", token: "" }),
|
||||
);
|
||||
loadStore();
|
||||
expect(localStorage.getItem(LEGACY_KEY)).toBeNull();
|
||||
expect(localStorage.getItem(STORE_KEY)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
17
packages/web/src/test/platform.test.ts
Normal file
17
packages/web/src/test/platform.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isTauri } from "@/lib/platform";
|
||||
|
||||
describe("isTauri", () => {
|
||||
afterEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
|
||||
it("returns false in a plain browser/jsdom environment", () => {
|
||||
expect(isTauri()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the Tauri internals global is present", () => {
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||
expect(isTauri()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,22 +8,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("runtimeDefaultBaseUrl", () => {
|
||||
it("returns null when the global is unset", () => {
|
||||
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the global is blank", () => {
|
||||
(globalThis as Record<string, unknown>)[KEY] = " ";
|
||||
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an absolute URL verbatim", () => {
|
||||
it("returns an injected absolute URL verbatim", () => {
|
||||
(globalThis as Record<string, unknown>)[KEY] = "https://honcho.example.net";
|
||||
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
|
||||
});
|
||||
|
||||
it("resolves 'same-origin' to the page origin", () => {
|
||||
(globalThis as Record<string, unknown>)[KEY] = "same-origin";
|
||||
expect(runtimeDefaultBaseUrl()).toBe(location.origin);
|
||||
it("returns null when unset or empty", () => {
|
||||
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||
(globalThis as Record<string, unknown>)[KEY] = " ";
|
||||
expect(runtimeDefaultBaseUrl()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user