mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Expose self-host daemon setup URLs from /api/config at runtime so the Add computer dialog renders the operator's own server/app domains, while Multica Cloud defaults stay unchanged. Fixes #3013.
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { ApiClient } from "./client";
|
|
import { parseWithFallback } from "./schema";
|
|
|
|
// Helper: stub fetch with a single JSON response. Status defaults to 200.
|
|
function stubFetchJson(body: unknown, status = 200) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
// These tests cover the five failure modes that white-screened the desktop
|
|
// app in past incidents. The contract is: a malformed response degrades to
|
|
// an empty/safe shape, never throws into React.
|
|
describe("ApiClient schema fallback", () => {
|
|
describe("listTimeline", () => {
|
|
it("falls back to an empty array when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("falls back when the body is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("accepts a new entry type rather than crashing on enum drift", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "future_kind", // not in TS union
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]?.type).toBe("future_kind");
|
|
});
|
|
|
|
// Forward-compat: when the server adds a new field to an existing
|
|
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
|
|
// zod 4 strips it, which would silently break a future TS type that
|
|
// adopts the field — see schemas.ts header comment.
|
|
it("preserves unknown fields the schema didn't list", async () => {
|
|
stubFetchJson([
|
|
{
|
|
type: "comment",
|
|
id: "e-1",
|
|
actor_type: "member",
|
|
actor_id: "u-1",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
// New server-side field not present in TimelineEntrySchema:
|
|
future_field: { nested: "value" },
|
|
},
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const entries = await client.listTimeline("issue-1");
|
|
const entry = entries[0] as unknown as Record<string, unknown>;
|
|
expect(entry.future_field).toEqual({ nested: "value" });
|
|
});
|
|
});
|
|
|
|
describe("listIssues", () => {
|
|
it("falls back to an empty list when the response is malformed", async () => {
|
|
// `issues` having the wrong type triggers the fallback. An object
|
|
// with only unexpected keys would *succeed* parsing now (every
|
|
// declared field has a default) and just pass the extras through
|
|
// via `.loose()`, so we use a wrong-type payload here instead.
|
|
stubFetchJson({ issues: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listIssues();
|
|
expect(res).toEqual({ issues: [], total: 0 });
|
|
});
|
|
});
|
|
|
|
describe("getConfig", () => {
|
|
it("drops malformed daemon setup URLs instead of throwing", async () => {
|
|
stubFetchJson({
|
|
cdn_domain: "cdn.example.com",
|
|
allow_signup: true,
|
|
daemon_server_url: { wrong: "shape" },
|
|
daemon_app_url: 123,
|
|
workspace_creation_disabled: false,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const config = await client.getConfig();
|
|
expect(config.cdn_domain).toBe("cdn.example.com");
|
|
expect(config.allow_signup).toBe(true);
|
|
expect(config.daemon_server_url).toBeUndefined();
|
|
expect(config.daemon_app_url).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("listGroupedIssues", () => {
|
|
it("falls back to empty groups when the response is malformed", async () => {
|
|
stubFetchJson({ groups: "not-an-array" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listGroupedIssues({ group_by: "assignee" });
|
|
expect(res).toEqual({ groups: [] });
|
|
});
|
|
});
|
|
|
|
describe("listComments", () => {
|
|
it("returns [] when the response is not an array", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const comments = await client.listComments("issue-1");
|
|
expect(comments).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listIssueSubscribers", () => {
|
|
it("returns [] when the response is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const subs = await client.listIssueSubscribers("issue-1");
|
|
expect(subs).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("listChildIssues", () => {
|
|
it("returns { issues: [] } when the issues field is missing", async () => {
|
|
stubFetchJson({});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listChildIssues("issue-1");
|
|
expect(res).toEqual({ issues: [] });
|
|
});
|
|
});
|
|
|
|
// Agent template catalog is hit by the desktop create-agent picker.
|
|
// Installed desktop builds outlive any given server, so the shape MUST
|
|
// survive future field renames / wrapping without crashing. Each test
|
|
// here mirrors a concrete future drift we want to absorb.
|
|
describe("listAgentTemplates", () => {
|
|
it("falls back to [] when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toEqual([]);
|
|
});
|
|
|
|
it("defaults skills to [] when the field is missing from a template", async () => {
|
|
// Future server: drops `skills` because the picker no longer reads
|
|
// them. Picker code calls `template.skills.length` — must not throw.
|
|
stubFetchJson([{ slug: "x", name: "X" }]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toHaveLength(1);
|
|
expect(tmpls[0]?.skills).toEqual([]);
|
|
});
|
|
|
|
it("accepts the bare-array shape (current contract)", async () => {
|
|
stubFetchJson([
|
|
{ slug: "a", name: "A", description: "", skills: [] },
|
|
{ slug: "b", name: "B", description: "", skills: [] },
|
|
]);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("accepts a future {templates: [...]} envelope without breaking", async () => {
|
|
// Server migrates to a paginated envelope. We unwrap so the picker
|
|
// keeps working on the older bare-array consumer.
|
|
stubFetchJson({
|
|
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
|
|
total: 1,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const tmpls = await client.listAgentTemplates();
|
|
expect(tmpls).toHaveLength(1);
|
|
expect(tmpls[0]?.slug).toBe("a");
|
|
});
|
|
});
|
|
|
|
describe("getAgentTemplate", () => {
|
|
it("falls back to a minimal record carrying the requested slug", async () => {
|
|
// Slug is part of the URL the user clicked — the fallback round-
|
|
// trips it so the page header still makes sense after a parse miss.
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAgentTemplate("code-reviewer");
|
|
expect(detail.slug).toBe("code-reviewer");
|
|
expect(detail.skills).toEqual([]);
|
|
expect(detail.instructions).toBe("");
|
|
});
|
|
|
|
it("defaults instructions to '' when the field is missing", async () => {
|
|
stubFetchJson({
|
|
slug: "code-reviewer",
|
|
name: "Code Reviewer",
|
|
description: "",
|
|
skills: [],
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAgentTemplate("code-reviewer");
|
|
expect(detail.instructions).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("listAutopilotDeliveries", () => {
|
|
it("falls back to an empty list when the body is null", async () => {
|
|
stubFetchJson(null);
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res).toEqual({ deliveries: [], total: 0 });
|
|
});
|
|
|
|
it("falls back to an empty list when `deliveries` is not an array", async () => {
|
|
stubFetchJson({ deliveries: "not-an-array", total: 0 });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res).toEqual({ deliveries: [], total: 0 });
|
|
});
|
|
|
|
it("accepts an unknown future status value rather than dropping the row", async () => {
|
|
// Server-side enum drift (e.g. new `quarantined` state). The list
|
|
// must still surface the row; downstream UI code's `default` arm
|
|
// handles unknown values with a generic visual.
|
|
stubFetchJson({
|
|
deliveries: [
|
|
{
|
|
id: "d-1",
|
|
workspace_id: "ws-1",
|
|
autopilot_id: "ap-1",
|
|
trigger_id: "t-1",
|
|
provider: "github",
|
|
event: "pull_request.opened",
|
|
dedupe_key: "abc",
|
|
dedupe_source: "x-github-delivery",
|
|
signature_status: "valid",
|
|
status: "quarantined",
|
|
attempt_count: 1,
|
|
content_type: "application/json",
|
|
response_status: 200,
|
|
autopilot_run_id: null,
|
|
replayed_from_delivery_id: null,
|
|
error: null,
|
|
received_at: "2026-01-01T00:00:00Z",
|
|
last_attempt_at: "2026-01-01T00:00:00Z",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
const client = new ApiClient("https://api.example.test");
|
|
const res = await client.listAutopilotDeliveries("ap-1");
|
|
expect(res.deliveries).toHaveLength(1);
|
|
expect(res.deliveries[0]?.status).toBe("quarantined");
|
|
});
|
|
});
|
|
|
|
describe("getAutopilotDelivery", () => {
|
|
it("falls back to a placeholder carrying the requested id", async () => {
|
|
stubFetchJson({ wrong: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
|
|
expect(detail.id).toBe("d-1");
|
|
expect(detail.autopilot_id).toBe("ap-1");
|
|
});
|
|
});
|
|
|
|
describe("createAgentFromTemplate", () => {
|
|
it("falls back to an empty agent when the response is malformed", async () => {
|
|
// The agent was created server-side even though the client can't
|
|
// parse the response — UI code reads `agent.id === ""` and skips
|
|
// the navigation step rather than landing on `/agents/`.
|
|
stubFetchJson({ unexpected: "shape" });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const resp = await client.createAgentFromTemplate({
|
|
template_slug: "x",
|
|
name: "X",
|
|
runtime_id: "rt-1",
|
|
});
|
|
expect(resp.agent.id).toBe("");
|
|
expect(resp.imported_skill_ids).toEqual([]);
|
|
expect(resp.reused_skill_ids).toEqual([]);
|
|
});
|
|
|
|
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
|
|
stubFetchJson({ agent: { id: "agent-1" } });
|
|
const client = new ApiClient("https://api.example.test");
|
|
const resp = await client.createAgentFromTemplate({
|
|
template_slug: "x",
|
|
name: "X",
|
|
runtime_id: "rt-1",
|
|
});
|
|
expect(resp.agent.id).toBe("agent-1");
|
|
expect(resp.imported_skill_ids).toEqual([]);
|
|
expect(resp.reused_skill_ids).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Direct tests for the helper, decoupled from any specific endpoint —
|
|
// guards against an endpoint refactor masking a regression in the helper.
|
|
describe("parseWithFallback", () => {
|
|
const opts = { endpoint: "TEST /unit" };
|
|
|
|
it("returns parsed data on success", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
|
|
expect(out).toEqual({ id: "x" });
|
|
});
|
|
|
|
it("returns the fallback when validation fails", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
|
|
it("returns the fallback when data is null", () => {
|
|
const schema = z.object({ id: z.string() });
|
|
const fallback = { id: "fallback" };
|
|
const out = parseWithFallback(null, schema, fallback, opts);
|
|
expect(out).toBe(fallback);
|
|
});
|
|
});
|