Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions canvas/src/components/tabs/__tests__/ConfigTab.hermes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
//
// Regression tests for ConfigTab hermes-workspace UX (#1894 + #1900).
//
// All four bugs this suite pins hit the same workspace on 2026-04-23:
// a hermes-runtime workspace whose Config tab showed "LangGraph
// (default)" in the runtime dropdown, an empty Model field, and a
// scary red "No config.yaml found" banner. Clicking Save would
// silently PATCH runtime back to LangGraph, breaking the workspace.
//
// Each test pins one invariant. If any fails, the bug is back.

import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import React from "react";

afterEach(cleanup);

// ── API mock ──────────────────────────────────────────────────────────
// ConfigTab calls three endpoints on load:
// 1. GET /workspaces/:id — workspace metadata (runtime)
// 2. GET /workspaces/:id/model — model
// 3. GET /workspaces/:id/files/config.yaml — template-managed config (may 404)
// And POST /templates for the runtime dropdown options.
//
// Each test wires the mock to return the shape that matches the scenario
// it's pinning. Unhandled URLs default to rejecting so the test fails loud
// if ConfigTab queries something unexpected.
const apiGet = vi.fn();
const apiPatch = vi.fn();
const apiPut = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string) => apiGet(path),
patch: (path: string, body: unknown) => apiPatch(path, body),
put: (path: string, body: unknown) => apiPut(path, body),
post: vi.fn(),
del: vi.fn(),
},
}));

// Zustand store used by Save → restart. Not exercised in these tests.
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }),
{ getState: () => ({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }) },
),
}));

// AgentCardSection fetches its own data — stub to avoid noise.
vi.mock("../AgentCardSection", () => ({
AgentCardSection: () => <div data-testid="agent-card-stub" />,
}));

import { ConfigTab } from "../ConfigTab";

// helper — wire the api.get mock for one scenario
function wireApi(opts: {
workspaceRuntime?: string;
workspaceModel?: string;
configYamlContent?: string | null; // null = 404
templates?: Array<{ id: string; name?: string; runtime?: string; models?: unknown[] }>;
}) {
apiGet.mockImplementation((path: string) => {
if (path === `/workspaces/ws-test`) {
return Promise.resolve({ runtime: opts.workspaceRuntime ?? "" });
}
if (path === `/workspaces/ws-test/model`) {
return Promise.resolve({ model: opts.workspaceModel ?? "" });
}
if (path === `/workspaces/ws-test/files/config.yaml`) {
if (opts.configYamlContent === null) {
return Promise.reject(new Error("not found"));
}
return Promise.resolve({ content: opts.configYamlContent ?? "" });
}
if (path === "/templates") {
return Promise.resolve(opts.templates ?? []);
}
return Promise.reject(new Error(`unmocked api.get: ${path}`));
});
}

beforeEach(() => {
apiGet.mockReset();
apiPatch.mockReset();
apiPut.mockReset();
});

describe("ConfigTab — hermes workspace", () => {
it("loads runtime from workspace metadata when config.yaml is missing (#1894 bug 1)", async () => {
// This is the hermes case: no platform config.yaml, so the form must
// fall back to GET /workspaces/:id's runtime field. Before the fix, the
// runtime dropdown showed "LangGraph (default)" because the fallback
// didn't exist.
wireApi({
workspaceRuntime: "hermes",
workspaceModel: "openai/gpt-4o",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});

render(<ConfigTab workspaceId="ws-test" />);

// Wait for loads
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
expect((select as HTMLSelectElement).value).toBe("hermes");
});

it("does NOT show 'No config.yaml found' error for hermes (#1894 bug 3)", async () => {
// Hermes manages its own config at ~/.hermes/config.yaml on the
// workspace host — the platform config.yaml NOT existing is expected,
// not an error. Showing a red error banner misleads the user.
wireApi({
workspaceRuntime: "hermes",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});

render(<ConfigTab workspaceId="ws-test" />);

await waitFor(() => {
const node = screen.queryByText(/No config\.yaml found/i);
// Assert the red error is absent; a gray info banner with the same
// phrase would also fail this (which is what we want — we don't
// want any "no config.yaml" phrasing on hermes at all).
expect(node).toBeNull();
});
});

it("shows hermes-specific info banner pointing to Terminal tab (#1894)", async () => {
wireApi({
workspaceRuntime: "hermes",
configYamlContent: null,
templates: [{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] }],
});

render(<ConfigTab workspaceId="ws-test" />);

await waitFor(() => {
expect(screen.getByText(/Hermes manages its own config/i)).toBeTruthy();
});
});

it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
// Regression guard the other way — the gray info banner is hermes-
// specific. A langgraph workspace with no config.yaml SHOULD still
// see the red error so the user knows to provide a template config.
wireApi({
workspaceRuntime: "",
configYamlContent: null,
templates: [],
});

render(<ConfigTab workspaceId="ws-test" />);

await waitFor(() => {
expect(screen.getByText(/No config\.yaml found/i)).toBeTruthy();
});
});
});

