Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2117ec8ac | |||
| e1bf973d91 | |||
| 62b150308c | |||
| e86f3bbda6 | |||
| 7825919439 | |||
| 9baca38f5e | |||
| 28dd21a78b | |||
| 33bffd9293 | |||
| 6b4bcb3b94 | |||
| e912df5438 | |||
| b417688588 | |||
| ef87b2e3e8 | |||
| 6041e36cf1 | |||
| 7ebaa3a686 | |||
| f5bc58f472 | |||
| 8aee937104 | |||
| 0bea8b5a41 | |||
| 563ea2b7ba |
@@ -18,6 +18,109 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
// ─── Pure fill helpers ────────────────────────────────────────────────────────
|
||||
// Each snippet is server-stamped with workspace_id + platform_url but leaves
|
||||
// AUTH_TOKEN as a placeholder. These helpers stamp the real token in so the
|
||||
// operator's copy-paste is truly ready-to-run. All are pure string ops.
|
||||
|
||||
export function fillPythonSnippet(
|
||||
snippet: string,
|
||||
authToken: string,
|
||||
): string {
|
||||
return snippet.replace(
|
||||
'AUTH_TOKEN = "<paste from create response>"',
|
||||
`AUTH_TOKEN = "${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillCurlSnippet(
|
||||
snippet: string,
|
||||
authToken: string,
|
||||
): string {
|
||||
return snippet.replace(
|
||||
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_AUTH_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillChannelSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKENS=${authToken}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillUniversalMcpSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillHermesSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillCodexSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillOpenClawSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the ordered tab list shown in the modal. Each tab only appears when
|
||||
* the platform supplies the corresponding snippet. */
|
||||
export function buildTabOrder(info: ExternalConnectionInfo): Tab[] {
|
||||
const tabs: Tab[] = [];
|
||||
const { filledUniversalMcp, filledChannel, filledHermes, filledCodex, filledOpenClaw } = buildFilledSnippets(info);
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/** Pre-fill all snippets from an info object. Exposed for testing. */
|
||||
export function buildFilledSnippets(info: ExternalConnectionInfo) {
|
||||
return {
|
||||
filledPython: fillPythonSnippet(info.python_snippet, info.auth_token),
|
||||
filledCurl: fillCurlSnippet(info.curl_register_template, info.auth_token),
|
||||
filledChannel: fillChannelSnippet(info.claude_code_channel_snippet, info.auth_token),
|
||||
filledUniversalMcp: fillUniversalMcpSnippet(info.universal_mcp_snippet, info.auth_token),
|
||||
filledHermes: fillHermesSnippet(info.hermes_channel_snippet, info.auth_token),
|
||||
filledCodex: fillCodexSnippet(info.codex_snippet, info.auth_token),
|
||||
filledOpenClaw: fillOpenClawSnippet(info.openclaw_snippet, info.auth_token),
|
||||
};
|
||||
}
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
@@ -102,54 +205,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
// Python snippet is stamped server-side with workspace_id +
|
||||
// platform_url but leaves AUTH_TOKEN as a "<paste …>" placeholder
|
||||
// (that's what we're showing in the modal). Fill in the real
|
||||
// token here so the snippet the operator copies is truly ready-to-run.
|
||||
const filledPython = info.python_snippet.replace(
|
||||
'AUTH_TOKEN = "<paste from create response>"',
|
||||
`AUTH_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledCurl = info.curl_register_template.replace(
|
||||
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_AUTH_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// The channel snippet asks the operator to paste the auth_token into
|
||||
// the .env file's MOLECULE_WORKSPACE_TOKENS field. Stamp it server-side
|
||||
// here so the copy-paste-block is truly ready-to-run.
|
||||
const filledChannel = info.claude_code_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`,
|
||||
);
|
||||
// Universal MCP snippet uses MOLECULE_WORKSPACE_TOKEN as the env-var
|
||||
// name passed through to molecule-mcp via `claude mcp add ... -- env
|
||||
// MOLECULE_WORKSPACE_TOKEN=...`. The placeholder must match the
|
||||
// template's literal — pre-2026-04-30 polish this looked for
|
||||
// WORKSPACE_AUTH_TOKEN (carryover from the curl tab), which silently
|
||||
// skipped the substitution and left "<paste from create response>"
|
||||
// visible in the operator's clipboard.
|
||||
const filledUniversalMcp = info.universal_mcp_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
|
||||
// name as Universal MCP). Stamp the auth_token in so the operator's
|
||||
// copy-paste is fully ready-to-run.
|
||||
const filledHermes = info.hermes_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Codex + OpenClaw snippets carry the placeholder inside the
|
||||
// generated config block (TOML / JSON respectively). Stamp the
|
||||
// token in so the copy-paste is one less manual edit.
|
||||
const filledCodex = info.codex_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledOpenClaw = info.openclaw_snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
const { filledPython, filledCurl, filledChannel, filledUniversalMcp, filledHermes, filledCodex, filledOpenClaw } = buildFilledSnippets(info);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -171,27 +227,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
// (when offered) since it's the simplest setup; Python
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
{buildTabOrder(info).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fillPythonSnippet,
|
||||
fillCurlSnippet,
|
||||
fillChannelSnippet,
|
||||
fillUniversalMcpSnippet,
|
||||
fillHermesSnippet,
|
||||
fillCodexSnippet,
|
||||
fillOpenClawSnippet,
|
||||
buildFilledSnippets,
|
||||
buildTabOrder,
|
||||
ExternalConnectionInfo,
|
||||
} from '../ExternalConnectModal';
|
||||
|
||||
// ─── fillPythonSnippet ───────────────────────────────────────────────────────
|
||||
|
||||
describe('fillPythonSnippet', () => {
|
||||
it('stamps auth_token into the AUTH_TOKEN placeholder', () => {
|
||||
const input =
|
||||
'AUTH_TOKEN = "<paste from create response>"\n' +
|
||||
'PLATFORM_URL = "http://localhost:8080"';
|
||||
const got = fillPythonSnippet(input, 'tok-abc123');
|
||||
expect(got).toContain('AUTH_TOKEN = "tok-abc123"');
|
||||
// Original placeholder is gone
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
|
||||
it('leaves other lines untouched', () => {
|
||||
const input = 'PLATFORM_URL = "http://localhost:8080"\nAUTH_TOKEN = "<paste from create response>"';
|
||||
const got = fillPythonSnippet(input, 'tok-xyz');
|
||||
expect(got).toContain('PLATFORM_URL = "http://localhost:8080"');
|
||||
});
|
||||
|
||||
it('handles empty token', () => {
|
||||
const input = 'AUTH_TOKEN = "<paste from create response>"';
|
||||
const got = fillPythonSnippet(input, '');
|
||||
expect(got).toContain('AUTH_TOKEN = ""');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillCurlSnippet ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('fillCurlSnippet', () => {
|
||||
it('stamps auth_token into WORKSPACE_AUTH_TOKEN placeholder', () => {
|
||||
const input = 'WORKSPACE_AUTH_TOKEN="<paste from create response>"';
|
||||
const got = fillCurlSnippet(input, 'tok-curl');
|
||||
expect(got).toContain('WORKSPACE_AUTH_TOKEN="tok-curl"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillChannelSnippet ─────────────────────────────────────────────────────
|
||||
|
||||
describe('fillChannelSnippet', () => {
|
||||
it('stamps token into MOLECULE_WORKSPACE_TOKENS placeholder', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>';
|
||||
const got = fillChannelSnippet(input, 'tok-channel');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKENS=tok-channel');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillChannelSnippet(undefined, 'tok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillUniversalMcpSnippet ───────────────────────────────────────────────
|
||||
|
||||
describe('fillUniversalMcpSnippet', () => {
|
||||
it('stamps token with double-quoted value', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillUniversalMcpSnippet(input, 'tok-mcp');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN="tok-mcp"');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillUniversalMcpSnippet(undefined, 'tok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillHermesSnippet ─────────────────────────────────────────────────────
|
||||
|
||||
describe('fillHermesSnippet', () => {
|
||||
it('stamps token with double-quoted value', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillHermesSnippet(input, 'tok-hermes');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN="tok-hermes"');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillHermesSnippet(undefined, 'tok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillCodexSnippet ──────────────────────────────────────────────────────
|
||||
|
||||
describe('fillCodexSnippet', () => {
|
||||
it('uses TOML spacing (space around equals)', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"';
|
||||
const got = fillCodexSnippet(input, 'tok-codex');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN = "tok-codex"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillCodexSnippet(undefined, 'tok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillOpenClawSnippet ───────────────────────────────────────────────────
|
||||
|
||||
describe('fillOpenClawSnippet', () => {
|
||||
it('stamps token with WORKSPACE_TOKEN key name', () => {
|
||||
const input = 'WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillOpenClawSnippet(input, 'tok-oc');
|
||||
expect(got).toContain('WORKSPACE_TOKEN="tok-oc"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillOpenClawSnippet(undefined, 'tok')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildFilledSnippets ────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFilledSnippets', () => {
|
||||
const makeInfo = (overrides: Partial<ExternalConnectionInfo> = {}): ExternalConnectionInfo =>
|
||||
({
|
||||
workspace_id: 'ws-1',
|
||||
platform_url: 'http://localhost:8080',
|
||||
auth_token: 'tok-test',
|
||||
registry_endpoint: 'http://localhost:8080/registry/register',
|
||||
heartbeat_endpoint: 'http://localhost:8080/registry/heartbeat',
|
||||
python_snippet: 'AUTH_TOKEN = "<paste from create response>"',
|
||||
curl_register_template: 'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('fills python snippet', () => {
|
||||
const { filledPython } = buildFilledSnippets(makeInfo());
|
||||
expect(filledPython).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills curl snippet', () => {
|
||||
const { filledCurl } = buildFilledSnippets(makeInfo());
|
||||
expect(filledCurl).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills claude_code_channel_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
});
|
||||
const { filledChannel } = buildFilledSnippets(info);
|
||||
expect(filledChannel).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills universal_mcp_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledUniversalMcp } = buildFilledSnippets(info);
|
||||
expect(filledUniversalMcp).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills hermes_channel_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledHermes } = buildFilledSnippets(info);
|
||||
expect(filledHermes).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills codex_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
});
|
||||
const { filledCodex } = buildFilledSnippets(info);
|
||||
expect(filledCodex).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills openclaw_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledOpenClaw } = buildFilledSnippets(info);
|
||||
expect(filledOpenClaw).toContain('tok-test');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildTabOrder ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildTabOrder', () => {
|
||||
const makeInfo = (overrides: Partial<ExternalConnectionInfo> = {}): ExternalConnectionInfo =>
|
||||
({
|
||||
workspace_id: 'ws-1',
|
||||
platform_url: 'http://localhost:8080',
|
||||
auth_token: 'tok-test',
|
||||
registry_endpoint: 'http://localhost:8080/registry/register',
|
||||
heartbeat_endpoint: 'http://localhost:8080/registry/heartbeat',
|
||||
python_snippet: 'AUTH_TOKEN = "<paste from create response>"',
|
||||
curl_register_template: 'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('python is always present', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).toContain('python');
|
||||
});
|
||||
|
||||
it('curl and fields are always present', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).toContain('curl');
|
||||
expect(tabs).toContain('fields');
|
||||
});
|
||||
|
||||
it('mcp first when universal_mcp_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs[0]).toBe('mcp');
|
||||
});
|
||||
|
||||
it('python first when universal_mcp_snippet is absent', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs[0]).toBe('python');
|
||||
});
|
||||
|
||||
it('mcp excluded when universal_mcp_snippet is absent', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).not.toContain('mcp');
|
||||
});
|
||||
|
||||
it('includes claude when claude_code_channel_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
}));
|
||||
expect(tabs).toContain('claude');
|
||||
});
|
||||
|
||||
it('includes hermes when hermes_channel_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('hermes');
|
||||
});
|
||||
|
||||
it('includes codex when codex_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('codex');
|
||||
});
|
||||
|
||||
it('includes openclaw when openclaw_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('openclaw');
|
||||
});
|
||||
|
||||
it('all optional tabs at once: full house', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toEqual([
|
||||
'mcp', 'python', 'claude', 'hermes', 'codex', 'openclaw', 'curl', 'fields',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ const FILE_ICONS: Record<string, string> = {
|
||||
|
||||
export function getIcon(path: string, isDir: boolean): string {
|
||||
if (isDir) return "📁";
|
||||
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
|
||||
const parts = path.split(".");
|
||||
const ext = parts.length > 1 ? "." + parts[parts.length - 1].toLowerCase() : "";
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ChannelsTab component.
|
||||
*
|
||||
* Covers: relativeTime pure function, SUPPORTS_DETECT_CHATS constant,
|
||||
* loading/empty/error states, channel list rendering (enabled/disabled),
|
||||
* auto-refresh interval, header + Connect button.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelsTab } from "../ChannelsTab";
|
||||
|
||||
// Mock @/lib/api — hoisted so it's applied before the module loads.
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
|
||||
// ─── SUPPORTS_DETECT_CHATS ─────────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — SUPPORTS_DETECT_CHATS", () => {
|
||||
it("supports Detect Chats for telegram", async () => {
|
||||
// Telegram is the only platform that supports Detect Chats.
|
||||
// This is a smoke test: the tab must render without crashing.
|
||||
// NOTE: ChannelsTab calls Promise.allSettled([channels, adapters]) so
|
||||
// both must be mocked for the load() to complete and exit loading state.
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Channels")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — states", () => {
|
||||
it("shows loading text initially", () => {
|
||||
_mockGet.mockImplementation(
|
||||
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>
|
||||
);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Loading channels...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty message when no channels", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No channels connected")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error alert when channels fetch fails", async () => {
|
||||
// Channels fails; adapters succeeds. Both must be resolved/rejected
|
||||
// for Promise.allSettled to settle and setLoading(false).
|
||||
_mockGet.mockRejectedValueOnce(new Error("server error"));
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
// The component renders a generic error message, not the raw error text.
|
||||
expect(screen.getByText(/Failed to load connected channels/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error alert when adapters fetch fails", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
_mockGet.mockRejectedValueOnce(new Error("adapters error"));
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load platforms/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Channel list ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — channel list", () => {
|
||||
// ChannelsTab.load() calls Promise.allSettled([channels, adapters]).
|
||||
// Both must be mocked for load() to complete and exit loading state.
|
||||
const channelsPayload = (channels: object[]) => channels;
|
||||
const adaptersPayload: unknown[] = [];
|
||||
|
||||
it("renders one channel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: { chat_id: "12345" }, enabled: true, allowed_users: [],
|
||||
message_count: 10, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Telegram")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders multiple channels", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([
|
||||
{ id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: { chat_id: "1" }, enabled: true, allowed_users: [],
|
||||
message_count: 5, last_message_at: null, created_at: new Date().toISOString() },
|
||||
{ id: "ch-2", workspace_id: "ws-1", channel_type: "slack",
|
||||
config: { channel_id: "C0123" }, enabled: false, allowed_users: [],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString() },
|
||||
]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Telegram")).toBeTruthy();
|
||||
expect(screen.getByText("Slack")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("capitalises channel type", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "discord",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Discord")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'On' toggle for enabled channel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 1, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
// Use exact string "On" (not regex) — "Connect" contains "on" and would
|
||||
// match /on/i, causing multiple-element errors.
|
||||
expect(screen.getByRole("button", { name: "On" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Off' toggle for disabled channel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: false, allowed_users: [],
|
||||
message_count: 1, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Off" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows message count", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 42, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("42 messages")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Last: never' when last_message_at is null", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Last: never")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows allowed user count when users are present", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: ["alice", "bob"],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2 allowed user(s)")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("has a Test button for each channel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /test/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("has a Remove button for each channel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(channelsPayload([{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 0, last_message_at: null, created_at: new Date().toISOString(),
|
||||
}]));
|
||||
_mockGet.mockResolvedValueOnce(adaptersPayload);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /remove/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header + Connect button ───────────────────────────────────────────────
|
||||
|
||||
describe("ChannelsTab — header", () => {
|
||||
it("renders the Channels heading", async () => {
|
||||
_mockGet.mockResolvedValue([]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Channels")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("has a Connect button when channels exist", async () => {
|
||||
_mockGet.mockResolvedValue([
|
||||
{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 1, last_message_at: null, created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /connect/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("has a Cancel button when form is open", async () => {
|
||||
_mockGet.mockResolvedValue([
|
||||
{
|
||||
id: "ch-1", workspace_id: "ws-1", channel_type: "telegram",
|
||||
config: {}, enabled: true, allowed_users: [],
|
||||
message_count: 1, last_message_at: null, created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
render(<ChannelsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole("button", { name: /connect/i });
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for TracesTab component.
|
||||
*
|
||||
* Covers: loading/empty/error states, trace list rendering, expand/collapse,
|
||||
* status dot coloring, latency formatting, token usage display, Refresh button.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TracesTab } from "../TracesTab";
|
||||
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TracesTab — states", () => {
|
||||
it("shows loading text initially", () => {
|
||||
_mockGet.mockImplementation(
|
||||
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>
|
||||
);
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Loading traces...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty message when no traces", async () => {
|
||||
_mockGet.mockResolvedValueOnce({ data: [] });
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No traces yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty message when API returns null data", async () => {
|
||||
_mockGet.mockResolvedValueOnce({ data: null });
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No traces yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error alert when fetch fails", async () => {
|
||||
_mockGet.mockRejectedValueOnce(new Error("trace service unavailable"));
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/trace service unavailable/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Trace list ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("TracesTab — trace list", () => {
|
||||
it("renders one trace", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "deploy-agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "ok",
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("deploy-agent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders trace name as fallback to 'trace'", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "",
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("trace")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows trace count in header", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: "t-1", name: "A", timestamp: new Date().toISOString() },
|
||||
{ id: "t-2", name: "B", timestamp: new Date().toISOString() },
|
||||
{ id: "t-3", name: "C", timestamp: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3 traces")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows latency in milliseconds for values < 1000", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "Fast trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: 250,
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("250ms")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows latency in seconds for values >= 1000", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "Slow trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: 3500,
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3.5s")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows token usage when present", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "AI trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
usage: { input: 100, output: 200, total: 300 },
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("300 tok")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show latency when latency is absent", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-1",
|
||||
name: "Trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Trace")).toBeTruthy();
|
||||
// Should have no "ms" or "s" latency text
|
||||
expect(screen.queryByText(/\d+ms/)).toBeNull();
|
||||
expect(screen.queryByText(/\d+\.\d+s/)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Expand / Collapse ───────────────────────────────────────────────────
|
||||
|
||||
describe("TracesTab — expand/collapse", () => {
|
||||
it("expands a trace row on click", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-expand",
|
||||
name: "Expandable",
|
||||
timestamp: new Date().toISOString(),
|
||||
input: { prompt: "hello" },
|
||||
output: { result: "world" },
|
||||
totalCost: 0.000123,
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("Expandable"));
|
||||
|
||||
fireEvent.click(screen.getByText("Expandable"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Input")).toBeTruthy();
|
||||
expect(screen.getByText("Output")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows trace ID in expanded panel", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "trace-id-abc123",
|
||||
name: "Trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("Trace"));
|
||||
|
||||
fireEvent.click(screen.getByText("Trace"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("trace-id-abc123")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cost when totalCost is present", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-cost",
|
||||
name: "Costly trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
totalCost: 0.000456,
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("Costly trace"));
|
||||
|
||||
fireEvent.click(screen.getByText("Costly trace"));
|
||||
|
||||
await waitFor(() => {
|
||||
// Use regex — toFixed(6) is locale-sensitive (may render as "0,000456").
|
||||
expect(screen.getByText(/\$0\.000456/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses expanded row on second click", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-collapse",
|
||||
name: "Collapsible",
|
||||
timestamp: new Date().toISOString(),
|
||||
input: { key: "value" },
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("Collapsible"));
|
||||
|
||||
fireEvent.click(screen.getByText("Collapsible"));
|
||||
await waitFor(() => expect(screen.getByText("Input")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByText("Collapsible"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Input")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("has aria-expanded attribute on trace row", async () => {
|
||||
_mockGet.mockResolvedValueOnce({
|
||||
data: [{
|
||||
id: "t-a11y",
|
||||
name: "A11y trace",
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("A11y trace"));
|
||||
|
||||
const row = screen.getByText("A11y trace").closest("button");
|
||||
expect(row?.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(row!);
|
||||
await waitFor(() => {
|
||||
expect(row?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Refresh button ───────────────────────────────────────────────────────
|
||||
|
||||
describe("TracesTab — refresh", () => {
|
||||
it("has a Refresh button", async () => {
|
||||
_mockGet.mockResolvedValueOnce({ data: [] });
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {});
|
||||
expect(screen.getByRole("button", { name: /refresh/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a reload", async () => {
|
||||
_mockGet.mockResolvedValueOnce({ data: [] });
|
||||
render(<TracesTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /refresh/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
|
||||
expect(_mockGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "../uploads";
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for uploads.ts — uploadChatFiles and downloadChatFile.
|
||||
*
|
||||
* Covers: empty-file guard, successful upload, error-throw on non-ok,
|
||||
* external-URL window.open bypass, platform-attachment fetch+blob download,
|
||||
* error-throw on non-ok download, URL.createObjectURL lifecycle.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { isPlatformAttachment, resolveAttachmentHref, uploadChatFiles, downloadChatFile } from "../uploads";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
describe("resolveAttachmentHref — URI scheme normalisation", () => {
|
||||
const wsId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
@@ -164,3 +173,135 @@ describe("isPlatformAttachment", () => {
|
||||
expect(isPlatformAttachment("ftp://server/file")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── uploadChatFiles ────────────────────────────────────────────────────────
|
||||
|
||||
describe("uploadChatFiles", () => {
|
||||
const wsId = "test-ws-id";
|
||||
|
||||
// Suppress console.error from AbortSignal.timeout in node environment
|
||||
// where native AbortController may not be fully stubbed.
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let fetchMock: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockReturnValue();
|
||||
fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
fetchMock?.mockRestore();
|
||||
});
|
||||
|
||||
it("returns an empty array when given no files", async () => {
|
||||
const result = await uploadChatFiles(wsId, []);
|
||||
expect(result).toEqual([]);
|
||||
// fetch should NOT be called at all
|
||||
});
|
||||
|
||||
it("returns ChatAttachment[] on successful upload", async () => {
|
||||
const mockFiles: ChatAttachment[] = [
|
||||
{ name: "report.pdf", uri: "workspace:/workspace/report.pdf", size: 1024, mimeType: "application/pdf" },
|
||||
{ name: "data.csv", uri: "workspace:/workspace/data.csv", size: 512, mimeType: "text/csv" },
|
||||
];
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ files: mockFiles }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
// Pass two files so the test validates the complete response round-trip
|
||||
// (the mock returns two ChatAttachment objects).
|
||||
const file1 = new File(["content1"], "report.pdf", { type: "application/pdf" });
|
||||
const file2 = new File(["content2"], "data.csv", { type: "text/csv" });
|
||||
const result = await uploadChatFiles(wsId, [file1, file2]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe("report.pdf");
|
||||
expect(result[1].name).toBe("data.csv");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toContain(`/workspaces/${wsId}/chat/uploads`);
|
||||
// FormData stores files in order; each appended field is independent.
|
||||
const formFile = (opts.body as FormData).get("files") as File;
|
||||
expect(formFile.name).toBe("report.pdf");
|
||||
expect(formFile.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("throws Error with status text on non-ok response", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response("Internal Server Error", { status: 500 })
|
||||
);
|
||||
|
||||
const file = new File(["content"], "fail.pdf", { type: "application/pdf" });
|
||||
await expect(uploadChatFiles(wsId, [file])).rejects.toThrow("upload failed: 500 Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── downloadChatFile ────────────────────────────────────────────────────────
|
||||
|
||||
describe("downloadChatFile", () => {
|
||||
const wsId = "test-ws-id";
|
||||
const makeAttachment = (uri: string): ChatAttachment => ({
|
||||
name: "report.pdf",
|
||||
uri,
|
||||
size: 1024,
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockReturnValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("opens external HTTPS URLs in a new tab (no fetch involved)", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await downloadChatFile(wsId, makeAttachment("https://cdn.example.com/file.pdf"));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledOnce();
|
||||
expect(openSpy).toHaveBeenCalledWith("https://cdn.example.com/file.pdf", "_blank", "noopener,noreferrer");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("fetches and triggers blob download for platform attachments", async () => {
|
||||
const blobResult = new Blob(["hello world"], { type: "application/pdf" });
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: () => Promise.resolve(blobResult),
|
||||
} as unknown as Response;
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(mockResponse);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
await downloadChatFile(wsId, makeAttachment("workspace:/workspace/report.pdf"));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![0]).toContain(`/workspaces/${wsId}/chat/download`);
|
||||
expect(openSpy).not.toHaveBeenCalled(); // blob path, not window.open
|
||||
|
||||
fetchMock.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws Error on non-ok download response", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
new Response("Not Found", { status: 404 })
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadChatFile(wsId, makeAttachment("workspace:/workspace/missing.pdf"))
|
||||
).rejects.toThrow("download failed: 404");
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,13 +26,16 @@ export function createMessage(
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): ChatMessage {
|
||||
return {
|
||||
const base = {
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (attachments && attachments.length > 0) {
|
||||
return Object.freeze({ ...base, attachments });
|
||||
}
|
||||
return Object.freeze(base);
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
|
||||
@@ -158,6 +158,151 @@ func TestNilIfEmpty_Contract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// parseUsageFromA2AResponse
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseUsageFromA2AResponse_EmptyAndMalformed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty", []byte{}},
|
||||
{"non-JSON", []byte("not json")},
|
||||
{"empty object", []byte("{}")},
|
||||
{"null result", []byte(`{"result": null}`)},
|
||||
{"string result", []byte(`{"result": "hello"}`)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in, out := parseUsageFromA2AResponse(tc.body)
|
||||
if in != 0 || out != 0 {
|
||||
t.Errorf("parseUsageFromA2AResponse = (%d, %d), want (0, 0)", in, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFromA2AResponse_ResultUsageShape(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"result": {
|
||||
"usage": {"input_tokens": 1500, "output_tokens": 320}
|
||||
}
|
||||
}`)
|
||||
in, out := parseUsageFromA2AResponse(body)
|
||||
if in != 1500 || out != 320 {
|
||||
t.Errorf("parseUsageFromA2AResponse = (%d, %d), want (1500, 320)", in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFromA2AResponse_TopLevelUsage(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"usage": {"input_tokens": 100, "output_tokens": 50}
|
||||
}`)
|
||||
in, out := parseUsageFromA2AResponse(body)
|
||||
if in != 100 || out != 50 {
|
||||
t.Errorf("parseUsageFromA2AResponse = (%d, %d), want (100, 50)", in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFromA2AResponse_BothPresentPrefersResult(t *testing.T) {
|
||||
// When both result.usage and top-level usage exist, result.usage wins.
|
||||
body := []byte(`{
|
||||
"result": {"usage": {"input_tokens": 500, "output_tokens": 200}},
|
||||
"usage": {"input_tokens": 50, "output_tokens": 20}
|
||||
}`)
|
||||
in, out := parseUsageFromA2AResponse(body)
|
||||
if in != 500 || out != 200 {
|
||||
t.Errorf("parseUsageFromA2AResponse = (%d, %d), want (500, 200) from result.usage", in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUsageFromA2AResponse_ZeroUsage(t *testing.T) {
|
||||
// Zero values are treated as absent (readUsageMap returns ok=false).
|
||||
body := []byte(`{"result": {"usage": {"input_tokens": 0, "output_tokens": 0}}}`)
|
||||
in, out := parseUsageFromA2AResponse(body)
|
||||
if in != 0 || out != 0 {
|
||||
t.Errorf("parseUsageFromA2AResponse = (%d, %d), want (0, 0)", in, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// readUsageMap
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestReadUsageMap_HappyPath(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`{"input_tokens": 100, "output_tokens": 50}`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if !ok {
|
||||
t.Fatal("readUsageMap returned ok=false, want true")
|
||||
}
|
||||
if in != 100 || out != 50 {
|
||||
t.Errorf("readUsageMap = (%d, %d, %v), want (100, 50, true)", in, out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUsageMap_MissingUsage(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"other": json.RawMessage(`{}`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if ok {
|
||||
t.Errorf("readUsageMap returned ok=true for missing usage, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUsageMap_ZeroValues(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`{"input_tokens": 0, "output_tokens": 0}`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if ok {
|
||||
t.Errorf("readUsageMap returned ok=true for zero usage, want false")
|
||||
}
|
||||
if in != 0 || out != 0 {
|
||||
t.Errorf("readUsageMap = (%d, %d, %v), want (0, 0, false)", in, out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUsageMap_OnlyInputTokens(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`{"input_tokens": 200, "output_tokens": 0}`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if !ok {
|
||||
t.Fatal("readUsageMap returned ok=false, want true")
|
||||
}
|
||||
if in != 200 || out != 0 {
|
||||
t.Errorf("readUsageMap = (%d, %d, %v), want (200, 0, true)", in, out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUsageMap_OnlyOutputTokens(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`{"input_tokens": 0, "output_tokens": 150}`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if !ok {
|
||||
t.Fatal("readUsageMap returned ok=false, want true")
|
||||
}
|
||||
if in != 0 || out != 150 {
|
||||
t.Errorf("readUsageMap = (%d, %d, %v), want (0, 150, true)", in, out, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadUsageMap_MalformedUsageJSON(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`not valid json`),
|
||||
}
|
||||
in, out, ok := readUsageMap(m)
|
||||
if ok {
|
||||
t.Errorf("readUsageMap returned ok=true for malformed usage JSON, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress unused import warning — setupTestDB references db.DB but this file
|
||||
// only tests pure functions, so db is only needed transitively through helpers.
|
||||
var _ = db.DB
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
)
|
||||
|
||||
// ==================== resolveDeliveryMode ====================
|
||||
// Covers workspace_dispatchers.go / registry.go:resolveDeliveryMode
|
||||
|
||||
func TestResolveDeliveryMode_PayloadModeWins(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, mode := range []string{models.DeliveryModePush, models.DeliveryModePoll} {
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-any-id", mode)
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode(payloadMode=%q) unexpected error: %v", mode, err)
|
||||
}
|
||||
if got != mode {
|
||||
t.Errorf("resolveDeliveryMode(payloadMode=%q) = %q, want %q", mode, got, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// DB must NOT have been queried when payloadMode is set.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExistingDeliveryMode(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Workspace row has existing delivery_mode = "poll"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-poll").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow("poll", "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-poll", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePoll {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q", got, models.DeliveryModePoll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExternalRuntime_DefaultsToPoll(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row exists but delivery_mode is NULL; runtime = "external"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-external").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow(nil, "external"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-external", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePoll {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (external runtime)", got, models.DeliveryModePoll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_SelfHosted_DefaultsToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row exists; delivery_mode is NULL; runtime = "langgraph"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-self-hosted").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow(nil, "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-self-hosted", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (self-hosted default)", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_NotFound_DefaultsToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row not found → sql.ErrNoRows → default push
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-nonexistent").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-nonexistent", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error on no-rows: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (not-found default)", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_DBError_Propagated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-error").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := h.resolveDeliveryMode(ctx, "ws-error", "")
|
||||
if err == nil {
|
||||
t.Errorf("resolveDeliveryMode() expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExistingDeliveryModeEmptyString(t *testing.T) {
|
||||
// When the DB returns an empty (non-NULL) string for delivery_mode,
|
||||
// it falls through to the runtime check (not the existing.Valid path).
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// delivery_mode is explicitly empty string (not NULL), runtime = "langgraph"
|
||||
// → falls through to runtime check → "push" for non-external
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-empty-mode").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow("", "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-empty-mode", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// ==================== IsValidDeliveryMode ====================
|
||||
|
||||
func TestIsValidDeliveryMode_Valid(t *testing.T) {
|
||||
for _, mode := range []string{DeliveryModePush, DeliveryModePoll} {
|
||||
if !IsValidDeliveryMode(mode) {
|
||||
t.Errorf("IsValidDeliveryMode(%q) = false, want true", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidDeliveryMode_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false}, // empty string is not valid — callers must resolve the default
|
||||
{"pushx", false}, // typo
|
||||
{"pollx", false}, // typo
|
||||
{"PUSH", false}, // case-sensitive
|
||||
{"PUSH ", false}, // trailing space
|
||||
{"push ", false}, // trailing space
|
||||
{"hybrid", false}, // non-existent mode
|
||||
{"poll ", false}, // trailing space
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsValidDeliveryMode(tc.val)
|
||||
if got != tc.want {
|
||||
t.Errorf("IsValidDeliveryMode(%q) = %v, want %v", tc.val, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WorkspaceStatus ====================
|
||||
|
||||
func TestWorkspaceStatus_String(t *testing.T) {
|
||||
statuses := []WorkspaceStatus{
|
||||
StatusProvisioning,
|
||||
StatusOnline,
|
||||
StatusOffline,
|
||||
StatusDegraded,
|
||||
StatusFailed,
|
||||
StatusRemoved,
|
||||
StatusPaused,
|
||||
StatusHibernated,
|
||||
StatusHibernating,
|
||||
StatusAwaitingAgent,
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if got := s.String(); got != string(s) {
|
||||
t.Errorf("WorkspaceStatus(%q).String() = %q, want %q", s, got, string(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_Length(t *testing.T) {
|
||||
// The const block has 10 statuses; AllWorkspaceStatuses must match.
|
||||
if got := len(AllWorkspaceStatuses); got != 10 {
|
||||
t.Errorf("len(AllWorkspaceStatuses) = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_ContainsAllNamed(t *testing.T) {
|
||||
// Verify every named const appears in AllWorkspaceStatuses exactly once.
|
||||
named := []WorkspaceStatus{
|
||||
StatusProvisioning,
|
||||
StatusOnline,
|
||||
StatusOffline,
|
||||
StatusDegraded,
|
||||
StatusFailed,
|
||||
StatusRemoved,
|
||||
StatusPaused,
|
||||
StatusHibernated,
|
||||
StatusHibernating,
|
||||
StatusAwaitingAgent,
|
||||
}
|
||||
set := make(map[WorkspaceStatus]bool, len(AllWorkspaceStatuses))
|
||||
for _, s := range AllWorkspaceStatuses {
|
||||
set[s] = true
|
||||
}
|
||||
for _, s := range named {
|
||||
if !set[s] {
|
||||
t.Errorf("named status %q missing from AllWorkspaceStatuses", s)
|
||||
}
|
||||
}
|
||||
if len(set) != len(named) {
|
||||
t.Errorf("AllWorkspaceStatuses has %d unique entries, want %d", len(set), len(named))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_NoEmpty(t *testing.T) {
|
||||
for _, s := range AllWorkspaceStatuses {
|
||||
if s == "" {
|
||||
t.Errorf("AllWorkspaceStatuses contains empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,7 +620,9 @@ def sanitize_agent_error(
|
||||
# a malicious or buggy peer injecting a huge error body, and
|
||||
# scrubs any API keys / bearer tokens that snuck into the message.
|
||||
detail = _sanitize_for_external(stderr[:_MAX_STDERR_PREVIEW])
|
||||
return f"Agent error ({tag}): {detail}"
|
||||
if category:
|
||||
return f"Agent error ({tag}): {detail}"
|
||||
return f"Agent error: {detail}"
|
||||
return f"Agent error ({tag}) — see workspace logs for details."
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user