Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 719c56d132 | |||
| c7350da23c | |||
| a2117ec8ac | |||
| a809201bad | |||
| ab966c56ba | |||
| c8e312a195 | |||
| e1bf973d91 | |||
| 62b150308c | |||
| d2041df571 | |||
| e86f3bbda6 | |||
| 73eb3c7a85 | |||
| 39fc5d0f4e | |||
| 7825919439 | |||
| 9baca38f5e | |||
| 28dd21a78b | |||
| 33bffd9293 | |||
| 6b4bcb3b94 | |||
| e912df5438 | |||
| b417688588 | |||
| ef87b2e3e8 | |||
| 6041e36cf1 | |||
| 7ebaa3a686 | |||
| f5bc58f472 | |||
| 8aee937104 | |||
| 04b96d9cda |
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,216 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
|
||||
* Tests for the main FilesTab / PlatformOwnedFilesTab component.
|
||||
*
|
||||
* NotAvailablePanel: pure presentational component — renders a "feature not
|
||||
* available" placeholder for external-runtime workspaces.
|
||||
* FilesToolbar: pure props-driven component — directory selector, file count,
|
||||
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
|
||||
* Covers: NotAvailablePanel (external runtime), loading/empty/error states,
|
||||
* FilesToolbar actions, and the /configs-only upload guard.
|
||||
*
|
||||
* No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors.
|
||||
* No @testing-library/jest-dom — use textContent / className / getAttribute.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
import { FilesTab } from "../../FilesTab.tsx";
|
||||
import type { FileEntry } from "../../FilesTab/tree";
|
||||
|
||||
// ─── afterEach ─────────────────────────────────────────────────────────────────
|
||||
// ─── Mock ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
|
||||
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
it("renders heading 'Files not available'", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("Files not available");
|
||||
});
|
||||
const emptyFileList: FileEntry[] = [];
|
||||
|
||||
it("renders the runtime name in monospace", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("external");
|
||||
const spans = container.querySelectorAll("span");
|
||||
const monoSpans = Array.from(spans).filter(
|
||||
(s) => s.className && s.className.includes("font-mono"),
|
||||
);
|
||||
expect(monoSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */
|
||||
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) {
|
||||
return render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
{...extraProps}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders a Chat tab hint in description", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
|
||||
expect(container.textContent).toContain("Chat tab");
|
||||
});
|
||||
// ─── NotAvailablePanel ──────────────────────────────────────────────────────
|
||||
|
||||
it("SVG icon has aria-hidden=true", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders without crashing for any runtime string", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
|
||||
expect(container.textContent).toContain("unknown-runtime");
|
||||
});
|
||||
|
||||
it("applies the correct layout classes to root div", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain("flex");
|
||||
expect(root.className).toContain("flex-col");
|
||||
expect(root.className).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
const noop = vi.fn();
|
||||
|
||||
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={noop}
|
||||
fileCount={0}
|
||||
onNewFile={noop}
|
||||
onUpload={noop}
|
||||
onDownloadAll={noop}
|
||||
onClearAll={noop}
|
||||
onRefresh={noop}
|
||||
{...props}
|
||||
describe("FilesTab — NotAvailablePanel", () => {
|
||||
it("renders NotAvailablePanel when runtime is external", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the directory selector with correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("aria-label")).toBe("File root directory");
|
||||
expect(screen.getByText(/Files not available/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory selector has all four options", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain("/configs");
|
||||
expect(values).toContain("/home");
|
||||
expect(values).toContain("/workspace");
|
||||
expect(values).toContain("/plugins");
|
||||
});
|
||||
|
||||
it("calls setRoot when directory changes", () => {
|
||||
const setRoot = vi.fn();
|
||||
const { container } = renderToolbar({ setRoot });
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "/home";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(setRoot).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("displays the file count", () => {
|
||||
const { container } = renderToolbar({ fileCount: 42 });
|
||||
expect(container.textContent).toContain("42 files");
|
||||
});
|
||||
|
||||
it("shows New + Upload + Clear buttons for /configs", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
it("renders the runtime name in NotAvailablePanel", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(texts).toContain("+ New");
|
||||
expect(texts).toContain("Upload");
|
||||
expect(texts).toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(texts).toContain("↻");
|
||||
expect(screen.getByText(/external/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /workspace", () => {
|
||||
const { container } = renderToolbar({ root: "/workspace" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
it("does NOT call api.get when runtime is external", async () => {
|
||||
render(
|
||||
<FilesTab
|
||||
workspaceId="ws-1"
|
||||
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /home", () => {
|
||||
const { container } = renderToolbar({ root: "/home" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /plugins", () => {
|
||||
const { container } = renderToolbar({ root: "/plugins" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("New button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const newBtn = container.querySelector('button[aria-label="Create new file"]');
|
||||
expect(newBtn?.textContent?.trim()).toBe("+ New");
|
||||
});
|
||||
|
||||
it("Export button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
|
||||
expect(exportBtn?.textContent?.trim()).toBe("Export");
|
||||
});
|
||||
|
||||
it("Clear button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
|
||||
expect(clearBtn?.textContent?.trim()).toBe("Clear");
|
||||
});
|
||||
|
||||
it("Refresh button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
|
||||
expect(refreshBtn?.textContent?.trim()).toBe("↻");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onNewFile });
|
||||
container.querySelector('button[aria-label="Create new file"]')!.click();
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
const { container } = renderToolbar({ onDownloadAll });
|
||||
container.querySelector('button[aria-label="Download all files"]')!.click();
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onClearAll });
|
||||
container.querySelector('button[aria-label="Delete all files"]')!.click();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const { container } = renderToolbar({ onRefresh });
|
||||
container.querySelector('button[aria-label="Refresh file list"]')!.click();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(_mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Loading / Empty / Error states ────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — states", () => {
|
||||
it("shows loading text while fetching files", () => {
|
||||
_mockGet.mockImplementation(
|
||||
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
|
||||
);
|
||||
renderPlatformTab();
|
||||
expect(screen.getByText("Loading files...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'No config files yet' when root is /configs and no files", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No config files yet/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches from the correct endpoint", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows file count from toolbar when files exist", async () => {
|
||||
_mockGet.mockResolvedValue([
|
||||
{ path: "configs/a.yaml", size: 10, dir: false },
|
||||
{ path: "configs/b.yaml", size: 20, dir: false },
|
||||
]);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2 files")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — FilesToolbar", () => {
|
||||
it("shows Refresh button", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Refresh file list")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows root directory selector", async () => {
|
||||
_mockGet.mockResolvedValueOnce(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Refresh button triggers a reload", async () => {
|
||||
// Use persistent mock — loadFiles fires on mount AND on Refresh click.
|
||||
_mockGet.mockResolvedValue(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => screen.getByLabelText("Refresh file list"));
|
||||
const before = _mockGet.mock.calls.length;
|
||||
fireEvent.click(screen.getByLabelText("Refresh file list"));
|
||||
await waitFor(() => {
|
||||
expect(_mockGet.mock.calls.length).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Upload guard ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesTab — upload guard", () => {
|
||||
it("no error alert on dragover when root is /configs (default)", async () => {
|
||||
_mockGet.mockResolvedValue(emptyFileList);
|
||||
renderPlatformTab();
|
||||
await waitFor(() => screen.getByText(/No config files yet/i));
|
||||
|
||||
// No alert should be present
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for tree.ts — buildTree and getIcon pure functions.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileEntry } from "../tree";
|
||||
import { buildTree, getIcon } from "../tree";
|
||||
|
||||
// ─── getIcon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getIcon", () => {
|
||||
it("returns folder emoji for directories", () => {
|
||||
expect(getIcon("/configs", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .md", () => {
|
||||
expect(getIcon("readme.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .yaml", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .yml", () => {
|
||||
expect(getIcon("config.yml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .py", () => {
|
||||
expect(getIcon("script.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .ts", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .tsx", () => {
|
||||
expect(getIcon("App.tsx", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .js", () => {
|
||||
expect(getIcon("index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .json", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .html", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .css", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns correct emoji for .sh", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
it("returns default file emoji for unknown extensions", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Rakefile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("extension matching is case-insensitive", () => {
|
||||
expect(getIcon("readme.MD", false)).toBe("📄");
|
||||
expect(getIcon("script.PY", false)).toBe("🐍");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildTree ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildTree", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(buildTree([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a single file at root", () => {
|
||||
const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]).toMatchObject({
|
||||
name: "config.yaml",
|
||||
path: "config.yaml",
|
||||
isDir: false,
|
||||
children: [],
|
||||
size: 128,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a single directory at root", () => {
|
||||
const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]).toMatchObject({
|
||||
name: "skills",
|
||||
path: "skills",
|
||||
isDir: true,
|
||||
children: [],
|
||||
size: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts dirs before files at the same level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "b.txt", size: 10, dir: false },
|
||||
{ path: "a.txt", size: 10, dir: false },
|
||||
{ path: "z-dir", size: 0, dir: true },
|
||||
{ path: "a-dir", size: 0, dir: true },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(4);
|
||||
// Dirs first: z-dir, a-dir alphabetically → a before z
|
||||
expect(tree[0].name).toBe("a-dir");
|
||||
expect(tree[1].name).toBe("z-dir");
|
||||
// Then files alphabetically
|
||||
expect(tree[2].name).toBe("a.txt");
|
||||
expect(tree[3].name).toBe("b.txt");
|
||||
});
|
||||
|
||||
it("alphabetically sorts files within the same level", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "z.yaml", size: 10, dir: false },
|
||||
{ path: "a.yaml", size: 10, dir: false },
|
||||
{ path: "m.yaml", size: 10, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]);
|
||||
});
|
||||
|
||||
it("nests a file under its parent directory", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "skills", size: 0, dir: true },
|
||||
{ path: "skills/readme.md", size: 64, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("skills");
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0]).toMatchObject({
|
||||
name: "readme.md",
|
||||
path: "skills/readme.md",
|
||||
isDir: false,
|
||||
size: 64,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates intermediate directories automatically", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b/c/deep.txt", size: 32, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
// Root has one child: "a"
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("a");
|
||||
expect(tree[0].isDir).toBe(true);
|
||||
// "a" has one child: "b"
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe("b");
|
||||
// "b" has one child: "c"
|
||||
expect(tree[0].children[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].children[0].name).toBe("c");
|
||||
// "c" has the file
|
||||
expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt");
|
||||
expect(tree[0].children[0].children[0].children[0].size).toBe(32);
|
||||
});
|
||||
|
||||
it("adds multiple files to the same directory", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "configs", size: 0, dir: true },
|
||||
{ path: "configs/a.yaml", size: 10, dir: false },
|
||||
{ path: "configs/b.yaml", size: 20, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a directory already created as intermediate", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b.txt", size: 5, dir: false },
|
||||
{ path: "a", size: 0, dir: true },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
// "a" should appear only once
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0].name).toBe("a");
|
||||
// The dir "a" should still contain "b.txt"
|
||||
expect(tree[0].children).toHaveLength(1);
|
||||
expect(tree[0].children[0].name).toBe("b.txt");
|
||||
});
|
||||
|
||||
it("intermediate dirs have size 0", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a/b/c/file.txt", size: 1, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree[0].size).toBe(0);
|
||||
expect(tree[0].children[0].size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles deeply nested mixed dirs and files", () => {
|
||||
const files: FileEntry[] = [
|
||||
{ path: "a", size: 0, dir: true },
|
||||
{ path: "a/b", size: 0, dir: true },
|
||||
{ path: "a/b/c", size: 0, dir: true },
|
||||
{ path: "a/b/c/d.txt", size: 1, dir: false },
|
||||
{ path: "a/b/e.txt", size: 2, dir: false },
|
||||
{ path: "a/f.txt", size: 3, dir: false },
|
||||
];
|
||||
const tree = buildTree(files);
|
||||
expect(tree).toHaveLength(1); // root: "a"
|
||||
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]);
|
||||
expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort())
|
||||
.toEqual(["c", "e.txt"]);
|
||||
});
|
||||
});
|
||||
@@ -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,205 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EventsTab component.
|
||||
*
|
||||
* Covers: formatTime pure function, EVENT_COLORS constant,
|
||||
* loading/error/empty states, event list rendering, expand/collapse,
|
||||
* refresh button, auto-refresh setup.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventsTab } from "../EventsTab";
|
||||
|
||||
// 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();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── formatTime tests (via rendered output) ────────────────────────────────────
|
||||
|
||||
describe("EventsTab — formatTime", () => {
|
||||
it("shows 'ago' for events less than a minute old", async () => {
|
||||
const now = new Date();
|
||||
const recent = new Date(now.getTime() - 30_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: recent },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ago/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'm ago' for events less than an hour old", async () => {
|
||||
const now = new Date();
|
||||
const minsAgo = new Date(now.getTime() - 5 * 60_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: minsAgo },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/m ago/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'h ago' for events less than a day old", async () => {
|
||||
const now = new Date();
|
||||
const hoursAgo = new Date(now.getTime() - 3 * 3_600_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: hoursAgo },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/h ago/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── EVENT_COLORS rendering ───────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — EVENT_COLORS", () => {
|
||||
it("renders all known event types without crashing", async () => {
|
||||
const eventTypes = [
|
||||
"WORKSPACE_ONLINE",
|
||||
"WORKSPACE_OFFLINE",
|
||||
"WORKSPACE_DEGRADED",
|
||||
"WORKSPACE_PROVISIONING",
|
||||
"WORKSPACE_REMOVED",
|
||||
"WORKSPACE_PROVISION_FAILED",
|
||||
"AGENT_CARD_UPDATED",
|
||||
];
|
||||
_mockGet.mockResolvedValueOnce(
|
||||
eventTypes.map((event_type, i) => ({
|
||||
id: `e-${i}`, event_type, workspace_id: null, payload: {}, created_at: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
for (const et of eventTypes) {
|
||||
expect(screen.getByText(et)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unknown event types without crashing", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-unk", event_type: "UNKNOWN_EVENT_XYZ", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("UNKNOWN_EVENT_XYZ")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — states", () => {
|
||||
it("shows loading text initially", () => {
|
||||
_mockGet.mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Loading events...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty message when no events returned", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No events yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error alert when fetch fails", async () => {
|
||||
_mockGet.mockRejectedValueOnce(new Error("server error"));
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Event list ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — event list", () => {
|
||||
it("renders all returned events", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { foo: 1 }, created_at: new Date().toISOString() },
|
||||
{ id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: { bar: 2 }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/WORKSPACE_/).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows event count in header", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
{ id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
{ id: "e3", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3 events")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands payload panel on click", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-expand", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { key: "value" }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("WORKSPACE_ONLINE"));
|
||||
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/"key":\s*"value"/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses expanded panel on second click", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-collapse", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: { x: 1 }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("WORKSPACE_DEGRADED"));
|
||||
|
||||
fireEvent.click(screen.getByText("WORKSPACE_DEGRADED"));
|
||||
await waitFor(() => expect(screen.getByText(/"x":\s*1/)).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByText("WORKSPACE_DEGRADED"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/"x":\s*1/)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Refresh button ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — refresh", () => {
|
||||
it("has a Refresh button", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {});
|
||||
expect(screen.getByRole("button", { name: /refresh/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a reload", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /refresh/i }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
|
||||
// Called at least twice: initial load + refresh click
|
||||
expect(_mockGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ScheduleTab component.
|
||||
*
|
||||
* Covers: cronToHuman pure function, relativeTime pure function,
|
||||
* loading/error/empty states, schedule list rendering.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ScheduleTab } from "../ScheduleTab";
|
||||
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
|
||||
// ─── cronToHuman tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — cronToHuman", () => {
|
||||
it('returns "Every minute" for "* * * * *"', async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "* * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every minute")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Every X minutes' for '*/X * * * *'", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "*/15 * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every 15 minutes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Every X hours' for '0 */X * * *'", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 */3 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every 3 hours")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Daily at HH:MM UTC' for daily schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "30 14 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Daily at 14:30 UTC")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Weekdays at HH:MM UTC' for weekday schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * 1-5",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Weekdays at 09:00 UTC")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to raw expression for unrecognised patterns", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 0 1 * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("0 0 1 * *")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to raw expression for malformed input", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "not a cron",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("not a cron")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── relativeTime tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — relativeTime", () => {
|
||||
it('shows "Last: never" when last_run_at is null', async () => {
|
||||
// Use mockResolvedValue (persistent) instead of mockResolvedValueOnce because
|
||||
// ScheduleTab's 10 s auto-refresh interval fires and calls fetchSchedules
|
||||
// a second time, consuming a one-time mock and clearing the DOM.
|
||||
_mockGet.mockResolvedValue([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
// Use "Last: never" to match the exact label text in ScheduleTab.tsx:349.
|
||||
// findByText("never") would throw on the multiple-match ambiguity since
|
||||
// "never" also appears in the "Next: never" span.
|
||||
expect(await screen.findByText("Last: never")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — states", () => {
|
||||
it("shows empty message when no schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("No schedules yet")).toBeTruthy();
|
||||
});
|
||||
// Note: ScheduleTab silently swallows fetch errors (no error state for
|
||||
// the initial load). Error state only exists for form-level actions
|
||||
// (save/delete/toggle) which require api.post/del/patch mocking.
|
||||
});
|
||||
|
||||
// ─── Schedule list ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — list", () => {
|
||||
it("renders schedule name", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Nightly Run", cron_expr: "0 2 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Nightly Run")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Schedule A", cron_expr: "0 9 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
{ id: "s2", workspace_id: "ws-1", name: "Schedule B", cron_expr: "*/15 * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: false, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Schedule A")).toBeTruthy();
|
||||
expect(await screen.findByText("Schedule B")).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
|
||||
|
||||
@@ -80,6 +80,54 @@ func TestExtractIdempotencyKey_emptyOnMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// extractExpiresInSeconds
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractExpiresInSeconds_valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
|
||||
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
|
||||
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
|
||||
{"nested message — not affected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
|
||||
{"missing expires_in_seconds", `{"params":{"message":{"role":"user"}}}`, 0},
|
||||
{"no params at all", `{"method":"message/send"}`, 0},
|
||||
{"malformed JSON", `not json`, 0},
|
||||
{"empty body", ``, 0},
|
||||
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
|
||||
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractExpiresInSeconds([]byte(tc.body)); got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds(%q) = %d, want %d", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDelegationIDFromBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -116,6 +116,9 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListPendingApprovals scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
@@ -155,6 +158,9 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListApprovals scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ func (h *BundleHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reject null JSON (which binds to a zero-value Bundle{}) and empty schema.
|
||||
// Without this guard a POST of `null` or `{}` would INSERT a workspace row
|
||||
// with name="" and tier=0 into the DB before bundle.Import() fails.
|
||||
if b.Schema == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result := bundle.Import(ctx, &b, nil, h.broadcaster, h.provisioner, h.platformURL)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Import — JSON binding error cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleImport_InvalidJSON(t *testing.T) {
|
||||
h := NewBundleHandler(nil, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"not JSON", `not json at all`},
|
||||
{"truncated JSON", `{"name": "test",`},
|
||||
{"null", `null`},
|
||||
{"array", `[]`},
|
||||
{"number", `42`},
|
||||
{"boolean", `true`},
|
||||
{"string", `"just a string"`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(tc.body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Import(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid JSON %q: expected status %d, got %d", tc.body, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Import — valid JSON routes to bundle.Import and returns 201
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Import does: INSERT workspaces (creates record), UPDATE runtime (after
|
||||
// parsing config.yaml), plus a RecordAndBroadcast (not a DB call). SubWorkspaces
|
||||
// recursion is a no-op for this test bundle. No workspace_schedules or
|
||||
// workspace_secrets INSERT in the current importer.
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE workspaces SET runtime").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := `{"name": "test-workspace", "schema": "1.0", "tier": 3}`
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Import(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("valid JSON: expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Export — workspace not found (ErrNoRows → 404)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleExport_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Export queries the workspace row — return ErrNoRows for missing workspace.
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
|
||||
WithArgs("ws-nonexistent").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-nonexistent"}}
|
||||
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-nonexistent", nil)
|
||||
|
||||
h.Export(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BundleHandler Export — query error (DB error → 404, per bundle.Export semantics)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBundleExport_QueryError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// Simulate a non-ErrNoRows DB error.
|
||||
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
|
||||
WithArgs("ws-error").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-error"}}
|
||||
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-error", nil)
|
||||
|
||||
h.Export(c)
|
||||
|
||||
// bundle.Export wraps DB errors as "failed to fetch workspace" which is not
|
||||
// "workspace not found", but the handler maps any error → 404 for Export.
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d for DB error, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -645,6 +645,9 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
}
|
||||
delegations = append(delegations, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListDelegations scan error: %v", err)
|
||||
}
|
||||
|
||||
if delegations == nil {
|
||||
delegations = []map[string]interface{}{}
|
||||
|
||||
@@ -352,6 +352,9 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
|
||||
|
||||
result = append(result, peer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("queryPeerMaps scan error: %v", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ func (h *EventsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListEvents scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -87,5 +90,8 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListEventsByWorkspace scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -248,6 +248,9 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListInstructions scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
@@ -258,6 +261,7 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
func scanInstructions(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
}) []Instruction {
|
||||
var instructions []Instruction
|
||||
for rows.Next() {
|
||||
@@ -269,6 +273,9 @@ func scanInstructions(rows interface {
|
||||
}
|
||||
instructions = append(instructions, inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Instructions scan loop error: %v", err)
|
||||
}
|
||||
if instructions == nil {
|
||||
instructions = []Instruction{}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ func (h *SecretsHandler) List(c *gin.Context) {
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListSecrets scan error: %v", err)
|
||||
}
|
||||
|
||||
// 2. Global secrets not overridden at workspace level
|
||||
globalRows, err := db.DB.QueryContext(ctx,
|
||||
@@ -324,6 +327,9 @@ func (h *SecretsHandler) ListGlobal(c *gin.Context) {
|
||||
"scope": "global",
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListGlobalSecrets scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, secrets)
|
||||
}
|
||||
|
||||
@@ -400,6 +406,9 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("notifyGlobalSecretChange scan error: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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