Compare commits

..

3 Commits

Author SHA1 Message Date
fullstack-engineer bb61352c86 feat(canvas): add theme-cookie + theme-provider test coverage
CI / Detect changes (pull_request) Failing after 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / all-required (pull_request) Failing after 3s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 6s
security-review / approved (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 13s
sop-tier-check / tier-check (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
Harness Replays / Harness Replays (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 49s
CI / Canvas (Next.js) (pull_request) Successful in 4m37s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m28s
E2E Chat / E2E Chat (pull_request) Failing after 9m29s
CI / Platform (Go) (pull_request) Successful in 11m20s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 12s
sop-tier-check / tier-check (pull_request_target) Successful in 12s
Add 16 tests for theme-provider.tsx (previously uncovered):
- applyResolvedTheme: sets data-theme on documentElement, idempotent,
  guards against undefined document (SSR safety)
- ThemeProvider: initialTheme, resolvedTheme derivation, setTheme updates
  both theme+resolvedTheme, writes mol_theme cookie, calls
  applyResolvedTheme on mount and on change
- useTheme: noop fallback when no provider in tree, setTheme no-op

Extend theme-cookie.test.ts with 9 tests:
- THEME_COOKIE constant: non-empty string, equals "mol_theme", stable
- themeBootScript: non-empty, contains cookie name, system/light/dark,
  dataset.theme assignment, matchMedia call, IIFE wrapper, pure/stable

Also export applyResolvedTheme from theme-provider.tsx so it is
importable in tests (previously unexported internal function).

No handler or component logic changed. 25 new tests pass.

Closes #1466

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:21:15 +00:00
fullstack-engineer e84bf3a4c6 test(handlers+canvas): BroadcastHandler sqlmock suite + extractAgentText tests (#1475)
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 4s
Harness Replays / detect-changes (push) Successful in 5s
E2E Chat / detect-changes (push) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
Harness Replays / Harness Replays (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 40s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m18s
CI / Platform (Go) (push) Successful in 3m9s
CI / Canvas (Next.js) (push) Successful in 4m37s
CI / Canvas Deploy Reminder (push) Successful in 1s
E2E Chat / E2E Chat (push) Failing after 5m1s
CI / Python Lint & Test (push) Successful in 6m51s
CI / all-required (push) Successful in 6m51s
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-18 07:30:33 +00:00
core-qa 376f78278d fix(ci): increase Go test timeouts for cold runner performance (#1175)
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Chat / E2E Chat (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
CI / Detect changes (push) Failing after 1s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / all-required (push) Failing after 2s
CI / Platform (Go) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Python Lint & Test (push) Has been cancelled
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Has been cancelled
Runtime PR-Built Compatibility / detect-changes (push) Has been cancelled
Handlers Postgres Integration / detect-changes (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
E2E Chat / detect-changes (push) Has been cancelled
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 49s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m5s
Co-authored-by: Molecule AI Core-QA <core-qa@agents.moleculesai.app>
Co-committed-by: Molecule AI Core-QA <core-qa@agents.moleculesai.app>
2026-05-18 07:30:24 +00:00
8 changed files with 590 additions and 522 deletions
+12 -10
View File
@@ -145,10 +145,10 @@ jobs:
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
# this cap catches any step that leaks past that. Set well above 10m so
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
# this cap catches any step that leaks past that. Set well above 30m so
# the per-step timeout is the active constraint.
timeout-minutes: 15
timeout-minutes: 35
defaults:
run:
working-directory: workspace-server
@@ -176,12 +176,14 @@ jobs:
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
name: Diagnostic — per-package verbose 60s
name: Diagnostic — per-package verbose (300s timeout)
run: |
set +e
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
# 300s allows handlers + pendinguploads packages to complete on cold
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
@@ -194,10 +196,10 @@ jobs:
- if: always()
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
# full ./... suite with race detection + coverage. A 30m per-step timeout
# lets the suite complete on cold cache (~13-25m) while failing cleanly
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
- if: always()
name: Per-file coverage report
@@ -248,6 +248,88 @@ describe("extractResponseText", () => {
});
});
describe("extractAgentText", () => {
it("extracts text from top-level parts", () => {
const task = {
parts: [{ kind: "text", text: "Agent said hello" }],
};
expect(extractAgentText(task)).toBe("Agent said hello");
});
it("extracts from artifacts[0].parts when top-level parts absent", () => {
const task = {
artifacts: [
{ parts: [{ kind: "text", text: "From artifact block" }] },
],
};
expect(extractAgentText(task)).toBe("From artifact block");
});
it("extracts from status.message.parts as fallback", () => {
const task = {
status: {
message: { parts: [{ kind: "text", text: "Status text" }] },
},
};
expect(extractAgentText(task)).toBe("Status text");
});
it("prefers top-level parts over artifacts", () => {
const task = {
parts: [{ kind: "text", text: "top-level wins" }],
artifacts: [
{ parts: [{ kind: "text", text: "artifact text" }] },
],
};
expect(extractAgentText(task)).toBe("top-level wins");
});
it("prefers top-level parts over status.message", () => {
const task = {
parts: [{ kind: "text", text: "parts wins" }],
status: {
message: { parts: [{ kind: "text", text: "status text" }] },
},
};
expect(extractAgentText(task)).toBe("parts wins");
});
it("returns string identity when task itself is a string", () => {
expect(extractAgentText("plain string task" as unknown as Record<string, unknown>)).toBe(
"plain string task",
);
});
it("returns fallback when task is an empty object", () => {
expect(extractAgentText({})).toBe("(Could not extract response text)");
});
it("returns fallback when task has no extractable text", () => {
expect(
extractAgentText({ status: "running", other: "fields" }),
).toBe("(Could not extract response text)");
});
it("tolerates malformed nested shapes without throwing", () => {
const task = {
parts: null,
artifacts: "not an array",
status: { message: 42 },
};
expect(extractAgentText(task)).toBe("(Could not extract response text)");
});
it("joins multiple text parts with newline", () => {
const task = {
parts: [
{ kind: "text", text: "Line one" },
{ kind: "text", text: "Line two" },
],
};
expect(extractAgentText(task)).toBe("Line one\nLine two");
});
});
describe("extractTextsFromParts", () => {
it("extracts text parts with kind=text", () => {
const parts = [
+65 -2
View File
@@ -1,9 +1,12 @@
// @vitest-environment jsdom
/**
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
* Tests for theme-cookie.ts:
* - THEME_COOKIE constant
* - readThemeCookie
* - themeBootScript
*/
import { describe, it, expect } from "vitest";
import { readThemeCookie } from "../theme-cookie";
import { readThemeCookie, THEME_COOKIE, themeBootScript } from "../theme-cookie";
describe("readThemeCookie", () => {
it('returns "light" when cookie value is "light"', () => {
@@ -45,3 +48,63 @@ describe("readThemeCookie", () => {
}
});
});
// ── THEME_COOKIE ────────────────────────────────────────────────────────────────
describe("THEME_COOKIE", () => {
it("is a non-empty string", () => {
expect(typeof THEME_COOKIE).toBe("string");
expect(THEME_COOKIE.length).toBeGreaterThan(0);
});
it("equals 'mol_theme'", () => {
expect(THEME_COOKIE).toBe("mol_theme");
});
it("is stable — constant is not reassigned", () => {
const first = THEME_COOKIE;
const second = THEME_COOKIE;
expect(first).toBe(second);
});
});
// ── themeBootScript ─────────────────────────────────────────────────────────────
describe("themeBootScript", () => {
it("is a non-empty string", () => {
expect(typeof themeBootScript).toBe("string");
expect(themeBootScript.length).toBeGreaterThan(0);
});
it("contains THEME_COOKIE value in the cookie-regex pattern", () => {
// The script reads document.cookie looking for mol_theme=...
expect(themeBootScript).toContain(THEME_COOKIE);
});
it("contains 'system', 'light', 'dark' in the match pattern", () => {
expect(themeBootScript).toContain("system");
expect(themeBootScript).toContain("light");
expect(themeBootScript).toContain("dark");
});
it("contains data-theme assignment on documentElement", () => {
// The script sets document.documentElement.dataset.theme = resolved
expect(themeBootScript).toContain("dataset.theme");
expect(themeBootScript).toContain("document.documentElement");
});
it("contains matchMedia call for OS preference fallback", () => {
expect(themeBootScript).toContain("matchMedia");
expect(themeBootScript).toContain("prefers-color-scheme");
});
it("wraps the entire body in an IIFE so it runs immediately", () => {
expect(themeBootScript).toMatch(/^\(\(\)=>/);
});
it("is pure — constant evaluated once, same value every time", () => {
const a = themeBootScript;
const b = themeBootScript;
expect(a).toBe(b);
});
});
@@ -0,0 +1,277 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for theme-provider.tsx:
* - applyResolvedTheme — pure DOM side-effect function
* - ThemeProvider — context, setTheme, resolvedTheme derivation
* - useTheme — hook + noop fallback
*
* Coverage gaps filled vs theme-cookie.test.ts (which tests only readThemeCookie):
* applyResolvedTheme, ThemeProvider initialTheme, resolvedTheme derivation
* from system preference, writeThemeCookie integration, useTheme noop fallback.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, screen, cleanup, act, waitFor } from "@testing-library/react";
import { applyResolvedTheme, ThemeProvider, useTheme } from "../theme-provider";
// ─── applyResolvedTheme ────────────────────────────────────────────────────────
describe("applyResolvedTheme", () => {
beforeEach(() => {
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
});
it('sets data-theme="light" on document.documentElement', () => {
applyResolvedTheme("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it('sets data-theme="dark" on document.documentElement', () => {
applyResolvedTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("is idempotent — calling twice with same value keeps the same attribute", () => {
applyResolvedTheme("dark");
applyResolvedTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("is a pure function for its DOM side-effect — no return value", () => {
expect(applyResolvedTheme("light")).toBeUndefined();
});
it("guards against undefined document (SSR safety)", () => {
// In Node.js / SSR context document is undefined; the function returns
// early without throwing. We simulate this by temporarily deleting document.
const saved = globalThis.document;
// @ts-expect-error — intentionally undefined for SSR test
globalThis.document = undefined;
expect(() => applyResolvedTheme("dark")).not.toThrow();
globalThis.document = saved;
});
});
// ─── ThemeProvider ─────────────────────────────────────────────────────────────
describe("ThemeProvider", () => {
beforeEach(() => {
// Stub matchMedia so ThemeProvider's system-preference useEffect works in jsdom.
// Default to light mode (matches=false) so resolvedTheme="light" when theme="system".
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false, // light preference by default
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
// Clear cookies set by writeThemeCookie.
if (typeof document !== "undefined") {
document.cookie = "mol_theme=; Max-Age=0";
}
});
function ThemeChild() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button
data-testid="set-light"
onClick={() => setTheme("light")}
>
light
</button>
<button
data-testid="set-dark"
onClick={() => setTheme("dark")}
>
dark
</button>
</div>
);
}
it("renders children", () => {
render(
<ThemeProvider initialTheme="light">
<span data-testid="child">Hello</span>
</ThemeProvider>,
);
expect(screen.getByTestId("child")).toBeTruthy();
});
it('initialTheme="light" sets theme=light', () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it('initialTheme="dark" sets theme=dark', () => {
render(
<ThemeProvider initialTheme="dark">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("dark");
});
it('initialTheme="system" falls back to light (matchMedia stub)', () => {
// matchMedia is not stubbed in jsdom by default; the provider calls it
// and reads the OS preference. Without a stub, jsdom returns
// { matches: false } → "light".
render(
<ThemeProvider initialTheme="system">
<ThemeChild />
</ThemeProvider>,
);
// Resolved is "light" because jsdom matchMedia stub returns false for dark.
expect(screen.getByTestId("resolved").textContent).toBe("light");
});
it("setTheme('dark') updates both theme and resolvedTheme", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("light");
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(screen.getByTestId("theme").textContent).toBe("dark");
// resolvedTheme tracks theme when not in system mode.
expect(screen.getByTestId("resolved").textContent).toBe("dark");
});
it("setTheme('light') updates both theme and resolvedTheme", async () => {
render(
<ThemeProvider initialTheme="dark">
<ThemeChild />
</ThemeProvider>,
);
await act(async () => {
screen.getByTestId("set-light").click();
});
expect(screen.getByTestId("theme").textContent).toBe("light");
expect(screen.getByTestId("resolved").textContent).toBe("light");
});
it("writes mol_theme cookie when setTheme is called", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(document.cookie).toContain("mol_theme=dark");
});
it("calls applyResolvedTheme on mount (data-theme set on <html>)", () => {
render(
<ThemeProvider initialTheme="dark">
<span data-testid="child">hi</span>
</ThemeProvider>,
);
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("calls applyResolvedTheme when resolvedTheme changes", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
// Start at light.
expect(document.documentElement.dataset.theme).toBe("light");
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(document.documentElement.dataset.theme).toBe("dark");
});
});
// ─── useTheme noop fallback ────────────────────────────────────────────────────
describe("useTheme without ThemeProvider", () => {
afterEach(() => {
cleanup();
});
it("useTheme returns noopTheme when no provider is in the tree", () => {
function ShowTheme() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<span data-testid="setTheme-type">{typeof setTheme}</span>
</div>
);
}
render(<ShowTheme />);
// noopTheme defaults: theme="system", resolvedTheme="light", setTheme no-op.
expect(screen.getByTestId("theme").textContent).toBe("system");
expect(screen.getByTestId("resolved").textContent).toBe("light");
expect(screen.getByTestId("setTheme-type").textContent).toBe("function");
});
it("setTheme is a no-op when no provider is present (no throw)", async () => {
let threw = false;
function ClickSetTheme() {
const { setTheme } = useTheme();
return (
<button
data-testid="call-setTheme"
onClick={() => {
try {
setTheme("dark");
} catch {
threw = true;
}
}}
>
call
</button>
);
}
render(<ClickSetTheme />);
await act(async () => {
screen.getByTestId("call-setTheme").click();
});
expect(threw).toBe(false);
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ function writeThemeCookie(value: ThemePreference): void {
document.cookie = parts.join("; ");
}
function applyResolvedTheme(resolved: ResolvedTheme): void {
export function applyResolvedTheme(resolved: ResolvedTheme): void {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = resolved;
}
@@ -1,472 +0,0 @@
package handlers
// Unit tests for plugins_listing.go:
// - parseManifestYAML: full YAML, missing fields, empty YAML
// - listRegistryFiltered: empty/missing dir, no yaml, valid yaml, runtime filter
// - ListRegistry (GET /plugins): no filter, with runtime filter
// - ListAvailableForWorkspace (GET /workspaces/:id/plugins/available): runtimeLookup stub
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
)
// -------- parseManifestYAML --------
func TestParseManifestYAML_FullPlugin(t *testing.T) {
data := []byte(`
name: molecule-audit
version: 1.2.3
description: Security audit plugin for Claude Code
author: Molecule AI
tags:
- security
- audit
skills:
- security-scan
- compliance-check
runtimes:
- claude_code
- hermes
`)
info := parseManifestYAML("fallback-name", data)
if info.Name != "fallback-name" {
t.Errorf("Name = %q; want fallback-name", info.Name)
}
if info.Version != "1.2.3" {
t.Errorf("Version = %q; want 1.2.3", info.Version)
}
if info.Description != "Security audit plugin for Claude Code" {
t.Errorf("Description = %q; want full description", info.Description)
}
if info.Author != "Molecule AI" {
t.Errorf("Author = %q; want Molecule AI", info.Author)
}
if len(info.Tags) != 2 || info.Tags[0] != "security" || info.Tags[1] != "audit" {
t.Errorf("Tags = %v; want [security audit]", info.Tags)
}
if len(info.Skills) != 2 || info.Skills[0] != "security-scan" || info.Skills[1] != "compliance-check" {
t.Errorf("Skills = %v; want [security-scan compliance-check]", info.Skills)
}
if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "hermes" {
t.Errorf("Runtimes = %v; want [claude_code hermes]", info.Runtimes)
}
}
func TestParseManifestYAML_MinimalFields(t *testing.T) {
// Only name field; all others should be zero-value.
data := []byte(`name: minimal-plugin`)
info := parseManifestYAML("fallback", data)
if info.Name != "fallback" {
t.Errorf("Name = %q; want fallback", info.Name)
}
if info.Version != "" {
t.Errorf("Version = %q; want empty", info.Version)
}
if info.Description != "" {
t.Errorf("Description = %q; want empty", info.Description)
}
if len(info.Tags) != 0 {
t.Errorf("Tags = %v; want []", info.Tags)
}
if len(info.Skills) != 0 {
t.Errorf("Skills = %v; want []", info.Skills)
}
if len(info.Runtimes) != 0 {
t.Errorf("Runtimes = %v; want []", info.Runtimes)
}
}
func TestParseManifestYAML_MissingPluginYAML(t *testing.T) {
// No plugin.yaml present → returns fallback name only.
info := parseManifestYAML("no-file", nil)
if info.Name != "no-file" {
t.Errorf("Name = %q; want no-file", info.Name)
}
}
func TestParseManifestYAML_BadYAML(t *testing.T) {
// Malformed YAML → returns fallback name only (no panic).
info := parseManifestYAML("bad-yaml", []byte("not: [yaml: at all"))
if info.Name != "bad-yaml" {
t.Errorf("Name = %q; want bad-yaml", info.Name)
}
if info.Version != "" {
t.Errorf("Version = %q; want empty after bad YAML", info.Version)
}
}
func TestParseManifestYAML_PartialFields(t *testing.T) {
// Present tags/skills/runtimes that are not []interface{} (e.g. wrong type)
// should not panic and should leave the field empty.
data := []byte(`
name: partial
tags: "not-an-array"
skills: 123
runtimes: true
`)
info := parseManifestYAML("partial", data)
if info.Name != "partial" {
t.Errorf("Name = %q; want partial", info.Name)
}
if len(info.Tags) != 0 {
t.Errorf("Tags = %v; want [] (wrong type)", info.Tags)
}
if len(info.Skills) != 0 {
t.Errorf("Skills = %v; want [] (wrong type)", info.Skills)
}
if len(info.Runtimes) != 0 {
t.Errorf("Runtimes = %v; want [] (wrong type)", info.Runtimes)
}
}
// -------- listRegistryFiltered --------
func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler {
// Construct a minimal PluginsHandler with a nil docker client
// (filesystem paths are tested directly; container-dependent paths are
// tested separately or skipped in this file).
h := &PluginsHandler{pluginsDir: pluginsDir}
return h
}
func writePluginYAML(t *testing.T, dir, name, content string) {
path := filepath.Join(dir, name, "plugin.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writeFile: %v", err)
}
}
func TestListRegistryFiltered_EmptyDir(t *testing.T) {
dir := t.TempDir()
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list for empty dir; got %d plugins", len(got))
}
}
func TestListRegistryFiltered_NonExistentDir(t *testing.T) {
h := makeTestHandler(t, "/does/not/exist")
got := h.listRegistryFiltered("")
if len(got) != 0 {
t.Errorf("expected empty list for nonexistent dir; got %d plugins", len(got))
}
}
func TestListRegistryFiltered_NoPluginYAML(t *testing.T) {
// Plugin directory exists but has no plugin.yaml → fallback name only.
dir := t.TempDir()
writePluginYAML(t, dir, "no-manifest-plugin", "")
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 {
t.Fatalf("expected 1 plugin; got %d", len(got))
}
if got[0].Name != "no-manifest-plugin" {
t.Errorf("Name = %q; want no-manifest-plugin", got[0].Name)
}
}
func TestListRegistryFiltered_ValidPlugin(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "molecule-audit", `
name: molecule-audit
version: 1.0.0
description: Security audit plugin
author: Molecule AI
tags:
- security
skills:
- audit
runtimes:
- hermes
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 {
t.Fatalf("expected 1 plugin; got %d", len(got))
}
if got[0].Name != "molecule-audit" {
t.Errorf("Name = %q; want molecule-audit", got[0].Name)
}
if got[0].Version != "1.0.0" {
t.Errorf("Version = %q; want 1.0.0", got[0].Version)
}
if len(got[0].Tags) != 1 || got[0].Tags[0] != "security" {
t.Errorf("Tags = %v; want [security]", got[0].Tags)
}
}
func TestListRegistryFiltered_FilesIgnored(t *testing.T) {
// Regular files in pluginsDir are skipped (only directories are scanned).
dir := t.TempDir()
writePluginYAML(t, dir, "real-plugin", `
name: real-plugin
version: 1.0.0
`)
f, err := os.Create(filepath.Join(dir, "not-a-plugin.txt"))
if err != nil {
t.Fatal(err)
}
f.Close()
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("")
if len(got) != 1 || got[0].Name != "real-plugin" {
t.Errorf("expected only real-plugin; got %v", got)
}
}
func TestListRegistryFiltered_RuntimeFilterMatches(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
h := makeTestHandler(t, dir)
// With hermes filter → only hermes-plugin returned.
got := h.listRegistryFiltered("hermes")
if len(got) != 1 || got[0].Name != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", got)
}
// With claude-code filter → hyphen normalises to underscore → cc-plugin returned.
got2 := h.listRegistryFiltered("claude-code")
if len(got2) != 1 || got2[0].Name != "cc-plugin" {
t.Errorf("expected [cc-plugin] with claude-code filter; got %v", got2)
}
}
func TestListRegistryFiltered_RuntimeFilterExcludes(t *testing.T) {
// Plugin declares hermes; query asks for claude-code → plugin excluded.
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-only", `
name: hermes-only
runtimes: [hermes]
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("claude_code")
if len(got) != 0 {
t.Errorf("expected empty list for mismatched runtime; got %v", got)
}
}
func TestListRegistryFiltered_UnspecifiedRuntimeIncluded(t *testing.T) {
// Plugin with no runtimes field is included in any filtered query
// ("unspecified = try it" contract).
dir := t.TempDir()
writePluginYAML(t, dir, "universal-plugin", `
name: universal-plugin
runtimes: []
`)
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("any-runtime")
if len(got) != 1 || got[0].Name != "universal-plugin" {
t.Errorf("expected [universal-plugin] with any runtime filter; got %v", got)
}
}
func TestListRegistryFiltered_MultipleMatching(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"plugin-a", "plugin-b", "plugin-c"} {
writePluginYAML(t, dir, name, `name: `+name+`
runtimes: [hermes, claude_code]
`)
}
h := makeTestHandler(t, dir)
got := h.listRegistryFiltered("hermes")
if len(got) != 3 {
t.Errorf("expected 3 plugins; got %d: %v", len(got), got)
}
}
// -------- ListRegistry (GET /plugins) --------
func listRegistryReq(runtime string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
url := "/plugins"
if runtime != "" {
url += "?runtime=" + runtime
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", url, nil)
return c.Request, w, c
}
func TestListRegistry_NoFilter(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "test-plugin", `
name: test-plugin
version: 0.1.0
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp) != 1 || resp[0]["name"] != "test-plugin" {
t.Errorf("unexpected response: %v", resp)
}
}
func TestListRegistry_WithRuntimeFilter(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("hermes")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", resp)
}
}
func TestListRegistry_EmptyOnNoMatches(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "cc-plugin", `name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
_, w, c := listRegistryReq("nonexistent")
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 0 {
t.Errorf("expected empty list; got %v", resp)
}
}
// -------- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available) --------
func listAvailableReq(workspaceID string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil)
return c.Request, w, c
}
func TestListAvailableForWorkspace_RuntimeLookupReturnsRuntime(t *testing.T) {
dir := t.TempDir()
writePluginYAML(t, dir, "hermes-plugin", `
name: hermes-plugin
runtimes: [hermes]
`)
writePluginYAML(t, dir, "cc-plugin", `
name: cc-plugin
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(workspaceID string) (string, error) {
return "hermes", nil
}
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000001")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
t.Errorf("expected [hermes-plugin]; got %v", resp)
}
}
func TestListAvailableForWorkspace_RuntimeLookupErrors(t *testing.T) {
// runtimeLookup error → runtime="" → full registry returned.
dir := t.TempDir()
writePluginYAML(t, dir, "plugin-a", `name: plugin-a
runtimes: [hermes]
`)
writePluginYAML(t, dir, "plugin-b", `name: plugin-b
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(workspaceID string) (string, error) {
return "", errors.New("runtime lookup failed")
}
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000002")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 2 {
t.Errorf("expected 2 plugins (full registry fallback); got %d: %v", len(resp), resp)
}
}
func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) {
// runtimeLookup nil → full registry (no filter).
dir := t.TempDir()
writePluginYAML(t, dir, "plugin-x", `name: plugin-x`)
h := makeTestHandler(t, dir)
// runtimeLookup is nil by default from makeTestHandler.
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000003")
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200; got %d", w.Code)
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 1 || resp[0]["name"] != "plugin-x" {
t.Errorf("expected [plugin-x]; got %v", resp)
}
}
func TestListAvailableForWorkspace_UnspecifiedRuntimePluginsAlwaysIncluded(t *testing.T) {
// Plugins with empty runtimes list should always be included
// regardless of workspace runtime.
dir := t.TempDir()
writePluginYAML(t, dir, "universal", `name: universal
runtimes: []
`)
writePluginYAML(t, dir, "cc-only", `name: cc-only
runtimes: [claude_code]
`)
h := makeTestHandler(t, dir)
h.runtimeLookup = func(id string) (string, error) { return "hermes", nil }
_, w, c := listAvailableReq("ws-001")
h.ListAvailableForWorkspace(c)
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// "universal" has no runtimes (try-it); "cc-only" doesn't support hermes.
if len(resp) != 1 || resp[0]["name"] != "universal" {
t.Errorf("expected [universal]; got %v", resp)
}
}
@@ -34,11 +34,13 @@ import (
// BroadcastHandler is constructed once and shared across requests.
type BroadcastHandler struct {
broadcaster *events.Broadcaster
broadcaster events.EventEmitter
}
// NewBroadcastHandler creates a BroadcastHandler.
func NewBroadcastHandler(b *events.Broadcaster) *BroadcastHandler {
// The emitter is any EventEmitter — the concrete *Broadcaster in production,
// or a test double in unit tests.
func NewBroadcastHandler(b events.EventEmitter) *BroadcastHandler {
return &BroadcastHandler{broadcaster: b}
}
@@ -67,7 +67,6 @@ func TestBroadcast_OrgScopedRecipients(t *testing.T) {
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
@@ -206,7 +205,7 @@ func TestBroadcast_Disabled(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
senderID := "00000000-0000-0000-0000-000000000003"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
@@ -237,7 +236,7 @@ func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
senderID := "00000000-0000-0000-0000-000000000004" // org root, only workspace in org
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
@@ -297,33 +296,12 @@ func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
// finding the org root errors, the handler returns 500 instead of proceeding
// with an un-scoped query that would broadcast to all orgs.
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
senderID := "00000000-0000-0000-0000-000000000005"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
@@ -353,16 +331,13 @@ func TestBroadcast_OrgRootLookupFails(t *testing.T) {
}
}
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
// from a workspace does not send a broadcast_receive to the sender itself
// (the sender logs broadcast_sent, not broadcast_receive).
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
peerID := "00000000-0000-0000-0000-000000000002"
senderID := "00000000-0000-0000-0000-000000000006"
peerID := "00000000-0000-0000-0000-000000000007"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
@@ -399,10 +374,145 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
}
}
// TestBroadcast_RecipientActivityLogFails_SkipsAndContinues: if one recipient's
// activity_log insert fails, the handler logs the error and continues to the
// next recipient rather than aborting the whole broadcast.
func TestBroadcast_RecipientActivityLogFails_SkipsAndContinues(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000008"
peerA := "00000000-0000-0000-0000-000000000009"
peerB := "00000000-0000-0000-0000-00000000000a"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Resilient Agent", true))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA).AddRow(peerB))
// Peer A fails — handler logs and continues
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
WillReturnError(context.DeadlineExceeded)
// Peer B succeeds
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerB, senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender log succeeds
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"partial delivery"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Only peerB was delivered
if int(resp["delivered"].(float64)) != 1 {
t.Errorf("expected delivered=1, got %v", resp["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestBroadcast_SenderActivityLogFails_StillReturns200: if the sender's own
// broadcast_sent activity_log insert fails, the handler still returns 200
// so the caller doesn't retry a broadcast that already partially delivered.
func TestBroadcast_SenderActivityLogFails_StillReturns200(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-00000000000b"
peerA := "00000000-0000-0000-0000-00000000000c"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Log-Fail Agent", true))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA))
// Peer log succeeds
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender log FAILS
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
WillReturnError(context.DeadlineExceeded)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"log fail test"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200 even on sender log failure, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000d"}}
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000d/broadcast", bytes.NewBufferString("{}"))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingBody(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000e"}}
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000e/broadcast", nil)
// no Content-Type and no body
handler.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…".
func TestBroadcast_Truncate(t *testing.T) {
cases := []struct {
msg string
@@ -410,14 +520,18 @@ func TestBroadcast_Truncate(t *testing.T) {
expect string
}{
{"short", 120, "short"}, // under max — no truncation
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
// exactly 120 chars → unchanged
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
// "this is a longer mes" = 20 runes; + "…" = 21 chars
// 21 runes at max=20 → 20 + "…" = 21 chars
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
// at-max boundary: 20 chars at max=20 → no truncation
{"exactly twenty chars", 20, "exactly twenty chars"},
// over max: 11 chars at max=10 → 10 + "…" = 11
{"hello world!", 10, "hello worl…"},
// Unicode: 3-rune string at max=3 → unchanged
{"日本語", 3, "日本語"},
// Empty string → unchanged
{"", 120, ""},
}
for _, tc := range cases {
result := broadcastTruncate(tc.msg, tc.max)