describe("ConfigTab — config.yaml on disk", () => {
it("config.yaml runtime/model wins when present, workspace metadata is fallback", async () => {
// If the workspace DB has runtime=langgraph but config.yaml declares
// runtime: crewai, the form should show crewai (config.yaml wins).
// Prevents silent runtime drift across reads.
wireApi({
workspaceRuntime: "langgraph", // DB
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
templates: [
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
],
});

render(<ConfigTab workspaceId="ws-test" />);

const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
expect((select as HTMLSelectElement).value).toBe("crewai");
});
});
38 changes: 38 additions & 0 deletions tests/e2e/test_staging_full_saas.sh
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,47 @@ print(parts[0].get('text', '') if parts else '')
if [ -z "$AGENT_TEXT" ]; then
fail "A2A returned no text. Raw: $A2A_RESP"
fi

# Specific error-class checks — each pattern caught a real P0 bug on
# 2026-04-23 that a generic "error|exception" check missed or misreported:
#
# "[hermes-agent error 401]" → gateway API_SERVER_KEY not propagated (hermes #12)
# "Invalid API key" → tenant auth chain (CP #238 race)
# "model_not_found" → hermes custom provider slug passthrough (#13)
# "Encrypted content is not supported" → hermes codex_responses API misroute (#14)
# "Unknown provider" → bridge misconfigured PROVIDER= (regression of #13 fix)
# "hermes-agent unreachable" → gateway process died
#
# Fail LOUD with the specific pattern so CI log + alert channel makes the
# regression unambiguous.
if echo "$AGENT_TEXT" | grep -qF "[hermes-agent error 401]"; then
fail "A2A — REGRESSION: hermes gateway auth broken (API_SERVER_KEY not in runtime env). See template-hermes#12. Raw: $AGENT_TEXT"
fi
if echo "$AGENT_TEXT" | grep -qF "hermes-agent unreachable"; then
fail "A2A — REGRESSION: hermes gateway process down. Check /var/log/hermes-gateway.log on the workspace EC2. Raw: $AGENT_TEXT"
fi
if echo "$AGENT_TEXT" | grep -qF "model_not_found"; then
fail "A2A — REGRESSION: model slug passed through with provider prefix. See template-hermes#13. Raw: $AGENT_TEXT"
fi
if echo "$AGENT_TEXT" | grep -qF "Encrypted content is not supported"; then
fail "A2A — REGRESSION: hermes custom provider hit /v1/responses instead of chat_completions. Config.yaml should declare api_mode: chat_completions. See template-hermes#14. Raw: $AGENT_TEXT"
fi
if echo "$AGENT_TEXT" | grep -qF "Unknown provider"; then
fail "A2A — REGRESSION: install.sh set PROVIDER to a value not in hermes's registry. Run 'hermes doctor' on the workspace to see valid values. Raw: $AGENT_TEXT"
fi
# Generic catch-all — falls through if none of the known regressions hit.
if echo "$AGENT_TEXT" | grep -qiE "error|exception"; then
fail "A2A returned an error-shaped response: $AGENT_TEXT"
fi

# Content assertion — the prompt asks the model to reply with exactly "PONG".
# Real models produce "PONG" (possibly with minor wrapping); a broken pipeline
# that echoes the prompt back or returns truncated context won't. Normalize
# to uppercase before matching to tolerate "pong" / "Pong".
if ! echo "$AGENT_TEXT" | tr '[:lower:]' '[:upper:]' | grep -qF "PONG"; then
fail "A2A reply didn't contain expected PONG token. Real: $AGENT_TEXT"
fi

ok "A2A parent round-trip succeeded: \"${AGENT_TEXT:0:80}\""

# ─── 9. HMA + peers + activity (full mode) ─────────────────────────────
Expand Down
Loading
Loading