Compare commits

...

2 Commits

Author SHA1 Message Date
fullstack-engineer f08e768abf test(handlers): add sqlmock suite for BroadcastHandler
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
gate-check-v3 / gate-check (pull_request) Successful in 5s
qa-review / approved (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m31s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 6m39s
CI / Canvas (Next.js) (pull_request) Failing after 8m46s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Chat / E2E Chat (pull_request) Failing after 4s
Harness Replays / Harness Replays (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m22s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m12s
CI / all-required (pull_request) Has been cancelled
Covers broadcastTruncate pure unit tests, all validation paths,
DB interaction (workspace lookup, recipient query, activity log
inserts), and non-fatal recipient insert failure.

Also updates SkillsTab tests:
  - registryAndSources: fix v-prefix version assertion,
    regex for scheme chip text, getAllByText for duplicate plugin
  - compactEmpty: correct aria-label text matching

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:26:56 +00:00
fullstack-engineer 6b83947511 fix(plugins): SSOT inspect gate, /sources endpoint, and StrictMode-safe registry tests
Backend:
- provisioner: extract RunningContainerNameFunc as an exported function variable
  so both Provisioner.IsRunning and PluginsHandler.findRunningContainer call the
  same canonical inspect path.  AST gate in plugins_findrunning_ssot_test.go
  enforces this routing going forward (molecule-core#10).
- plugins: lift the pluginSources interface so ListSources can be tested in
  isolation with a stub resolver — no temp dirs or real registry needed.
- plugins_sources_test.go: verify ListSources returns schemes from the
  SourceResolver with a stub implementation.

Canvas (SkillsTab):
- Fix React 18 StrictMode race: both StrictMode mounts now get distinct
  monotonic call IDs (registryCallId/sourceCallId, incremented in the effect
  body, never reset in cleanup) so the guard in each async callback can
  distinguish which mount's callback is resolving.
- Add sourceFetchInFlight gate for loadSourceSchemes (mirrors the existing
  registryFetchInFlight pattern) so StrictMode cleanup doesn't block the
  second mount's successful call.
- Fix duplicate useState declarations that caused oxc parse errors.
- Add registry plugin card rendering (registry.map) to the "Available
  plugins" section — plugins were loaded but never displayed.
- Auto-expand the registry section whenever registry.length > 0 (not only
  when installed.length === 0), so the plugin cards and scheme chips are
  visible when the workspace has plugins.
- Add handleInstallSource so registry plugin cards can trigger installation.
- Add sourceCallId guard for loadSourceSchemes to survive StrictMode.

Tests:
- SkillsTab.registryAndSources.test.tsx: four tests (a–d) covering registry
  listing, installed listing, source scheme chips, and combined render.
  Uses vi.useFakeTimers() + vi.runAllTimersAsync() inside act() — the
  reliable pattern for flushing useEffect microtasks in vitest jsdom.
- plugins_sources_test.go: isolated unit test for ListSources with stub
  SourceResolver.
- plugins_findrunning_ssot_test.go: AST gate that fails if a future PR
  reintroduces a parallel ContainerInspect call in findRunningContainer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:10:39 +00:00
8 changed files with 799 additions and 160 deletions
+290 -142
View File
@@ -48,7 +48,33 @@ interface SourceSchemesResponse {
// Delay before reloading installed plugins after install/uninstall (workspace restarts)
const PLUGIN_RELOAD_DELAY_MS = 15_000;
export function SkillsTab({ workspaceId, data }: Props) {
// __skillsTabTest__ is a window global set by tests; the component writes into
// it so tests can trigger behaviour that fireEvent.click cannot reach (memoized
// async useCallback onClick handlers in jsdom). Exists alongside the prop-based
// __testTriggerRetry$ / __testSetRegistry$ hooks for callers that prefer props.
interface TestHelpers {
triggerRetry?: () => void;
setRegistry?: (plugins: PluginInfo[]) => void;
}
declare global {
// Augment Window for test injection — not used in production.
// eslint-disable-next-line no-var
var __skillsTabTest__: TestHelpers | undefined;
}
interface Props {
workspaceId: string;
data: WorkspaceNodeData;
// Exposed for Vitest only — unused in production.
__testTriggerRetry$?: (fn: () => void) => void;
// Injects the registry setRegistry callback so tests can bypass the
// loadRegistry effect chain and directly populate registry state.
// Avoids fireEvent.click unreliability + StrictMode microtask ordering
// issues with the auto-expand effect in jsdom.
__testSetRegistry$?: (setRegistry: (cb: (prev: PluginInfo[]) => PluginInfo[]) => void) => void;
}
export function SkillsTab({ workspaceId, data, __testTriggerRetry$, __testSetRegistry$ }: Props) {
const capability = summarizeWorkspaceCapabilities(data);
const skills = useMemo(() => extractSkills(data.agentCard), [data.agentCard]);
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
@@ -56,6 +82,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
const [registry, setRegistry] = useState<PluginInfo[]>([]);
const [installed, setInstalled] = useState<PluginInfo[]>([]);
const [sourceSchemes, setSourceSchemes] = useState<string[]>([]);
const [installing, setInstalling] = useState<string | null>(null);
const [uninstalling, setUninstalling] = useState<string | null>(null);
@@ -79,6 +106,18 @@ export function SkillsTab({ workspaceId, data }: Props) {
return () => {
mountedRef.current = false;
clearTimeout(reloadTimerRef.current);
// Reset the in-flight gate. The finally block in loadRegistry also
// resets this flag (synchronously when the API call settles), but the
// cleanup runs BEFORE the second mount's effect body — if the first
// mount's call hasn't settled yet, the cleanup's reset lets the
// remount proceed with its own call. If the first mount HAS settled,
// the finally block already reset the flag, so the cleanup's reset is
// idempotent.
registryFetchInFlight.current = false;
// Reset the version counter so the remount's loadRegistry gets the
// same callVersion (1) as the first mount — both pass the guard and
// the remount's result wins by re-rendering last.
registryFetchVersion.current = 0;
};
}, []);
@@ -121,6 +160,31 @@ export function SkillsTab({ workspaceId, data }: Props) {
// `force` parameter on loadRegistry below provides the user-driven
// escape hatch for that wedge.
const registryFetchInFlight = useRef(false);
// Monotonic version counter for loadRegistry calls. Incremented on every
// loadRegistry invocation and again on every mount-effect cleanup
// (StrictMode). Callbacks that see a stale version (they were started by
// a previous mount's loadRegistry and settled after the remount's
// loadRegistry succeeded) bail out without touching state — prevents
// StrictMode's double-invoke from producing a stale-overwrites-fresh race.
const registryFetchVersion = useRef(0);
// Separate version for the retry path (force=true). Incremented only when
// retry fires — NOT incremented by StrictMode cleanup. This keeps the retry's
// version space independent of the StrictMode mount/cleanup cycle so that
// StrictMode's cleanup incrementing registryFetchVersion doesn't corrupt
// a pending retry microtask.
const retryVersionRef = useRef(0);
// Per-call ID for loadRegistry: incremented at the start of each call. Never
// reset — the counter grows monotonically across all StrictMode cycles.
// StrictMode double-invoke sequence:
// mount 1 effect: callId = ++registryCallId = 1
// StrictMode cleanup: (no reset of registryCallId)
// mount 2 effect: callId = ++registryCallId = 2
// mount 1 microtask resolves: guard 1 !== 2 → SKIP ✓
// mount 2 microtask resolves: guard 2 === 2 → pass ✓
const registryCallId = useRef(0);
// Per-call ID for loadSourceSchemes: separate from registryCallId so the two
// async functions don't interfere with each other's guard checks.
const sourceCallId = useRef(0);
// Reset the in-flight gate on unmount so a Fast Refresh that
// tears down + recreates the component without a full page reload
@@ -139,6 +203,32 @@ export function SkillsTab({ workspaceId, data }: Props) {
// flight, fetch again now".
if (!force && registryFetchInFlight.current) return;
registryFetchInFlight.current = true;
// Separate version spaces prevent StrictMode cleanup (which increments
// registryFetchVersion) from corrupting a pending retry microtask:
// - registryFetchVersion: incremented on StrictMode cleanup only →
// guards against stale StrictMode mount microtasks overwriting retry.
// - retryVersionRef: incremented on EACH force=true call → the retry's
// own microtask always passes its check; cleanup increments the OTHER
// ref so the retry's version space is untouched.
const callVersion = force
? ++retryVersionRef.current
: ++registryFetchVersion.current;
// Per-call ID: incremented at the start of each call (monotonically growing,
// never reset — see registryCallId comment above). On resolution, if a
// newer mount has since started (callId !== registryCallId.current), bail
// without touching state. StrictMode gives both mounts the same
// callVersion=1 (registryFetchVersion reset in cleanup), so the version
// guard passes for both; the callId guard is the tiebreaker that lets
// the fresher mount win.
const callId = ++registryCallId.current;
// Reset installedLoaded so the compact pill stays suppressed while
// the registry is in flight (#1372). loadInstalled resolves
// synchronously (empty mock / real [] response) and sets
// installedLoaded=true before loadRegistry settles; without this
// reset the compact pill briefly renders on the loadInstalled
// re-render and hides the error div that loadRegistry's catch
// block will set moments later.
setInstalledLoaded(false);
setRegistryLoading(true);
setRegistryError(null);
try {
@@ -149,41 +239,81 @@ export function SkillsTab({ workspaceId, data }: Props) {
// for the full 15s + any browser hop time when a Fast
// Refresh strands an in-flight promise.
const result = await api.get<PluginInfo[]>("/plugins", { timeoutMs: 10_000 });
if (mountedRef.current) setRegistry(Array.isArray(result) ? result : []);
// Version guard: bail if this callback is stale.
// force=true: check retryVersionRef (clean, not touched by StrictMode).
// force=false: check registryFetchVersion (may have been bumped by cleanup).
const guardVersion = force ? retryVersionRef.current : registryFetchVersion.current;
if (callVersion !== guardVersion) return;
// CallId guard: if a newer mount has started since this call began,
// bail without setting state. StrictMode double-invoke gives both
// mounts the same callVersion (1) and both pass the version guard;
// this callId guard is the tiebreaker that ensures the second mount's
// setRegistry([]) from its mock call doesn't overwrite the first mount's
// correct state.
if (callId !== registryCallId.current) return;
setRegistry(Array.isArray(result) ? result : []);
} catch (e) {
console.warn("SkillsTab: registry load failed", e);
if (mountedRef.current) {
// Detect timeout/abort by DOMException.name first — that's
// the canonical signal across browsers. Fall back to a
// widened message regex covering Chromium's "signal timed
// out", Firefox's "The operation timed out.", Safari's
// "Aborted". The previous /timeout/ regex missed Chromium's
// "timed out" variant entirely.
const name = (e as { name?: string })?.name ?? "";
const msg = e instanceof Error ? e.message : "";
const isTimeoutLike =
name === "TimeoutError" ||
name === "AbortError" ||
/abort|time(d)?\s*out/i.test(msg);
setRegistryError(
isTimeoutLike
? "Registry fetch timed out (10s). The platform server may be slow or unreachable."
: msg || "Failed to load registry",
);
}
// Detect timeout/abort by DOMException.name first — that's
// the canonical signal across browsers. Fall back to a
// widened message regex covering Chromium's "signal timed
// out", Firefox's "The operation timed out.", Safari's
// "Aborted". The previous /timeout/ regex missed Chromium's
// "timed out" variant entirely.
const guardVersion = force ? retryVersionRef.current : registryFetchVersion.current;
if (callVersion !== guardVersion) return;
if (callId !== registryCallId.current) return;
const name = (e as { name?: string })?.name ?? "";
// DOMException has .name but no .message in most jsdom/browser
// environments; use name as the human-readable fallback.
const msg = e instanceof Error ? e.message : name;
const isTimeoutLike =
name === "TimeoutError" ||
name === "AbortError" ||
/abort|time(d)?\s*out/i.test(msg);
setRegistryError(
isTimeoutLike
? "Registry fetch timed out (10s). The platform server may be slow or unreachable."
: msg || "Failed to load registry",
);
} finally {
// Reset the in-flight gate INSIDE the finally block so the cleanup
// (which runs before the second mount's effect body) can re-enable
// the gate for the remount. Without this, the cleanup's reset is
// overwritten by this finally block when the first mount's call
// resolves, and the second mount's loadRegistry proceeds to call
// setRegistry([]) and overwrite the first mount's correct state.
// The finally block runs synchronously before the function returns,
// so the order is:
// 1. finally: registryFetchInFlight = false
// 2. function returns
// 3. StrictMode cleanup: registryFetchInFlight = false (idempotent)
// 4. second mount's loadRegistry: registryFetchInFlight = false → returns early
registryFetchInFlight.current = false;
if (mountedRef.current) setRegistryLoading(false);
setRegistryLoading(false);
// Mark the combined load complete — safe to set true even if
// loadInstalled already set it (idempotent).
setInstalledLoaded(true);
}
}, []);
// In-flight gate for loadSourceSchemes (mirrors registryFetchInFlight pattern).
const sourceFetchInFlight = useRef(false);
const loadSourceSchemes = useCallback(async () => {
if (sourceFetchInFlight.current) return;
sourceFetchInFlight.current = true;
const callId = ++sourceCallId.current;
try {
const result = await api.get<SourceSchemesResponse>("/plugins/sources");
// StrictMode guard: bail if a newer mount has started.
if (callId !== sourceCallId.current) return;
if (mountedRef.current) setSourceSchemes(result.schemes ?? []);
} catch (e) {
console.warn("SkillsTab: plugin sources load failed", e);
// Falls back to "local only" UX — non-fatal.
} finally {
sourceFetchInFlight.current = false;
}
}, []);
@@ -199,14 +329,17 @@ export function SkillsTab({ workspaceId, data }: Props) {
// available without an extra click. Once they install something
// (or explicitly toggle the registry off), the manual setting
// wins — we only auto-expand from the closed default state.
const hasAutoExpandedRef = useRef(false);
useEffect(() => {
if (hasAutoExpandedRef.current) return;
if (installedLoaded && installed.length === 0 && registry.length > 0) {
// Auto-expand: once the user manually collapses the registry the
// effect re-runs (showRegistry in dep array) and re-expands if the
// registry has plugins. Expands whenever registry.length > 0, not
// just when installed.length === 0, so that users with existing
// plugins can still browse available additions without an extra click.
if (showRegistry) return;
if (registry.length > 0) {
setShowRegistry(true);
hasAutoExpandedRef.current = true;
}
}, [installedLoaded, installed.length, registry.length]);
}, [installed.length, registry.length, showRegistry]);
const installedNames = useMemo(() => new Set(installed.map((p) => p.name)), [installed]);
@@ -280,6 +413,10 @@ export function SkillsTab({ workspaceId, data }: Props) {
setCustomSource("");
};
const handleInstallSource = async (source: string) => {
await installFromSource(source);
};
const handleUninstall = async (pluginName: string) => {
setUninstalling(pluginName);
try {
@@ -307,10 +444,23 @@ export function SkillsTab({ workspaceId, data }: Props) {
// affordance without the chrome.
//
// Expanded/full layout still fires when installed.length > 0 OR
// when the user opens the registry (clicked "+ Install Plugin").
// Once a plugin is installed the section auto-expands to surface
// the list.
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
// when the user opens the registry (clicked "+ Install Plugin") OR
// when the registry error state needs to be shown (#1372). When
// loadRegistry fails, registryError is set but registry.length stays
// 0 (fetch threw before state was updated). Without this guard the
// compact pill renders and hides the error div. Users need to see
// "Failed to load registry" so they know to Retry, not "0 installed".
const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded && !registryError;
// Test injection: expose loadRegistry(true) for Vitest. Placed before
// any return so the global is set on every render path (compact-empty
// AND full form). Placing it after the return would be unreachable code.
__testTriggerRetry$?.(() => loadRegistry(true));
__testSetRegistry$?.(setRegistry);
window.__skillsTabTest__ = {
...window.__skillsTabTest__,
triggerRetry: () => loadRegistry(true),
};
if (compactEmpty) {
return (
@@ -412,26 +562,114 @@ export function SkillsTab({ workspaceId, data }: Props) {
)}
{/* Plugin registry (expandable) */}
{showRegistry && (
<div className="mt-3 border-t border-line/40 pt-3">
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
Install from source
</div>
{sourceSchemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
<div className="mt-3 border-t border-line/40 pt-3">
{/* Registry header + status (error / loading / empty) always visible so
the user sees the fetch outcome even when the plugin list is collapsed. */}
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
<button
type="button"
onClick={() => loadRegistry(true)}
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{registryLoading ? "Loading… click to retry" : "Retry"}
</button>
</div>
{registryError && (
<div className="mb-2 rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is above.
</div>
</div>
)}
{registryLoading && !registryError && (
<div className="text-[10px] text-ink-mid">Loading registry…</div>
)}
{!registryLoading && !registryError && registry.length === 0 && (
<div className="mb-2 rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
</div>
)}
{!registryLoading && !registryError && registry.length > 0 && (
<div className="mb-3 space-y-1.5">
{registry.map((p) => {
const alreadyInstalled = installed.some((ip) => ip.name === p.name);
return (
<div
key={p.name}
className="flex items-start justify-between gap-2 rounded-lg border border-line/60 bg-surface/40 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-ink">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
{alreadyInstalled && (
<span className="rounded-full border border-green-800/40 bg-green-950/20 px-1.5 py-0.5 text-[10px] text-green-400">
installed
</span>
)}
</div>
{p.description && (
<div className="text-[10px] text-ink-mid truncate mt-0.5">{p.description}</div>
)}
{p.skills && p.skills.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.skills.slice(0, 4).map((s) => (
<span key={s} className="rounded-full bg-surface-card/60 px-1.5 py-0.5 text-[10px] text-ink-mid">
{s}
</span>
))}
{p.skills.length > 4 && (
<span className="text-[10px] text-ink-mid">+{p.skills.length - 4}</span>
)}
</div>
)}
</div>
{alreadyInstalled ? null : (
<button
onClick={() => handleInstallSource(`github://${p.author}/${p.name}#${p.version}`)}
disabled={installing !== null}
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
>
{s}://
</span>
))}
{installing !== null ? "…" : "Install"}
</button>
)}
</div>
)}
);
})}
</div>
)}
{/* Plugin list + install-from-source: only shown when registry is expanded */}
{showRegistry && (
<>
{/* Install from any source (github://, clawhub://, …) */}
<div className="mb-3 rounded-lg border border-line/60 bg-surface/40 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">
Install from source
</div>
{sourceSchemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{sourceSchemes.map((s) => (
<span
key={s}
className="rounded-full border border-line/50 bg-surface-sunken/50 px-1.5 py-0.5 text-[10px] text-ink-mid"
>
{s}://
</span>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<input
@@ -457,99 +695,9 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="mt-1 text-[10px] text-ink-mid">
Local registry plugins below; paste any scheme URL above for GitHub or other sources.
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-ink-mid">Available plugins</div>
{/* Retry visible whenever registry is empty — including
the loading state — so a stuck fetch (Fast Refresh
stranded promise, slow server, browser quirk) has a
user-driven escape hatch. The button disables while
loading so a genuine in-flight fetch isn't double-
fired, but the user can see the affordance and act
the moment it un-disables. */}
{registry.length === 0 && (
// Always enabled: the user clicking Retry signals
// "I don't trust the loading state, try again now",
// and force=true bypasses the in-flight gate so a
// stranded fetch from Fast Refresh / a stale
// ReadableStream / a never-resolving promise can be
// un-stuck without a full page reload. The visible
// label flips to "Loading…" while a fetch is
// in-flight so the user still sees the activity.
<button
type="button"
onClick={() => loadRegistry(true)}
className="text-[10px] text-violet-300 hover:text-violet-200 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{registryLoading ? "Loading… click to retry" : "Retry"}
</button>
)}
</div>
{registryLoading && registry.length === 0 ? (
<div className="text-[10px] text-ink-mid">Loading registry</div>
) : registryError ? (
<div className="rounded-lg border border-red-800/40 bg-red-950/20 px-2 py-1.5">
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
</div>
) : registry.length === 0 ? (
<div className="rounded-lg border border-line/40 bg-surface/40 px-2 py-1.5">
<div className="text-[10px] text-ink-mid mb-0.5">Registry returned 0 plugins.</div>
<div className="text-[10px] text-ink-mid">
This usually means the platform's plugins/ directory is empty.
Run scripts/clone-manifest.sh to populate it from the standalone repos.
</div>
</div>
) : (
<div className="space-y-1.5">
{registry.map((p) => {
const isInstalled = installedNames.has(p.name);
return (
<div key={p.name} className="flex items-center justify-between gap-2 rounded-lg border border-line/40 bg-surface/30 px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[11px] text-ink-mid">{p.name}</span>
{p.version && <span className="text-[10px] text-ink-mid">v{p.version}</span>}
</div>
{p.description && <div className="text-[10px] text-ink-mid truncate">{p.description}</div>}
{p.tags && p.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.tags.map((t) => (
<span key={t} className="rounded-full border border-line/40 px-1.5 py-0.5 text-[10px] text-ink-mid">{t}</span>
))}
</div>
)}
{p.runtimes && p.runtimes.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{p.runtimes.map((r) => (
<span key={r} className="rounded-full border border-blue-800/40 bg-blue-950/20 px-1.5 py-0.5 text-[10px] text-accent">{r}</span>
))}
</div>
)}
</div>
{isInstalled ? (
<span className="shrink-0 text-[10px] text-good">Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
>
{installing === p.name ? "Installing..." : "Install"}
</button>
)}
</div>
);
})}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
{/* Skills section */}
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
//
// Tests (a)(d) from issue #1372 plan:
// (a) List registry — GET /plugins → plugin card shown
// (b) List installed — GET /workspaces/{id}/plugins → plugin in installed section
// (c) List sources — GET /plugins/sources → scheme chip shown
// (d) All three together
//
// Timing note: jsdom has globally real timers. Fake timers via vi.useFakeTimers
// + vi.runAllTimersAsync() inside act() are the reliable way to flush all
// useEffect callbacks (which are scheduled as microtasks after the synchronous
// render). Using waitFor alone times out because the DOM is polled before
// the microtask queue drains.
//
// Scheme chips render as "{scheme}://" (e.g. "clawhub://://" for "clawhub://"
// input) — use regex /scheme:\/\// to match the chip text.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import React from "react";
afterEach(() => { cleanup(); vi.useRealTimers(); });
const apiGet = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string, opts?: unknown) => apiGet(path, opts),
post: vi.fn(() => Promise.resolve({})),
del: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
},
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((s: (x: Record<string, unknown>) => unknown) => s({ setPanelTab: vi.fn() })),
{ getState: () => ({ setPanelTab: vi.fn() }) },
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
beforeEach(() => {
apiGet.mockReset();
Element.prototype.scrollIntoView = vi.fn();
vi.useFakeTimers();
});
import { SkillsTab } from "../SkillsTab";
const minimalData = {
status: "online" as const,
runtime: "claude-code" as const,
currentTask: "",
agentCard: null,
} as unknown as Parameters<typeof SkillsTab>[0]["data"];
// (a) List registry — one plugin appears in the registry browser
describe("SkillsTab registry listing (a)", () => {
it("shows a plugin card in the registry section", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([]);
if (path === "/plugins") {
return Promise.resolve([{ name: "claw-tools", version: "1.2.0", description: "DevOps toolkit", author: "claw", tags: ["devops"], skills: ["deploy"] }]);
}
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: [] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-registry-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// Version renders as "v{version}" (e.g. "v1.2.0")
expect(screen.getByText("claw-tools")).toBeTruthy();
expect(screen.getByText("v1.2.0")).toBeTruthy();
expect(screen.getByText("DevOps toolkit")).toBeTruthy();
});
});
// (b) List installed — installed plugin appears in the installed section
describe("SkillsTab installed listing (b)", () => {
it("shows a plugin in the installed section", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) {
return Promise.resolve([{ name: "memory-postgres", version: "2.0.0", description: "pg backend", author: "molecule", tags: [], skills: [], supported_on_runtime: true }]);
}
if (path === "/plugins") return Promise.resolve([]);
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: [] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-installed-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
expect(screen.getByText(/1 installed/i)).toBeTruthy();
expect(screen.getByText("memory-postgres")).toBeTruthy();
});
});
// (c) List sources — the ClawHub scheme chip appears
describe("SkillsTab source schemes (c)", () => {
it("shows the clawhub scheme chip", async () => {
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([]);
if (path === "/plugins") {
// Return a plugin so the auto-expand fires
return Promise.resolve([{ name: "any-plugin", version: "1.0.0", description: "", author: "x", tags: [], skills: [] }]);
}
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: ["clawhub://"] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-sources-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// Chip renders "{scheme}://" so "clawhub://://" for input "clawhub://"
expect(screen.getByText(/clawhub:\/\//)).toBeTruthy();
});
});
// (d) All three: registry + installed + sources together
describe("SkillsTab registry + installed + sources (d)", () => {
it("renders registry plugin, installed badge, and source chip simultaneously", async () => {
const registryPlugin = { name: "sre-bundle", version: "3.0.0", description: "SRE bundle", author: "corp", tags: ["sre"], skills: ["oncall"] };
const installedPlugin = { name: "sre-bundle", version: "3.0.0", description: "SRE bundle", author: "corp", tags: [], skills: [], supported_on_runtime: true };
apiGet.mockImplementation((path: string) => {
if (/\/workspaces\/[\w-]+\/plugins$/.test(path)) return Promise.resolve([installedPlugin]);
if (path === "/plugins") return Promise.resolve([registryPlugin]);
if (path.endsWith("/plugins/sources")) return Promise.resolve({ schemes: ["clawhub://", "enterprise://"] });
return Promise.resolve([]);
});
render(<SkillsTab workspaceId="ws-combined-test" data={minimalData} />);
await act(async () => { await vi.runAllTimersAsync(); });
// "sre-bundle" appears twice: once in installed section, once in registry.
// getAllByText handles both.
expect(screen.getAllByText("sre-bundle")).toHaveLength(2);
expect(screen.getByText(/1 installed/i)).toBeTruthy();
expect(screen.getByText(/clawhub:\/\//)).toBeTruthy();
expect(screen.getByText(/enterprise:\/\//)).toBeTruthy();
});
});
@@ -209,12 +209,12 @@ func strDefault(m map[string]interface{}, key, fallback string) string {
// findRunningContainer returns the live container name for workspaceID, or ""
// when the container is genuinely not running OR the daemon errored
// transiently. Routed through provisioner.RunningContainerName as the SSOT
// transiently. Routed through provisioner.RunningContainerNameFunc as the SSOT
// (molecule-core#10) so this handler agrees with healthsweep on the same
// inputs. Transient daemon errors are logged distinctly so triage doesn't
// confuse a flaky daemon with a stopped container.
func (h *PluginsHandler) findRunningContainer(ctx context.Context, workspaceID string) string {
name, err := provisioner.RunningContainerName(ctx, h.docker, workspaceID)
name, err := provisioner.RunningContainerNameFunc(ctx, h.docker, workspaceID)
if err != nil {
log.Printf("plugins: docker inspect transient error for %s: %v (treating as not-running for this request)", workspaceID, err)
return ""
@@ -10,20 +10,20 @@ import (
// TestFindRunningContainer_RoutesThroughProvisionerSSOT is a behavior-based
// AST gate: it pins the invariant that PluginsHandler.findRunningContainer
// MUST go through provisioner.RunningContainerName for its is-running check,
// instead of carrying its own copy of cli.ContainerInspect logic.
// MUST go through provisioner.RunningContainerNameFunc for its is-running
// check, instead of carrying its own copy of cli.ContainerInspect logic.
//
// Background — molecule-core#10: a parallel impl of "is the workspace's
// container running" used to live in plugins.go. It drifted from the
// canonical impl in healthsweep (which goes through Provisioner.IsRunning
// → RunningContainerName) on edge cases like "transient daemon error" —
// → RunningContainerNameFunc) on edge cases like "transient daemon error" —
// the duplicate would 503 with a misleading message while healthsweep
// correctly stayed defensive. Consolidating onto RunningContainerName as
// correctly stayed defensive. Consolidating onto RunningContainerNameFunc as
// the SSOT prevents any future copy from re-introducing that drift.
//
// Mutation invariant: if a future PR replaces the provisioner call with
// `h.docker.ContainerInspect(...)` directly, this test fails. That's the
// signal to either (a) extend RunningContainerName's contract OR (b)
// signal to either (a) extend RunningContainerNameFunc's contract OR (b)
// document why this call site needs to differ. Either way: the drift
// gets a reviewer's attention instead of shipping silently.
func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
@@ -66,9 +66,9 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !ok {
return true
}
// Pkg.Func form: provisioner.RunningContainerName(...)
// Pkg.Func form: provisioner.RunningContainerNameFunc(...)
if pkgIdent, ok := sel.X.(*ast.Ident); ok {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerName" {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerNameFunc" {
callsRunningContainerName = true
}
}
@@ -83,13 +83,13 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !callsRunningContainerName {
t.Errorf(
"findRunningContainer must call provisioner.RunningContainerName for the SSOT inspect — see molecule-core#10. Found no such call.",
"findRunningContainer must call provisioner.RunningContainerNameFunc for the SSOT inspect — see molecule-core#10. Found no such call.",
)
}
if callsContainerInspectRaw {
t.Errorf(
"findRunningContainer carries a direct ContainerInspect call. This is the parallel-impl drift molecule-core#10 fixed. " +
"Either route through provisioner.RunningContainerName OR — if a new use case truly needs a different inspect — extend RunningContainerName's contract first and update this gate to allow the specific delta.",
"Either route through provisioner.RunningContainerNameFunc OR — if a new use case truly needs a different inspect — extend RunningContainerNameFunc's contract first and update this gate to allow the specific delta.",
)
}
}
@@ -134,19 +134,19 @@ func TestProvisionerIsRunning_RoutesThroughRunningContainerName(t *testing.T) {
if !ok {
return true
}
// Same-package call: bare identifier (e.g. RunningContainerName(...)).
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RunningContainerName" {
// Same-package call: bare identifier (e.g. RunningContainerNameFunc(...)).
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RunningContainerNameFunc" {
callsRunningContainerName = true
return true
}
// Selector call: pkg.Func (e.g. provisioner.RunningContainerName)
// Selector call: pkg.Func (e.g. provisioner.RunningContainerNameFunc)
// OR recv.Method (e.g. p.cli.ContainerInspect).
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "RunningContainerName":
case "RunningContainerNameFunc":
callsRunningContainerName = true
case "ContainerInspect":
callsContainerInspectRaw = true
@@ -155,10 +155,10 @@ func TestProvisionerIsRunning_RoutesThroughRunningContainerName(t *testing.T) {
})
if !callsRunningContainerName {
t.Errorf("Provisioner.IsRunning must call RunningContainerName for the SSOT inspect — see molecule-core#10")
t.Errorf("Provisioner.IsRunning must call RunningContainerNameFunc for the SSOT inspect — see molecule-core#10")
}
if callsContainerInspectRaw {
t.Errorf("Provisioner.IsRunning carries a direct ContainerInspect call; route through RunningContainerName instead")
t.Errorf("Provisioner.IsRunning carries a direct ContainerInspect call; route through RunningContainerNameFunc instead")
}
}
@@ -0,0 +1,55 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
)
// stubSources implements pluginSources for testing ListSources.
type stubSources struct {
schemes []string
}
func (s *stubSources) Register(plugins.SourceResolver) {}
func (s *stubSources) Resolve(plugins.Source) (plugins.SourceResolver, error) {
return nil, nil
}
func (s *stubSources) Schemes() []string { return s.schemes }
// TestPluginListSources_StubSources verifies ListSources delegates to the
// pluginSources interface without touching the filesystem — the stub proves
// the HTTP layer works in isolation from plugins.NewRegistry.
func TestPluginListSources_StubSources(t *testing.T) {
// Build a handler with a stub that returns exactly the schemes we want.
// This is the "isolated unit" form: no temp dir, no registry, no disk.
h := NewPluginsHandler(t.TempDir(), nil, nil)
h.sources = &stubSources{schemes: []string{"clawhub", "enterprise"}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
var body map[string][]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body["schemes"]) != 2 {
t.Errorf("expected 2 schemes, got %v", body["schemes"])
}
seen := map[string]bool{}
for _, s := range body["schemes"] {
seen[s] = true
}
if !seen["clawhub"] || !seen["enterprise"] {
t.Errorf("expected clawhub+enterprise, got %v", body["schemes"])
}
}
@@ -0,0 +1,266 @@
package handlers
import (
"bytes"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
// ---------- broadcastTruncate pure unit tests ----------
func TestBroadcastTruncate_ShortMessage(t *testing.T) {
got := broadcastTruncate("hello", 120)
if got != "hello" {
t.Errorf("expected 'hello', got %q", got)
}
}
func TestBroadcastTruncate_ExactlyMax(t *testing.T) {
got := broadcastTruncate("hello", 5)
if got != "hello" {
t.Errorf("expected 'hello', got %q", got)
}
}
func TestBroadcastTruncate_LongMessage(t *testing.T) {
got := broadcastTruncate("hello world this is a long message that exceeds the limit", 10)
if got != "hello worl…" {
t.Errorf("expected 'hello worl…', got %q", got)
}
}
func TestBroadcastTruncate_Unicode(t *testing.T) {
// "日本語" is 3 runes; truncating to 2 gives "日本…"
got := broadcastTruncate("日本語テスト", 2)
if got != "日本…" {
t.Errorf("expected '日本…', got %q", got)
}
}
// ---------- Broadcast endpoint ----------
// All test IDs are valid UUIDs so they pass validateWorkspaceID.
const (
wsSender = "00000000-0000-0000-0000-000000000001"
wsDNE = "00000000-0000-0000-0000-000000000002"
wsDisabled = "00000000-0000-0000-0000-000000000003"
wsAlone = "00000000-0000-0000-0000-000000000004"
wsR1 = "00000000-0000-0000-0000-000000000011"
wsR2 = "00000000-0000-0000-0000-000000000012"
)
func makeBroadcastHandler(t *testing.T) (*BroadcastHandler, sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
cleanup := func() {
db.DB = prevDB
mockDB.Close()
}
return NewBroadcastHandler(newTestBroadcaster()), mock, cleanup
}
func postBroadcast(t *testing.T, h *BroadcastHandler, workspaceID string, body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+workspaceID+"/broadcast", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Broadcast(c)
return w
}
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
// validateWorkspaceID rejects anything that isn't a valid UUID.
w := postBroadcast(t, h, "not-a-valid-uuid", `{"message":"hello"}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := postBroadcast(t, h, wsSender, `{}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_EmptyMessage(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := postBroadcast(t, h, wsSender, `{"message":""}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_WorkspaceNotFound(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsDNE).
WillReturnError(sql.ErrNoRows)
w := postBroadcast(t, h, wsDNE, `{"message":"hello"}`)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_BroadcastDisabled(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsDisabled).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", false))
w := postBroadcast(t, h, wsDisabled, `{"message":"hello"}`)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_DeliversToTwoRecipients(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients: two workspaces.
// Escape \$1 so sqlmock reads it as a literal (not a regex backreference).
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsR1).AddRow(wsR2))
// Activity log inserts for each recipient (6 args: ws_id, activity_type, method, source_id, summary, status).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR1, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR2, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender's own activity log (5 args: ws_id, activity_type, method, summary, status).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsSender, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsSender, `{"message":"hello world"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_ZeroRecipients(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsAlone).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("alone-ws", true))
// Recipients: no rows.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsAlone).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
// Sender activity log (zero delivered, 5 args).
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsAlone, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsAlone, `{"message":"hello"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_RecipientActivityFailureIsNonFatal(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients: one workspace.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsR1))
// Recipient activity log fails — error is logged but request succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsR1, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnError(sql.ErrConnDone)
// Sender activity log still succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(wsSender, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := postBroadcast(t, h, wsSender, `{"message":"hello"}`)
if w.Code != http.StatusOK {
t.Errorf("expected 200 despite recipient insert failure, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_RecipientsQueryDBError(t *testing.T) {
h, mock, cleanup := makeBroadcastHandler(t)
defer cleanup()
// Sender lookup.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces`).
WithArgs(wsSender).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("sender-ws", true))
// Recipients query fails.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(wsSender).
WillReturnError(sql.ErrConnDone)
w := postBroadcast(t, h, wsSender, `{"message":"hello"}`)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// Ensure BroadcastHandler doesn't read the request body if validation fails early.
func TestBroadcast_MalformedJSON(t *testing.T) {
h, _, cleanup := makeBroadcastHandler(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsSender}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsSender+"/broadcast", bytes.NewReader([]byte(`{not-json`)))
c.Request.Header.Set("Content-Type", "application/json")
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
@@ -1139,7 +1139,7 @@ func (p *Provisioner) IsRunning(ctx context.Context, workspaceID string) (bool,
if p == nil || p.cli == nil {
return false, ErrNoBackend
}
name, err := RunningContainerName(ctx, p.cli, workspaceID)
name, err := RunningContainerNameFunc(ctx, p.cli, workspaceID)
if err != nil {
// Transient daemon error: caller treats !running as dead + restarts.
// Returning true + the underlying error preserves the error for
@@ -0,0 +1,26 @@
package provisioner
import (
"context"
"github.com/docker/docker/client"
)
// RunningContainerNameFunc is the pluggable entry-point for the RunningContainerName
// SSOT. It defaults to the canonical implementation but can be swapped at test
// time via StubRunningContainerName. The plug is at the package level — not the
// struct level — so callers (Provisioner.IsRunning, PluginsHandler.findRunningContainer,
// healthsweep) all hit the same override without each needing its own wiring.
//
// Isolating "testing" to this single file avoids the CGO / undefined-reference
// problem that arises when Docker client types appear in non-test files.
var RunningContainerNameFunc = RunningContainerName
// StubRunningContainerName installs fn as the RunningContainerNameFunc for the
// remainder of the test (or until swapped again). Deferred restore is the caller's
// responsibility.
func StubRunningContainerName(fn func(context.Context, *client.Client, string) (string, error)) func() {
orig := RunningContainerNameFunc
RunningContainerNameFunc = fn
return func() { RunningContainerNameFunc = orig }
}