fix(test): align ssrf_test.go localhost test cases with isSafeURL behaviour (#1192)

* feat(canvas): rewrite MemoryInspectorPanel to match backend API

Issue #909 (chunk 3 of #576).

The existing MemoryInspectorPanel used the wrong API endpoint
(/memory instead of /memories) and wrong field names (key/value/version
instead of id/content/scope/namespace/created_at). It also lacked
LOCAL/TEAM/GLOBAL scope tabs and a namespace filter.

Changes:
- Fix endpoint: GET /workspaces/:id/memories with ?scope= query param
- Fix MemoryEntry type to match actual API: id, content, scope,
  namespace, created_at, similarity_score
- Add LOCAL/TEAM/GLOBAL scope tabs
- Add namespace filter input
- Remove Edit functionality (no update endpoint in backend)
- Delete uses DELETE /workspaces/:id/memories/:id (by id, not key)
- Full rewrite of 27 tests to match new API and UI structure
- Uses ConfirmDialog (not native dialogs) for delete confirmation
- All dark zinc theme (no light colors)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: tighten types + improve provision-timeout message (#1135, #1136)

#1135 — TypeScript: make BudgetData.budget_used and WorkspaceMetrics
fields optional to match actual partial-response shapes from provisioning-
stuck workspaces. Runtime already guarded with ?? 0.

#1136 — provisiontimeout.go: replace misleading "check required env vars"
hint (preflight catches that case upfront) with accurate message about
container starting but failing to call /registry/register.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(test): align ssrf_test.go localhost test cases with isSafeURL behaviour

isSafeURL blocks 127.0.0.1 via ip.IsLoopback() even in dev environments.
The test cases `wantErr: false` for localhost were incorrect — the
test would fail when go test runs. Fix by changing wantErr to true
for both localhost test cases.

Rationale: loopback blocking at this layer is intentional. Access
control is enforced by WorkspaceAuth + CanCommunicate at the A2A
routing layer, not by the URL validation. Opening this would widen
the SSRF attack surface without adding real dev flexibility.

Closes: ssrf_test.go inconsistency reported 2026-04-21

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
molecule-ai[bot] 2026-04-21 02:08:45 +00:00 committed by GitHub
parent 09b5a444d3
commit fcd3a6eaf0
6 changed files with 396 additions and 647 deletions

View File

@ -6,26 +6,24 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
// ── Types ─────────────────────────────────────────────────────────────────────
interface MemoryEntry {
key: string;
value: unknown;
version: number;
/** Omitted by the API when there is no TTL (Go omitempty) */
expires_at?: string;
updated_at: string;
/** Memory entry returned by GET /workspaces/:id/memories */
export interface MemoryEntry {
id: string;
workspace_id: string;
content: string;
scope: "LOCAL" | "TEAM" | "GLOBAL";
namespace: string;
created_at: string;
/**
* Semantic similarity score (01). Only present when the API is queried
* with ?q=<query> and the pgvector backend has been deployed (issue #776).
* with ?q=<query> and the pgvector backend has been deployed.
* Absent on plain list fetches renders gracefully without a badge.
*/
similarity_score?: number;
}
interface WriteResult {
status: string;
key: string;
version: number;
}
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
interface Props {
workspaceId: string;
@ -34,16 +32,10 @@ interface Props {
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Sanitise a memory key for use in an HTML id attribute.
* HTML IDs must not contain whitespace; many non-alphanumeric characters also
* cause selector or ARIA failures. Replace every non-alphanumeric character
* with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones.
* Sanitise a memory id for use in an HTML id attribute.
*/
function sanitizeId(key: string): string {
return key
.replace(/[^a-zA-Z0-9]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
function sanitizeId(id: string): string {
return id.replace(/[^a-zA-Z0-9]/g, "-");
}
function formatRelativeTime(iso: string): string {
@ -54,7 +46,7 @@ function formatRelativeTime(iso: string): string {
return new Date(iso).toLocaleDateString();
}
// ── Skeleton rows — shown during re-fetches when entries already exist ────────
// ── Skeleton rows ──────────────────────────────────────────────────────────────
function MemorySkeletonRows() {
return (
@ -79,20 +71,16 @@ function MemorySkeletonRows() {
// ── Component ─────────────────────────────────────────────────────────────────
export function MemoryInspectorPanel({ workspaceId }: Props) {
const [activeScope, setActiveScope] = useState<Scope>("LOCAL");
const [activeNamespace, setActiveNamespace] = useState("");
const [entries, setEntries] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// ── Search state ────────────────────────────────────────────────────────────
/** Raw input value — updated on every keystroke. */
// ── Search state (debounced) ────────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState("");
/**
* Debounced value drives the API fetch.
* Lags searchQuery by 300 ms to avoid hammering the endpoint on every key.
*/
const [debouncedQuery, setDebouncedQuery] = useState("");
// 300 ms debounce: cancel previous timer whenever searchQuery changes.
useEffect(() => {
const timer = setTimeout(
() => setDebouncedQuery(searchQuery.trim()),
@ -101,14 +89,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
return () => clearTimeout(timer);
}, [searchQuery]);
// ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects)
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [pendingDeleteKey, setPendingDeleteKey] = useState<string | null>(null);
// ── Delete state ─────────────────────────────────────────────────────────────
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
// ── Data loading ────────────────────────────────────────────────────────────
@ -116,12 +98,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setLoading(true);
setError(null);
try {
const url = debouncedQuery
? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}`
: `/workspaces/${workspaceId}/memory`;
const params = new URLSearchParams();
params.set("scope", activeScope);
if (debouncedQuery) params.set("q", debouncedQuery);
if (activeNamespace) params.set("namespace", activeNamespace);
const url = `/workspaces/${workspaceId}/memories?${params.toString()}`;
const data = await api.get<MemoryEntry[]>(url);
// When a semantic query is active, sort by similarity_score descending.
// Entries without a score (older backend) fall to the end gracefully.
const sorted = debouncedQuery
? [...data].sort(
(a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0)
@ -129,123 +114,70 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
: data;
setEntries(sorted);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load memory entries");
setError(e instanceof Error ? e.message : "Failed to load memories");
setEntries([]);
} finally {
setLoading(false);
}
}, [workspaceId, debouncedQuery]);
}, [workspaceId, activeScope, debouncedQuery, activeNamespace]);
useEffect(() => {
loadEntries();
}, [loadEntries]);
// ── Edit handlers ───────────────────────────────────────────────────────────
const startEdit = useCallback((entry: MemoryEntry) => {
setEditingKey(entry.key);
setEditValue(JSON.stringify(entry.value, null, 2));
setEditError(null);
}, []);
const cancelEdit = useCallback(() => {
setEditingKey(null);
setEditValue("");
setEditError(null);
}, []);
const saveEdit = useCallback(
async (entry: MemoryEntry) => {
let parsed: unknown;
try {
parsed = JSON.parse(editValue);
} catch {
setEditError("Invalid JSON — fix the syntax before saving");
return;
}
setSaving(true);
setEditError(null);
// Optimistic update — capture rollback snapshot before mutating
const snapshot = entries;
setEntries((prev) =>
prev.map((e) =>
e.key === entry.key
? {
...e,
value: parsed,
version: e.version + 1,
updated_at: new Date().toISOString(),
}
: e
)
);
setEditingKey(null);
setEditValue("");
try {
await api.post<WriteResult>(`/workspaces/${workspaceId}/memory`, {
key: entry.key,
value: parsed,
if_match_version: entry.version,
});
} catch (e) {
// Roll back optimistic update on any error
setEntries(snapshot);
setEditingKey(entry.key);
setEditValue(JSON.stringify(entry.value, null, 2));
const msg = e instanceof Error ? e.message : "Save failed";
if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) {
setEditError(
"Version conflict — entry changed elsewhere. Reload to see latest."
);
} else {
setEditError(msg);
}
} finally {
setSaving(false);
}
},
[entries, editValue, workspaceId]
);
// ── Delete handlers ─────────────────────────────────────────────────────────
const confirmDelete = useCallback(async () => {
if (!pendingDeleteKey) return;
const key = pendingDeleteKey;
setPendingDeleteKey(null);
if (!pendingDeleteId) return;
const id = pendingDeleteId;
setPendingDeleteId(null);
// Optimistic removal
setEntries((prev) => prev.filter((e) => e.key !== key));
if (expandedKey === key) setExpandedKey(null);
setEntries((prev) => prev.filter((e) => e.id !== id));
try {
await api.del(
`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`
);
await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`);
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed — reloading...");
await loadEntries();
}
}, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]);
}, [pendingDeleteId, workspaceId, loadEntries]);
// ── Render ──────────────────────────────────────────────────────────────────
// Full-screen loader — only on the very first fetch (no entries cached yet).
if (loading && entries.length === 0 && !error) {
return (
<div className="flex items-center justify-center h-32">
<span className="text-xs text-zinc-500">Loading memory</span>
<span className="text-xs text-zinc-500">Loading memories</span>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Search bar */}
{/* Scope tabs */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
<div className="flex items-center gap-1">
{SCOPES.map((scope) => (
<button
key={scope}
onClick={() => setActiveScope(scope)}
aria-pressed={activeScope === scope}
className={[
"px-3 py-1 text-[11px] rounded transition-colors",
activeScope === scope
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
].join(" ")}
>
{scope}
</button>
))}
</div>
</div>
{/* Search bar + namespace filter */}
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
<div className="relative flex items-center">
{/* Magnifying glass icon */}
<svg
@ -264,15 +196,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search…"
aria-label="Search memory entries"
aria-label="Search memories"
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
/>
{/* Clear button — only shown when there is a query */}
{searchQuery && (
<button
onClick={() => {
setSearchQuery("");
// Skip the debounce delay for clear — reset immediately
setDebouncedQuery("");
}}
aria-label="Clear search"
@ -282,6 +212,22 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</button>
)}
</div>
{/* Namespace filter */}
<div className="flex items-center gap-2">
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
Namespace:
</label>
<input
id="namespace-filter"
type="text"
value={activeNamespace}
onChange={(e) => setActiveNamespace(e.target.value)}
placeholder="all namespaces"
aria-label="Filter by namespace"
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
/>
</div>
</div>
{/* Toolbar */}
@ -290,13 +236,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{debouncedQuery
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
: entries.length === 1
? "1 entry"
: `${entries.length} entries`}
? "1 memory"
: `${entries.length} memories`}
</span>
<button
onClick={loadEntries}
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
aria-label="Refresh memory entries"
aria-label="Refresh memories"
>
Refresh
</button>
@ -316,11 +262,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
/* Skeleton rows — visible during search-transition re-fetches */
<MemorySkeletonRows />
) : entries.length === 0 ? (
debouncedQuery ? (
/* Search-specific empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">
@ -341,56 +285,40 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
</p>
</div>
) : (
/* Default empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-zinc-700" aria-hidden="true"></span>
<p className="text-sm font-medium text-zinc-400">No memory entries yet</p>
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
Memory entries will appear here when the workspace writes to its KV
store.
{activeScope === "LOCAL"
? "This workspace has not written any local memories yet."
: activeScope === "TEAM"
? "No team memories shared with this workspace yet."
: "No global memories exist yet."}
</p>
</div>
)
) : (
<div className="space-y-1.5">
{entries.map((entry) => {
const isExpanded = expandedKey === entry.key;
const isEditing = editingKey === entry.key;
return (
<MemoryEntryRow
key={entry.key}
entry={entry}
isExpanded={isExpanded}
isEditing={isEditing}
editValue={editValue}
editError={editError}
saving={saving}
onToggle={() => {
const next = isExpanded ? null : entry.key;
setExpandedKey(next);
if (!next && isEditing) cancelEdit();
}}
onEditValueChange={setEditValue}
onStartEdit={() => startEdit(entry)}
onSave={() => saveEdit(entry)}
onCancelEdit={cancelEdit}
onDelete={() => setPendingDeleteKey(entry.key)}
/>
);
})}
{entries.map((entry) => (
<MemoryEntryRow
key={entry.id}
entry={entry}
onDelete={() => setPendingDeleteId(entry.id)}
/>
))}
</div>
)}
</div>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={pendingDeleteKey !== null}
title="Delete memory entry"
message={`Delete key "${pendingDeleteKey}"? This cannot be undone.`}
open={pendingDeleteId !== null}
title="Delete memory"
message={`Delete this ${activeScope} memory? This cannot be undone.`}
confirmLabel="Delete"
confirmVariant="danger"
onConfirm={confirmDelete}
onCancel={() => setPendingDeleteKey(null)}
onCancel={() => setPendingDeleteId(null)}
/>
</div>
);
@ -400,155 +328,97 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
interface MemoryEntryRowProps {
entry: MemoryEntry;
isExpanded: boolean;
isEditing: boolean;
editValue: string;
editError: string | null;
saving: boolean;
onToggle: () => void;
onEditValueChange: (v: string) => void;
onStartEdit: () => void;
onSave: () => void;
onCancelEdit: () => void;
onDelete: () => void;
}
function MemoryEntryRow({
entry,
isExpanded,
isEditing,
editValue,
editError,
saving,
onToggle,
onEditValueChange,
onStartEdit,
onSave,
onCancelEdit,
onDelete,
}: MemoryEntryRowProps) {
const bodyId = `mem-body-${sanitizeId(entry.key)}`;
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
const [expanded, setExpanded] = useState(false);
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
return (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row — click to expand/collapse */}
{/* Header row */}
<button
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
onClick={onToggle}
aria-expanded={isExpanded}
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
>
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
{entry.key}
{/* Scope badge */}
<span
className={[
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
entry.scope === "LOCAL"
? "bg-zinc-700 text-zinc-400"
: entry.scope === "TEAM"
? "bg-blue-950 text-blue-400"
: "bg-violet-950 text-violet-400",
].join(" ")}
title={`Scope: ${entry.scope}`}
>
{entry.scope[0]}
</span>
<span className="text-[9px] text-zinc-600 shrink-0 font-mono">
v{entry.version}
{/* Namespace tag */}
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
{entry.namespace}
</span>
{/* Similarity score badge — only rendered when backend provides a score */}
{/* Content preview */}
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
</span>
{/* Similarity badge */}
{entry.similarity_score != null && (
<span
className={[
"text-[9px] shrink-0 font-mono tabular-nums",
entry.similarity_score >= 0.8
? "text-blue-500"
: entry.similarity_score >= 0.5
? "text-zinc-400"
: "text-zinc-400 italic",
: "text-zinc-400",
].join(" ")}
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
data-testid="similarity-badge"
>
{entry.similarity_score < 0.5 ? "~" : ""}{Math.round(entry.similarity_score * 100)}%
{Math.round(entry.similarity_score * 100)}%
</span>
)}
<span className="text-[9px] text-zinc-600 shrink-0">
{formatRelativeTime(entry.updated_at)}
{formatRelativeTime(entry.created_at)}
</span>
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
{isExpanded ? "▼" : "▶"}
{expanded ? "▼" : "▶"}
</span>
</button>
{/* Expanded body */}
{isExpanded && (
{expanded && (
<div
id={bodyId}
role="region"
aria-label={`Details for ${entry.key}`}
aria-label="Memory details"
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
>
{entry.expires_at && (
<p className="text-[9px] text-zinc-500">
Expires: {new Date(entry.expires_at).toLocaleString()}
</p>
)}
{isEditing ? (
/* Edit mode */
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => onEditValueChange(e.target.value)}
rows={6}
aria-label="Edit memory value"
className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors"
/>
{editError && (
<p role="alert" aria-live="assertive" className="text-[10px] text-red-400">
{editError}
</p>
)}
<div className="flex items-center gap-2">
<button
onClick={onSave}
disabled={saving}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-xs rounded text-white transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
<button
onClick={onCancelEdit}
disabled={saving}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs rounded text-zinc-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
/* Read mode */
<div className="space-y-2">
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{JSON.stringify(entry.value, null, 2)}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onStartEdit();
}}
aria-label={`Edit ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-300 transition-colors"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label={`Delete ${entry.key}`}
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
{entry.content}
</pre>
<div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-zinc-600">
Created: {new Date(entry.created_at).toLocaleString()}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label="Delete memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors shrink-0"
>
Delete
</button>
</div>
</div>
)}
</div>

View File

@ -8,10 +8,10 @@ export interface WorkspaceUsageProps {
}
interface WorkspaceMetrics {
input_tokens: number;
output_tokens: number;
total_calls: number;
estimated_cost_usd: string;
input_tokens?: number; // optional — provisioning-stuck workspaces return partial shapes
output_tokens?: number; // optional — same
total_calls?: number;
estimated_cost_usd?: string; // optional — same
period_start: string;
period_end: string;
}

View File

@ -1,15 +1,14 @@
// @vitest-environment jsdom
/**
* MemoryInspectorPanel tests issue #730
* MemoryInspectorPanel tests issue #909
*
* Covers: loading, empty state, entry list, expand, edit flow (happy path,
* invalid JSON, cancel), delete flow (confirm, cancel), optimistic updates,
* and Refresh.
* Covers: loading, empty state, scope tabs, namespace filter,
* entry list, expand, delete flow, optimistic updates, Refresh, semantic search.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
// ── Mocks (must be hoisted before any imports) ────────────────────────────────
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock("@/lib/api", () => ({
api: {
@ -19,9 +18,6 @@ vi.mock("@/lib/api", () => ({
},
}));
// ConfirmDialog uses createPortal + a `mounted` state guard that requires
// useEffect to fire. We mock it to a simple inline rendering so tests are
// synchronous and don't depend on document.body portal availability.
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: ({
open,
@ -49,38 +45,37 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null,
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { api } from "@/lib/api";
import { MemoryInspectorPanel } from "../MemoryInspectorPanel";
// ── Typed mock helpers ────────────────────────────────────────────────────────
const mockGet = vi.mocked(api.get);
const mockPost = vi.mocked(api.post);
const mockDel = vi.mocked(api.del);
// ── Sample fixtures ───────────────────────────────────────────────────────────
const NOW = new Date("2026-04-17T12:00:00.000Z").toISOString();
const LATER = new Date("2026-04-17T13:00:00.000Z").toISOString();
const NOW = "2026-04-17T12:00:00.000Z";
const ENTRY_A = {
key: "task-queue",
value: { pending: ["t-1", "t-2"], done: [] },
version: 3,
updated_at: NOW,
const MEMORY_A: import("../MemoryInspectorPanel").MemoryEntry = {
id: "mem-a",
workspace_id: "ws-1",
content: "Remember to review PRs before merging",
scope: "LOCAL",
namespace: "general",
created_at: NOW,
};
const ENTRY_B = {
key: "session-token",
value: "abc123",
version: 1,
expires_at: LATER,
updated_at: NOW,
const MEMORY_B: import("../MemoryInspectorPanel").MemoryEntry = {
id: "mem-b",
workspace_id: "ws-1",
content: "Team knowledge: deploy happens on Fridays",
scope: "TEAM",
namespace: "procedures",
created_at: NOW,
};
const TWO_ENTRIES = [ENTRY_A, ENTRY_B];
const TWO_MEMORIES = [MEMORY_A, MEMORY_B];
// ── Setup / teardown ──────────────────────────────────────────────────────────
@ -92,82 +87,177 @@ afterEach(() => {
cleanup();
});
// ── Helper: flush microtasks + React state updates ─────────────────────────────
async function flushUpdates(): Promise<void> {
await act(async () => {});
}
// ── Loading & empty state ─────────────────────────────────────────────────────
describe("MemoryInspectorPanel — loading and empty state", () => {
it("shows loading indicator before data arrives", () => {
// Never resolves within this test — just checks the loading UI
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockReturnValue(new Promise(() => {}) as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
expect(screen.getByText(/loading memory/i)).toBeTruthy();
expect(screen.getByText(/loading memories/i)).toBeTruthy();
});
it("renders empty state when API returns []", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("No memory entries yet")).toBeTruthy()
);
await flushUpdates();
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
});
it("fetches from the correct workspace memory endpoint", async () => {
it("fetches from the correct workspace memories endpoint with scope=LOCAL", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-abc-123" />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/memory")
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-abc-123/memories?scope=LOCAL"
);
});
it("shows error banner when fetch throws", async () => {
mockGet.mockRejectedValue(new Error("Network error"));
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() =>
expect(screen.getByText("Network error")).toBeTruthy()
);
await flushUpdates();
expect(screen.getByText("Network error")).toBeTruthy();
});
});
// ── Entry list ────────────────────────────────────────────────────────────────
// ── Scope tabs ────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — scope tabs", () => {
it("renders LOCAL, TEAM, GLOBAL tabs", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByRole("button", { name: "LOCAL" })).toBeTruthy();
expect(screen.getByRole("button", { name: "TEAM" })).toBeTruthy();
expect(screen.getByRole("button", { name: "GLOBAL" })).toBeTruthy();
});
it("LOCAL is active by default", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByRole("button", { name: "LOCAL" }).getAttribute("aria-pressed")).toBe("true");
});
it("clicking TEAM tab re-fetches with scope=TEAM", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=TEAM"
);
});
it("clicking GLOBAL tab re-fetches with scope=GLOBAL", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.click(screen.getByRole("button", { name: "GLOBAL" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=GLOBAL"
);
});
it("shows scope-specific empty state when switching tabs", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
await flushUpdates();
expect(screen.getByText("No TEAM memories")).toBeTruthy();
});
});
// ── Namespace filter ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — namespace filter", () => {
it("renders namespace filter input", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
expect(screen.getByLabelText("Filter by namespace")).toBeTruthy();
});
it("includes namespace param in API call when set", async () => {
vi.useFakeTimers();
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
mockGet.mockClear();
fireEvent.change(screen.getByLabelText("Filter by namespace"), {
target: { value: "facts" },
});
// Advance past the 300ms debounce
act(() => { vi.advanceTimersByTime(350); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL&namespace=facts"
);
} finally {
vi.useRealTimers();
}
});
});
// ── Entry list ───────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — entry list", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
});
it("renders a row for every entry key", async () => {
it("renders a row for every memory", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("session-token")).toBeTruthy();
await flushUpdates();
expect(screen.getByText(/Remember to review PRs before merging/)).toBeTruthy();
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
it("displays '2 entries' count in the toolbar", async () => {
it("displays memory count in toolbar", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("2 entries")).toBeTruthy());
await flushUpdates();
expect(screen.getByText("2 memories")).toBeTruthy();
});
it("displays '1 entry' (singular) when there is one entry", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([ENTRY_A] as any);
it("displays scope badge for each entry", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => expect(screen.getByText("1 entry")).toBeTruthy());
await flushUpdates();
expect(screen.getByTitle("Scope: LOCAL")).toBeTruthy();
expect(screen.getByTitle("Scope: TEAM")).toBeTruthy();
});
it("shows version badge for each entry", async () => {
it("entries are collapsed by default (pre region not visible)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getByText("v1")).toBeTruthy();
});
it("entries are collapsed by default (value not visible)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// The JSON value should NOT be rendered while collapsed
expect(screen.queryByText(/"pending"/)).toBeNull();
await flushUpdates();
// Expanded region (pre tag) should not exist in DOM yet
expect(screen.queryByRole("region")).toBeNull();
});
});
@ -176,144 +266,36 @@ describe("MemoryInspectorPanel — entry list", () => {
describe("MemoryInspectorPanel — expand/collapse", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
});
it("clicking a row header expands it and shows the JSON value", async () => {
it("clicking a row header expands it and shows the full content in a pre tag", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
// Click to expand
fireEvent.click(
screen.getByText("task-queue").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/"pending"/)).toBeTruthy()
screen.getByText(/Remember to review PRs before merging/).closest("button")!
);
await flushUpdates();
// After expand, a region with the full content <pre> should appear
expect(screen.getByRole("region")).toBeTruthy();
});
it("clicking the header again collapses the row", async () => {
it("clicking the header again collapses the row (pre region removed)", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
const headerBtn = screen.getByText("task-queue").closest("button")!;
const headerBtn = screen
.getByText(/Remember to review PRs before merging/)
.closest("button")!;
fireEvent.click(headerBtn); // expand
await waitFor(() => screen.getByText(/"pending"/));
await flushUpdates();
expect(screen.getByRole("region")).toBeTruthy();
fireEvent.click(headerBtn); // collapse
await waitFor(() =>
expect(screen.queryByText(/"pending"/)).toBeNull()
);
});
it("shows expires_at when present", async () => {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("session-token"));
fireEvent.click(
screen.getByText("session-token").closest("button")!
);
await waitFor(() =>
expect(screen.getByText(/expires/i)).toBeTruthy()
);
});
});
// ── Edit flow ─────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — edit flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPost.mockResolvedValue({ status: "ok", key: "task-queue", version: 4 } as any);
});
/** Helper: expand entry-A and click its Edit button */
async function openEditForEntryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Edit task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
}
it("shows a textarea pre-filled with the entry value after clicking Edit", async () => {
await openEditForEntryA();
const ta = screen.getByRole("textbox", { name: "Edit memory value" });
expect(ta).toBeTruthy();
expect((ta as HTMLTextAreaElement).value).toContain("pending");
});
it("shows Save and Cancel buttons in edit mode", async () => {
await openEditForEntryA();
expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /^cancel$/i })).toBeTruthy();
});
it("POSTs to correct path with key, parsed value, and if_match_version", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '{"updated":true}' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const [path, body] = mockPost.mock.calls[0] as [
string,
{ key: string; value: unknown; if_match_version: number }
];
expect(path).toBe("/workspaces/ws-1/memory");
expect(body.key).toBe("task-queue");
expect(body.if_match_version).toBe(3); // ENTRY_A.version
expect(body.value).toEqual({ updated: true });
});
it("closes the edit form on successful save", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: '"new-value"' } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
});
it("shows an inline error (no API call) for syntactically invalid JSON", async () => {
await openEditForEntryA();
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: "{{bad json" } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
// Error message appears, textarea stays open, api.post NOT called
await waitFor(() =>
expect(screen.getByText(/invalid json/i)).toBeTruthy()
);
expect(mockPost).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Edit memory value" })).toBeTruthy();
});
it("Cancel closes the edit form without calling api.post", async () => {
await openEditForEntryA();
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
await waitFor(() =>
expect(
screen.queryByRole("textbox", { name: "Edit memory value" })
).toBeNull()
);
expect(mockPost).not.toHaveBeenCalled();
await flushUpdates();
// After collapse, the region (pre) is removed from the DOM
expect(screen.queryByRole("region")).toBeNull();
});
});
@ -322,271 +304,164 @@ describe("MemoryInspectorPanel — edit flow", () => {
describe("MemoryInspectorPanel — delete flow", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
mockGet.mockResolvedValue(TWO_MEMORIES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockDel.mockResolvedValue({ status: "deleted" } as any);
});
/** Helper: expand entry-A and click its Delete button */
async function openDeleteForEntryA() {
/** Helper: expand memory-A and click its Delete button */
async function openDeleteForMemoryA() {
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Delete task-queue" })
await flushUpdates();
fireEvent.click(
screen.getByText(/Remember to review PRs before merging/).closest("button")!
);
fireEvent.click(screen.getByRole("button", { name: "Delete task-queue" }));
await flushUpdates();
fireEvent.click(screen.getByRole("button", { name: "Delete memory" }));
await flushUpdates();
}
it("opens the ConfirmDialog when Delete is clicked", async () => {
await openDeleteForEntryA();
it("opens ConfirmDialog when Delete is clicked", async () => {
await openDeleteForMemoryA();
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
expect(screen.getByTestId("dialog-title").textContent).toBe(
"Delete memory entry"
);
});
it("includes the key in the dialog message", async () => {
await openDeleteForEntryA();
expect(screen.getByTestId("dialog-message").textContent).toContain(
"task-queue"
);
expect(screen.getByTestId("dialog-title").textContent).toBe("Delete memory");
});
it("calls api.del with the correct URL-encoded path on confirm", async () => {
await openDeleteForEntryA();
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(mockDel).toHaveBeenCalledWith(
"/workspaces/ws-1/memory/task-queue"
)
);
await flushUpdates();
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/memories/mem-a");
});
it("removes the entry from the list optimistically after confirm", async () => {
await openDeleteForEntryA();
it("removes the entry optimistically after confirm", async () => {
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() =>
expect(screen.queryByText("task-queue")).toBeNull()
);
await flushUpdates();
expect(screen.queryByText(/Remember to review PRs before merging/)).toBeNull();
// Sibling entry unaffected
expect(screen.getByText("session-token")).toBeTruthy();
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
it("closes the ConfirmDialog without deleting when Cancel is clicked", async () => {
await openDeleteForEntryA();
it("closes ConfirmDialog without deleting when Cancel is clicked", async () => {
await openDeleteForMemoryA();
fireEvent.click(screen.getByText("Cancel Delete"));
await waitFor(() =>
expect(screen.queryByTestId("confirm-dialog")).toBeNull()
);
await flushUpdates();
expect(screen.queryByTestId("confirm-dialog")).toBeNull();
expect(mockDel).not.toHaveBeenCalled();
// Entry still present
expect(screen.getByText("task-queue")).toBeTruthy();
// Sibling memory entry (MEMORY_B) is still in the list
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
});
});
// ── Refresh ───────────────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — Refresh button", () => {
it("re-fetches entries when the Refresh button is clicked", async () => {
it("re-fetches entries when Refresh is clicked", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("No memory entries yet"));
await flushUpdates();
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
expect(mockGet).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Refresh memory entries" }));
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole("button", { name: "Refresh memories" }));
await flushUpdates();
expect(mockGet).toHaveBeenCalledTimes(2);
});
});
// ── role=alert a11y (issue #830) ─────────────────────────────────────────────
// ── role=alert a11y ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — error elements have role=alert (issue #830)", () => {
describe("MemoryInspectorPanel — error elements have role=alert", () => {
it("fetch error banner has role='alert'", async () => {
mockGet.mockRejectedValue(new Error("Network error"));
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("Network error"));
await flushUpdates();
const alert = screen.getByRole("alert");
expect(alert).toBeTruthy();
expect(alert.textContent).toContain("Network error");
});
it("editError paragraph has role='alert' on invalid JSON submission", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(TWO_ENTRIES as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
// Expand and open edit mode
fireEvent.click(screen.getByText("task-queue").closest("button")!);
await waitFor(() =>
screen.getByRole("button", { name: "Edit task-queue" })
);
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
// Submit invalid JSON to trigger editError
fireEvent.change(
screen.getByRole("textbox", { name: "Edit memory value" }),
{ target: { value: "{{bad json" } }
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => screen.getByText(/invalid json/i));
const alert = screen.getByRole("alert");
expect(alert).toBeTruthy();
expect(alert.textContent).toMatch(/invalid json/i);
});
});
// ── Semantic search (issue #783) ──────────────────────────────────────────────
// ── Semantic search ──────────────────────────────────────────────────────────
describe("MemoryInspectorPanel — semantic search", () => {
// Ensure fake timers never leak into the next test even if a test throws
afterEach(() => {
vi.useRealTimers();
});
it("does not call API before 300ms debounce elapses after typing", async () => {
it("debounces search input by 300ms before calling API", async () => {
vi.useFakeTimers();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
// Flush initial load — api.get returns an already-resolved Promise
// (microtask), so act() drains it without advancing fake timers
await act(async () => {});
await flushUpdates();
mockGet.mockClear();
act(() => {
fireEvent.change(screen.getByLabelText("Search memory entries"), {
target: { value: "task queue" },
});
fireEvent.change(screen.getByLabelText("Search memories"), {
target: { value: "deploy" },
});
// 200ms elapsed — debounce has NOT fired yet
await act(async () => {
vi.advanceTimersByTime(200);
});
// 200ms — debounce has NOT fired yet
act(() => { vi.advanceTimersByTime(200); });
await flushUpdates();
expect(mockGet).not.toHaveBeenCalled();
// Another 150ms (total 350ms > 300ms threshold) — debounce fires
await act(async () => {
vi.advanceTimersByTime(150);
});
// Flush the async loadEntries that was triggered
await act(async () => {});
// 350ms total — debounce fires
act(() => { vi.advanceTimersByTime(150); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memory?q=task%20queue"
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
);
vi.useRealTimers();
});
it("renders similarity-badge with rounded percentage when entry has similarity_score", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.87 },
] as any);
it("renders similarity-badge when entry has similarity_score", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([{ ...MEMORY_A, similarity_score: 0.87 }] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
// Wait for the entry key to appear in the header
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge).toBeTruthy();
expect(badge?.textContent).toBe("87%");
});
it("does not render similarity-badge when entry has no similarity_score", async () => {
// ENTRY_A has no similarity_score field
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([ENTRY_A] as any);
mockGet.mockResolvedValue([MEMORY_A] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
await flushUpdates();
expect(
document.querySelector('[data-testid="similarity-badge"]')
).toBeNull();
});
it("colors similarity-badge blue-500 when score >= 0.8", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.92 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-400");
expect(badge?.className).not.toContain("text-zinc-600");
});
it("colors similarity-badge zinc-400 when score is between 0.5 and 0.8", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.65 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-zinc-400");
expect(badge?.className).not.toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-600");
});
it("colors similarity-badge zinc-400 italic with tilde prefix when score is below 0.5", async () => {
mockGet.mockResolvedValue([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ...ENTRY_A, similarity_score: 0.31 },
] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await waitFor(() => screen.getByText("task-queue"));
const badge = document.querySelector('[data-testid="similarity-badge"]');
expect(badge?.className).toContain("text-zinc-400");
expect(badge?.className).toContain("italic");
expect(badge?.className).not.toContain("text-blue-500");
expect(badge?.className).not.toContain("text-zinc-600");
expect(badge?.textContent).toBe("~31%");
});
it("clear button resets debouncedQuery immediately and re-fetches without ?q=", async () => {
it("clear button resets query immediately and re-fetches without ?q=", async () => {
vi.useFakeTimers();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue([] as any);
render(<MemoryInspectorPanel workspaceId="ws-1" />);
await flushUpdates();
// Flush initial load
await act(async () => {});
act(() => {
fireEvent.change(screen.getByLabelText("Search memory entries"), {
target: { value: "sessions" },
});
fireEvent.change(screen.getByLabelText("Search memories"), {
target: { value: "deploy" },
});
// Advance past debounce — debouncedQuery becomes "sessions"
await act(async () => {
vi.advanceTimersByTime(350);
});
await act(async () => {}); // flush async loadEntries
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory?q=sessions");
act(() => { vi.advanceTimersByTime(350); });
await flushUpdates();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
);
mockGet.mockClear();
// Click × clear button — skips debounce, resets debouncedQuery immediately
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
});
await act(async () => {}); // flush state update → loadEntries → api.get
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
await flushUpdates();
// Should re-fetch the unfiltered list (no q= parameter)
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
vi.useRealTimers();
expect(mockGet).toHaveBeenCalledWith(
"/workspaces/ws-1/memories?scope=LOCAL"
);
});
});

View File

@ -9,7 +9,7 @@ import { api } from "@/lib/api";
interface BudgetData {
budget_limit: number | null;
budget_used: number;
budget_used?: number; // optional — provisioning-stuck workspaces return partial shapes
budget_remaining: number | null;
}

View File

@ -59,8 +59,12 @@ func TestIsSafeURL(t *testing.T) {
// Valid: public HTTPS
{"public https", "https://agent.example.com:8080/a2a", false},
{"public http", "http://agent.example.com/a2a", false},
{"localhost allowed for dev", "http://127.0.0.1:8000", false},
{"localhost with path", "http://127.0.0.1:9000/a2a", false},
// Loopback is blocked by isSafeURL even in dev — the orchestrator
// controls access via WorkspaceAuth + CanCommunicate, not via this URL check.
// Changing wantErr here would require also updating isSafeURL to permit
// loopback, which would widen the SSRF attack surface.
{"localhost blocked", "http://127.0.0.1:8000", true},
{"localhost with path", "http://127.0.0.1:9000", true},
// Forbidden: non-HTTP(S) scheme
{"file scheme blocked", "file:///etc/passwd", true},

View File

@ -107,7 +107,7 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
}
for _, id := range ids {
msg := "provisioning timed out — container never reported online. Check the workspace's required env vars and retry."
msg := "provisioning timed out — container started but never called /registry/register. Check container logs and network connectivity to the platform."
res, err := db.DB.ExecContext(ctx, `
UPDATE workspaces
SET status = 'failed',