426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
809 lines
24 KiB
TypeScript
809 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
/**
|
|
* Behavioral e2e for the Vector Browser GUI view (list mode + 2D tab + error
|
|
* states + every interactive control).
|
|
*
|
|
* What is real vs mocked:
|
|
* - REAL: the parser/layout module @elizaos/ui/components/pages/vector-browser-utils
|
|
* (rowToMemory / parseEmbedding / parseContent / buildVectorGraph2DLayout /
|
|
* PAGE_SIZE / DIM_COLUMNS). The view's own state machine and rendering are
|
|
* exercised against the real parser over real-shaped query rows.
|
|
* - MOCKED: the @elizaos/ui glue subpaths the view imports (api client,
|
|
* layout/panel/skeleton/primitive wrappers, useApp/useAgentElement/
|
|
* useRenderGuard/getBootConfig, MemoryDetailPanel). The mocks are thin
|
|
* passthroughs; MemoryDetailPanel renders the populated fields the view is
|
|
* contractually expected to surface so detail assertions are meaningful.
|
|
*
|
|
* The mocked api `client.executeDatabaseQuery` branches on the SQL the view
|
|
* actually emits (COUNT(*), information_schema.columns, the memories+embeddings
|
|
* JOIN, the `unique` COUNT), returning real-shaped QueryResult rows — including
|
|
* dim_768 pgvector ::text embeddings so the populated graph/stat/badge paths
|
|
* (never reachable in the packages/app smoke fixture) are covered here.
|
|
*
|
|
* This renders the adaptive `VectorBrowserView` (the single componentExport): a
|
|
* `SpatialSurface` + `Escape` wrapper that mounts the rich
|
|
* `VectorBrowserRichView` as the DOM child, so these assertions cover the
|
|
* wrapper and the rich surface together.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Translation helper: resolve to defaultValue when given, else the key ─────
|
|
type TVars = Record<string, unknown> | undefined;
|
|
function translate(key: string, vars?: TVars): string {
|
|
if (vars && typeof vars.defaultValue === "string") {
|
|
let out = vars.defaultValue;
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
if (k === "defaultValue") continue;
|
|
out = out.replace(new RegExp(`{{${k}}}`, "g"), String(v));
|
|
}
|
|
return out;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
type MockAppState = {
|
|
t: typeof translate;
|
|
};
|
|
|
|
// ── Query backend driven by SQL pattern ─────────────────────────────────────
|
|
type QueryResult = {
|
|
columns: string[];
|
|
rows: Record<string, unknown>[];
|
|
rowCount: number;
|
|
durationMs: number;
|
|
};
|
|
|
|
const DIM_COLS = [
|
|
"dim_384",
|
|
"dim_512",
|
|
"dim_768",
|
|
"dim_1024",
|
|
"dim_1536",
|
|
"dim_3072",
|
|
] as const;
|
|
|
|
function pgvector(dim: number, seed: number): string {
|
|
const parts: number[] = [];
|
|
for (let i = 0; i < dim; i += 1) {
|
|
parts.push(Number(((seed + 1) * 0.013 + i * 0.0007).toFixed(6)));
|
|
}
|
|
return `[${parts.join(",")}]`;
|
|
}
|
|
|
|
function memoryRow(opts: {
|
|
id: string;
|
|
text: string;
|
|
type: string;
|
|
withEmbedding: boolean;
|
|
seed: number;
|
|
}): Record<string, unknown> {
|
|
const row: Record<string, unknown> = {
|
|
id: opts.id,
|
|
content: JSON.stringify({ text: opts.text }),
|
|
type: opts.type,
|
|
room_id: `room-${opts.seed}`,
|
|
entity_id: `entity-${opts.seed}`,
|
|
created_at: "2026-06-16T10:30:00.000Z",
|
|
unique: true,
|
|
};
|
|
for (const col of DIM_COLS) {
|
|
row[col] =
|
|
col === "dim_768" && opts.withEmbedding ? pgvector(768, opts.seed) : null;
|
|
}
|
|
return row;
|
|
}
|
|
|
|
/** Configurable backend state per test. */
|
|
const backend = vi.hoisted(() => ({
|
|
tables: [] as Array<{
|
|
name: string;
|
|
schema: string;
|
|
rowCount: number;
|
|
columns: unknown[];
|
|
}>,
|
|
total: 0,
|
|
uniqueCount: 0,
|
|
/** Returns the page of memory rows for the given offset + search term. */
|
|
memoryRows: (
|
|
_offset: number,
|
|
_search: string,
|
|
): Record<string, unknown>[] => [],
|
|
/** Rows returned for graph view (INNER JOIN). */
|
|
graphRows: (): Record<string, unknown>[] => [],
|
|
tablesError: null as Error | null,
|
|
queryError: null as Error | null,
|
|
calls: [] as string[],
|
|
}));
|
|
|
|
function table(name: string) {
|
|
return { name, schema: "public", rowCount: backend.total, columns: [] };
|
|
}
|
|
|
|
function qr(rows: Record<string, unknown>[]): QueryResult {
|
|
return {
|
|
columns: rows.length ? Object.keys(rows[0]) : [],
|
|
rows,
|
|
rowCount: rows.length,
|
|
durationMs: 1,
|
|
};
|
|
}
|
|
|
|
const getDatabaseTables = vi.fn(async () => {
|
|
if (backend.tablesError) throw backend.tablesError;
|
|
return { tables: backend.tables };
|
|
});
|
|
|
|
const executeDatabaseQuery = vi.fn(
|
|
async (sql: string): Promise<QueryResult> => {
|
|
backend.calls.push(sql);
|
|
if (backend.queryError) throw backend.queryError;
|
|
|
|
if (/SELECT COUNT\(\*\) as cnt/i.test(sql) && /"unique"/i.test(sql)) {
|
|
return qr([{ cnt: backend.uniqueCount }]);
|
|
}
|
|
if (/SELECT COUNT\(\*\) as cnt/i.test(sql)) {
|
|
return qr([{ cnt: backend.total }]);
|
|
}
|
|
if (/information_schema\.columns/i.test(sql)) {
|
|
return qr([
|
|
{ column_name: "id", data_type: "uuid" },
|
|
{ column_name: "content", data_type: "jsonb" },
|
|
]);
|
|
}
|
|
if (/INNER JOIN "embeddings"/i.test(sql)) {
|
|
return qr(backend.graphRows());
|
|
}
|
|
// The list LEFT JOIN / plain SELECT path.
|
|
const offsetMatch = sql.match(/OFFSET (\d+)/i);
|
|
const offset = offsetMatch ? Number(offsetMatch[1]) : 0;
|
|
const likeMatch = sql.match(/LIKE '%([^%]*)%'/i);
|
|
const search = likeMatch ? likeMatch[1] : "";
|
|
return qr(backend.memoryRows(offset, search));
|
|
},
|
|
);
|
|
|
|
vi.mock("@elizaos/ui/api", () => ({
|
|
client: { getDatabaseTables, executeDatabaseQuery },
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/state", () => {
|
|
const state: MockAppState = { t: translate };
|
|
return {
|
|
useApp: () => state,
|
|
useAppSelector: <T,>(selector: (state: MockAppState) => T) =>
|
|
selector(state),
|
|
};
|
|
});
|
|
|
|
vi.mock("@elizaos/ui/hooks", () => ({
|
|
useRenderGuard: () => {},
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/config", () => ({
|
|
getBootConfig: () => ({
|
|
companionVectorBrowser: {
|
|
THREE: {},
|
|
createVectorBrowserRenderer: async () => {
|
|
throw new Error("no renderer in jsdom");
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/agent-surface", () => ({
|
|
useAgentElement: () => ({ ref: { current: null }, agentProps: {} }),
|
|
}));
|
|
|
|
// Thin passthrough layout / panel / skeleton.
|
|
vi.mock("@elizaos/ui/layouts", () => ({
|
|
WorkspaceLayout: ({ children }: { children: React.ReactNode }) => (
|
|
<div data-testid="workspace-layout">{children}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/components/composites/page-panel", () => ({
|
|
PagePanel: ({ children }: { children: React.ReactNode }) => (
|
|
<div data-testid="page-panel">{children}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/components/ui/skeleton-layouts", () => ({
|
|
ListSkeleton: () => <div data-testid="list-skeleton" />,
|
|
}));
|
|
|
|
// Primitive shims — Button/Input render real DOM; Select mirrors the radix
|
|
// onValueChange contract via a native <select> so we can drive it in jsdom.
|
|
vi.mock("@elizaos/ui/components/ui/button", () => ({
|
|
Button: ({
|
|
children,
|
|
onClick,
|
|
disabled,
|
|
type,
|
|
...rest
|
|
}: {
|
|
children?: React.ReactNode;
|
|
onClick?: () => void;
|
|
disabled?: boolean;
|
|
type?: "button" | "submit";
|
|
} & Record<string, unknown>) => {
|
|
const {
|
|
ref: _ref,
|
|
agentProps: _ap,
|
|
...domProps
|
|
} = rest as Record<string, unknown>;
|
|
return (
|
|
<button
|
|
type={type ?? "button"}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
{...(domProps as Record<string, unknown>)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/components/ui/input", () => ({
|
|
Input: ({
|
|
value,
|
|
onChange,
|
|
onKeyDown,
|
|
placeholder,
|
|
...rest
|
|
}: {
|
|
value?: string;
|
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
|
placeholder?: string;
|
|
} & Record<string, unknown>) => {
|
|
const {
|
|
ref: _ref,
|
|
agentProps: _ap,
|
|
...domProps
|
|
} = rest as Record<string, unknown>;
|
|
return (
|
|
<input
|
|
value={value}
|
|
onChange={onChange}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={placeholder}
|
|
{...(domProps as Record<string, unknown>)}
|
|
/>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@elizaos/ui/components/ui/select", () => ({
|
|
Select: ({
|
|
value,
|
|
onValueChange,
|
|
children,
|
|
}: {
|
|
value?: string;
|
|
onValueChange?: (v: string) => void;
|
|
children?: React.ReactNode;
|
|
}) => (
|
|
<select
|
|
data-testid="table-select"
|
|
value={value}
|
|
onChange={(e) => onValueChange?.(e.target.value)}
|
|
>
|
|
{children}
|
|
</select>
|
|
),
|
|
SelectContent: ({ children }: { children?: React.ReactNode }) => (
|
|
<>{children}</>
|
|
),
|
|
SelectItem: ({
|
|
value,
|
|
children,
|
|
}: {
|
|
value: string;
|
|
children?: React.ReactNode;
|
|
}) => <option value={value}>{children}</option>,
|
|
SelectTrigger: () => null,
|
|
SelectValue: () => null,
|
|
}));
|
|
|
|
// Faithful MemoryDetailPanel: renders the populated fields the real panel shows.
|
|
vi.mock("@elizaos/ui/components/pages/MemoryDetailPanel", () => ({
|
|
MemoryDetailPanel: ({
|
|
memory,
|
|
}: {
|
|
memory: {
|
|
id: string;
|
|
content: string;
|
|
type: string;
|
|
roomId: string;
|
|
entityId: string;
|
|
embedding: number[] | null;
|
|
} | null;
|
|
}) => {
|
|
if (!memory) {
|
|
return <div data-testid="detail-empty">Select a memory...</div>;
|
|
}
|
|
return (
|
|
<div data-testid="detail-panel">
|
|
<div data-testid="detail-content">{memory.content || "(empty)"}</div>
|
|
<div data-testid="detail-id">{memory.id}</div>
|
|
<div data-testid="detail-type">{memory.type}</div>
|
|
<div data-testid="detail-room">{memory.roomId}</div>
|
|
<div data-testid="detail-entity">{memory.entityId}</div>
|
|
{memory.embedding ? (
|
|
<div data-testid="detail-embedding">
|
|
len={memory.embedding.length} first=
|
|
{memory.embedding[0].toFixed(6)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
const { render, screen, fireEvent, cleanup, waitFor, within } = await import(
|
|
"@testing-library/react"
|
|
);
|
|
const { VectorBrowserView } = await import("../src/VectorBrowserView.tsx");
|
|
|
|
beforeEach(() => {
|
|
backend.tables = [];
|
|
backend.total = 0;
|
|
backend.uniqueCount = 0;
|
|
backend.memoryRows = () => [];
|
|
backend.graphRows = () => [];
|
|
backend.tablesError = null;
|
|
backend.queryError = null;
|
|
backend.calls = [];
|
|
getDatabaseTables.mockClear();
|
|
executeDatabaseQuery.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
function seedSingleTable(rows: Record<string, unknown>[], total = rows.length) {
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = total;
|
|
backend.uniqueCount = total;
|
|
backend.memoryRows = () => rows;
|
|
backend.graphRows = () => rows;
|
|
}
|
|
|
|
describe("VectorBrowserView — populated list", () => {
|
|
it("renders memory rows with content, NNd embedding badge, and stats", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "user prefers tea",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
memoryRow({
|
|
id: "m-1",
|
|
text: "meeting at noon",
|
|
type: "message",
|
|
withEmbedding: true,
|
|
seed: 1,
|
|
}),
|
|
]);
|
|
|
|
render(<VectorBrowserView />);
|
|
|
|
// populated content rows (real value, not just a heading)
|
|
expect(await screen.findByText("user prefers tea")).toBeTruthy();
|
|
expect(screen.getByText("meeting at noon")).toBeTruthy();
|
|
|
|
// embedding badge shows the real parsed length (768D) — appears per row
|
|
const badges = screen.getAllByText("768D");
|
|
expect(badges.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// stats metrics: total (2), embed dimensions (768D), unique (2)
|
|
await waitFor(() => {
|
|
expect(screen.getByText("embed")).toBeTruthy();
|
|
});
|
|
// "2" appears for total + unique metric values
|
|
expect(screen.getAllByText("2").length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it("shows the empty state when there are no records", async () => {
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = 0;
|
|
backend.memoryRows = () => [];
|
|
render(<VectorBrowserView />);
|
|
expect(await screen.findByText("None")).toBeTruthy();
|
|
});
|
|
|
|
it("falls back content to (empty) when content is blank", async () => {
|
|
const blank = memoryRow({
|
|
id: "m-blank",
|
|
text: "",
|
|
type: "",
|
|
withEmbedding: false,
|
|
seed: 9,
|
|
});
|
|
blank.content = "";
|
|
seedSingleTable([blank]);
|
|
render(<VectorBrowserView />);
|
|
expect(await screen.findByText("(empty)")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — search control", () => {
|
|
it("Search button issues a LIKE query and resets to page 0", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "alpha note",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
]);
|
|
backend.memoryRows = (_offset, search) =>
|
|
!search || search === "alpha"
|
|
? [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "alpha note",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
]
|
|
: [];
|
|
|
|
render(<VectorBrowserView />);
|
|
await screen.findByText("alpha note");
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
"vectorbrowserview.SearchContent",
|
|
);
|
|
fireEvent.change(input, { target: { value: "alpha" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "common.search" }));
|
|
|
|
await waitFor(() => {
|
|
expect(backend.calls.some((c) => /LIKE '%alpha%'/.test(c))).toBe(true);
|
|
});
|
|
expect(await screen.findByText("alpha note")).toBeTruthy();
|
|
});
|
|
|
|
it("Enter key in the search input triggers the same search", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "beta note",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
]);
|
|
render(<VectorBrowserView />);
|
|
await screen.findByText("beta note");
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
"vectorbrowserview.SearchContent",
|
|
);
|
|
fireEvent.change(input, { target: { value: "beta" } });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(backend.calls.some((c) => /LIKE '%beta%'/.test(c))).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("shows the search-specific empty state when a search matches nothing", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "gamma",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
]);
|
|
backend.memoryRows = (_offset, search) =>
|
|
search
|
|
? []
|
|
: [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "gamma",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
];
|
|
|
|
render(<VectorBrowserView />);
|
|
await screen.findByText("gamma");
|
|
|
|
const input = screen.getByPlaceholderText(
|
|
"vectorbrowserview.SearchContent",
|
|
);
|
|
fireEvent.change(input, { target: { value: "nomatch" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "common.search" }));
|
|
|
|
expect(await screen.findByText("None")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — table select reset cascade", () => {
|
|
it("switching table resets search/page and queries the new table", async () => {
|
|
backend.tables = [
|
|
table("memories"),
|
|
table("documents"),
|
|
table("embeddings"),
|
|
];
|
|
backend.total = 1;
|
|
backend.memoryRows = () => [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "in memories",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
];
|
|
render(<VectorBrowserView />);
|
|
await screen.findByText("in memories");
|
|
|
|
// type a search first so we can prove it is reset
|
|
const input = screen.getByPlaceholderText(
|
|
"vectorbrowserview.SearchContent",
|
|
);
|
|
fireEvent.change(input, { target: { value: "stale" } });
|
|
|
|
backend.calls = [];
|
|
const select = screen.getByTestId("table-select") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: "documents" } });
|
|
|
|
await waitFor(() => {
|
|
expect(backend.calls.some((c) => /FROM "documents"/.test(c))).toBe(true);
|
|
});
|
|
// search input was cleared by the reset cascade
|
|
expect(
|
|
(
|
|
screen.getByPlaceholderText(
|
|
"vectorbrowserview.SearchContent",
|
|
) as HTMLInputElement
|
|
).value,
|
|
).toBe("");
|
|
// no query carried the stale LIKE term
|
|
expect(backend.calls.some((c) => /LIKE '%stale%'/.test(c))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — pagination", () => {
|
|
it("renders Page 1 / N footer and Next issues an OFFSET=25 query", async () => {
|
|
// 60 rows total => 3 pages; PAGE_SIZE is 25
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = 60;
|
|
backend.uniqueCount = 60;
|
|
backend.memoryRows = (offset) => [
|
|
memoryRow({
|
|
id: `m-${offset}`,
|
|
text: `row at offset ${offset}`,
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: offset,
|
|
}),
|
|
];
|
|
|
|
render(<VectorBrowserView />);
|
|
expect(await screen.findByText("row at offset 0")).toBeTruthy();
|
|
|
|
// footer "Page 1 / 3"
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/1 \/ 3/)).toBeTruthy();
|
|
});
|
|
|
|
const prev = screen.getByRole("button", { name: "common.prev" });
|
|
const next = screen.getByRole("button", { name: "common.next" });
|
|
expect((prev as HTMLButtonElement).disabled).toBe(true);
|
|
expect((next as HTMLButtonElement).disabled).toBe(false);
|
|
|
|
fireEvent.click(next);
|
|
|
|
await waitFor(() => {
|
|
expect(backend.calls.some((c) => /OFFSET 25/.test(c))).toBe(true);
|
|
});
|
|
expect(await screen.findByText("row at offset 25")).toBeTruthy();
|
|
// now on page 2 of 3, prev becomes enabled
|
|
await waitFor(() => {
|
|
expect(
|
|
(
|
|
screen.getByRole("button", {
|
|
name: "common.prev",
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — detail face", () => {
|
|
it("clicking a row opens the populated detail panel; Back returns to list", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "mem-detail",
|
|
text: "open me",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 7,
|
|
}),
|
|
memoryRow({
|
|
id: "mem-other",
|
|
text: "another",
|
|
type: "message",
|
|
withEmbedding: true,
|
|
seed: 8,
|
|
}),
|
|
]);
|
|
render(<VectorBrowserView />);
|
|
const row = await screen.findByText("open me");
|
|
|
|
fireEvent.click(row.closest("button") as HTMLButtonElement);
|
|
|
|
const panel = await screen.findByTestId("detail-panel");
|
|
const region = within(panel);
|
|
expect(region.getByTestId("detail-content").textContent).toBe("open me");
|
|
expect(region.getByTestId("detail-id").textContent).toBe("mem-detail");
|
|
expect(region.getByTestId("detail-type").textContent).toBe("fact");
|
|
expect(region.getByTestId("detail-room").textContent).toBe("room-7");
|
|
expect(region.getByTestId("detail-entity").textContent).toBe("entity-7");
|
|
// embedding length + first toFixed(6) value rendered
|
|
expect(region.getByTestId("detail-embedding").textContent).toContain(
|
|
"len=768",
|
|
);
|
|
expect(region.getByTestId("detail-embedding").textContent).toContain(
|
|
"first=0.104000",
|
|
);
|
|
|
|
// Back to list returns to the master list
|
|
fireEvent.click(screen.getByRole("button", { name: "← Back to list" }));
|
|
expect(await screen.findByText("another")).toBeTruthy();
|
|
expect(screen.queryByTestId("detail-panel")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — 2D graph tab", () => {
|
|
it("renders the populated projection header + canvas with >=2 embeddings", async () => {
|
|
seedSingleTable([
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "a",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
memoryRow({
|
|
id: "m-1",
|
|
text: "b",
|
|
type: "message",
|
|
withEmbedding: true,
|
|
seed: 1,
|
|
}),
|
|
memoryRow({
|
|
id: "m-2",
|
|
text: "c",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 2,
|
|
}),
|
|
]);
|
|
const { container } = render(<VectorBrowserView />);
|
|
await screen.findByText("a");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "2D" }));
|
|
|
|
// aria-current marks the active tab
|
|
await waitFor(() => {
|
|
const tab = screen.getByRole("button", { name: "2D" });
|
|
expect(tab.getAttribute("aria-current")).toBe("true");
|
|
});
|
|
// populated path: "N vectors projected to ..." header (NOT the empty state).
|
|
// The header is "{N} {t('vectorbrowserview.vectorsProjectedTo')}"; the mock
|
|
// t() renders the bare key, so match it.
|
|
expect(
|
|
await screen.findByText(/vectorbrowserview\.vectorsProjectedTo/),
|
|
).toBeTruthy();
|
|
// the <2 empty-state key must NOT be present on the populated path
|
|
expect(
|
|
screen.queryByText("vectorbrowserview.NotEnoughEmbedding"),
|
|
).toBeNull();
|
|
// a canvas is mounted for the 2D projection
|
|
expect(container.querySelector("canvas")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the 'Not enough embeddings' state with <2 embeddings", async () => {
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = 1;
|
|
backend.graphRows = () => [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "lonely",
|
|
type: "fact",
|
|
withEmbedding: false,
|
|
seed: 0,
|
|
}),
|
|
];
|
|
backend.memoryRows = () => [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "lonely",
|
|
type: "fact",
|
|
withEmbedding: false,
|
|
seed: 0,
|
|
}),
|
|
];
|
|
render(<VectorBrowserView />);
|
|
await screen.findByText("lonely");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "2D" }));
|
|
// empty-state heading uses a bare i18n key (no defaultValue) -> mock t()
|
|
// renders the key. This guards the smoke regression: <2 embeddings can only
|
|
// ever show the empty state.
|
|
expect(
|
|
await screen.findByText("vectorbrowserview.NotEnoughEmbedding"),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("VectorBrowserView — error states", () => {
|
|
it("shows the connection-error screen and Retry re-invokes getDatabaseTables", async () => {
|
|
backend.tablesError = new Error("Failed to fetch");
|
|
render(<VectorBrowserView />);
|
|
|
|
// The connection-error screen is gated on the error message containing
|
|
// "agent is running" (the DatabaseConnectionError default). These headings
|
|
// use bare i18n keys (no defaultValue), so the mock t() renders the key.
|
|
expect(
|
|
await screen.findByText("databaseview.DatabaseNotAvailab"),
|
|
).toBeTruthy();
|
|
expect(screen.getByText("vectorbrowserview.StartTheAgentToB")).toBeTruthy();
|
|
expect(getDatabaseTables).toHaveBeenCalledTimes(1);
|
|
|
|
// Retry: clear the error and reload tables
|
|
backend.tablesError = null;
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = 1;
|
|
backend.memoryRows = () => [
|
|
memoryRow({
|
|
id: "m-0",
|
|
text: "recovered",
|
|
type: "fact",
|
|
withEmbedding: true,
|
|
seed: 0,
|
|
}),
|
|
];
|
|
|
|
fireEvent.click(
|
|
screen.getByRole("button", { name: "vectorbrowserview.RetryConnection" }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(getDatabaseTables.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
expect(await screen.findByText("recovered")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the LoadFailed error banner when a query fails", async () => {
|
|
backend.tables = [table("memories"), table("embeddings")];
|
|
backend.total = 1;
|
|
backend.queryError = new Error("syntax error at or near SELECT");
|
|
render(<VectorBrowserView />);
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"Failed to load memories: syntax error at or near SELECT",
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
});
|