test(web): add profile-switcher, object-filter-bar, stream-parser, and subagent-panel message tests

This commit is contained in:
kumarabhirup 2026-03-02 18:36:19 -08:00
parent a61aedd51d
commit 693811ccb2
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 310 additions and 0 deletions

View File

@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { createStreamParser } from "./chat-panel";
describe("createStreamParser", () => {
it("treats user-message as turn boundary and keeps user/assistant sequence stable", () => {
const parser = createStreamParser();
parser.processEvent({ type: "user-message", id: "u-1", text: "Draft an email" });
parser.processEvent({ type: "text-start" });
parser.processEvent({ type: "text-delta", delta: "Sure — drafting now." });
parser.processEvent({ type: "text-end" });
parser.processEvent({ type: "user-message", id: "u-2", text: "Also include follow-up" });
parser.processEvent({ type: "text-start" });
parser.processEvent({ type: "text-delta", delta: "Added a follow-up section." });
parser.processEvent({ type: "text-end" });
expect(parser.getParts()).toEqual([
{ type: "user-message", id: "u-1", text: "Draft an email" },
{ type: "text", text: "Sure — drafting now." },
{ type: "user-message", id: "u-2", text: "Also include follow-up" },
{ type: "text", text: "Added a follow-up section." },
]);
});
it("accumulates tool input/output by toolCallId and preserves terminal status", () => {
const parser = createStreamParser();
parser.processEvent({
type: "tool-input-start",
toolCallId: "tool-1",
toolName: "searchDocs",
});
parser.processEvent({
type: "tool-input-available",
toolCallId: "tool-1",
input: { query: "workspace lock" },
});
parser.processEvent({
type: "tool-output-available",
toolCallId: "tool-1",
output: { hits: 3 },
});
parser.processEvent({
type: "tool-input-start",
toolCallId: "tool-2",
toolName: "writeFile",
});
parser.processEvent({
type: "tool-output-error",
toolCallId: "tool-2",
errorText: "permission denied",
});
expect(parser.getParts()).toEqual([
{
type: "dynamic-tool",
toolCallId: "tool-1",
toolName: "searchDocs",
state: "output-available",
input: { query: "workspace lock" },
output: { hits: 3 },
},
{
type: "dynamic-tool",
toolCallId: "tool-2",
toolName: "writeFile",
state: "error",
input: {},
output: { error: "permission denied" },
},
]);
});
it("closes reasoning state on reasoning-end to prevent stuck streaming badges", () => {
const parser = createStreamParser();
parser.processEvent({ type: "reasoning-start" });
parser.processEvent({ type: "reasoning-delta", delta: "Planning edits..." });
parser.processEvent({ type: "reasoning-end" });
expect(parser.getParts()).toEqual([
{ type: "reasoning", text: "Planning edits..." },
]);
});
it("ignores unknown events without corrupting previously parsed parts", () => {
const parser = createStreamParser();
parser.processEvent({ type: "text-start" });
parser.processEvent({ type: "text-delta", delta: "Stable output" });
parser.processEvent({ type: "unrecognized-event", payload: "noise" });
parser.processEvent({ type: "text-end" });
expect(parser.getParts()).toEqual([{ type: "text", text: "Stable output" }]);
});
});

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildMessagesFromParsed } from "./subagent-panel";
describe("buildMessagesFromParsed", () => {
it("splits assistant output at user-message boundaries (prevents turn merging)", () => {
const messages = buildMessagesFromParsed("sub-1", "Initial task", [
{ type: "text", text: "Working on it." },
{ type: "reasoning", text: "Checking files", state: "streaming" },
{ type: "user-message", id: "u-1", text: "Please include a summary" },
{ type: "text", text: "Added a summary section." },
]);
expect(messages).toHaveLength(4);
expect(messages[0]?.role).toBe("user");
expect(messages[1]?.role).toBe("assistant");
expect(messages[2]).toMatchObject({
id: "u-1",
role: "user",
parts: [{ type: "text", text: "Please include a summary" }],
});
expect(messages[3]).toMatchObject({
role: "assistant",
parts: [{ type: "text", text: "Added a summary section." }],
});
});
it("creates stable fallback user IDs when stream omits explicit user-message id", () => {
const messages = buildMessagesFromParsed("sub-2", "Task", [
{ type: "user-message", text: "Follow-up without id" },
{ type: "text", text: "Handled follow-up." },
]);
expect(messages[1]?.id).toBe("user-sub-2-0");
expect(messages[1]?.role).toBe("user");
expect(messages[2]?.role).toBe("assistant");
});
});

View File

@ -0,0 +1,85 @@
// @vitest-environment jsdom
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { emptyFilterGroup, type SavedView } from "@/lib/object-filters";
import { ObjectFilterBar } from "./object-filter-bar";
const fields = [
{ id: "f-name", name: "Name", type: "text" },
{ id: "f-status", name: "Status", type: "enum", enum_values: ["Important", "Backlog"] },
];
const importantView: SavedView = {
name: "Important",
filters: {
id: "root",
conjunction: "and",
rules: [{ id: "r-important", field: "Status", operator: "is", value: "Important" }],
},
columns: ["Name", "Status"],
};
const backlogView: SavedView = {
name: "Backlog",
filters: {
id: "root",
conjunction: "and",
rules: [{ id: "r-backlog", field: "Status", operator: "is", value: "Backlog" }],
},
columns: ["Name", "Status"],
};
describe("ObjectFilterBar views interaction", () => {
it("loads selected view from dropdown (keeps view label and table intent aligned)", async () => {
const user = userEvent.setup();
const onLoadView = vi.fn();
render(
<ObjectFilterBar
fields={fields}
filters={emptyFilterGroup()}
onFiltersChange={vi.fn()}
savedViews={[importantView, backlogView]}
activeViewName="Important"
onSaveView={vi.fn()}
onLoadView={onLoadView}
onDeleteView={vi.fn()}
onSetActiveView={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /important/i }));
await user.click(screen.getByRole("button", { name: /backlog/i }));
expect(onLoadView).toHaveBeenCalledTimes(1);
expect(onLoadView).toHaveBeenCalledWith(backlogView);
});
it("clears active view from dropdown action (prevents sticky active-view state)", async () => {
const user = userEvent.setup();
const onFiltersChange = vi.fn();
const onSetActiveView = vi.fn();
render(
<ObjectFilterBar
fields={fields}
filters={emptyFilterGroup()}
onFiltersChange={onFiltersChange}
savedViews={[importantView]}
activeViewName="Important"
onSaveView={vi.fn()}
onLoadView={vi.fn()}
onDeleteView={vi.fn()}
onSetActiveView={onSetActiveView}
/>,
);
await user.click(screen.getByRole("button", { name: /important/i }));
await user.click(screen.getByRole("button", { name: /clear active view/i }));
expect(onSetActiveView).toHaveBeenCalledWith(undefined);
expect(onFiltersChange).toHaveBeenCalledWith(emptyFilterGroup());
});
});

View File

@ -0,0 +1,91 @@
// @vitest-environment jsdom
import React from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProfileSwitcher } from "./profile-switcher";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("ProfileSwitcher workspace delete action", () => {
const originalConfirm = window.confirm;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
window.confirm = originalConfirm;
});
it("deletes a profile workspace from the dropdown action", async () => {
const user = userEvent.setup();
const onWorkspaceDelete = vi.fn();
let profileFetchCount = 0;
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input as URL).href;
const method = init?.method ?? "GET";
if (url === "/api/profiles" && method === "GET") {
profileFetchCount += 1;
if (profileFetchCount === 1) {
return jsonResponse({
activeProfile: "work",
profiles: [
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: "/home/testuser/.openclaw-work/workspace",
isActive: true,
hasConfig: true,
},
],
});
}
return jsonResponse({
activeProfile: "work",
profiles: [
{
name: "work",
stateDir: "/home/testuser/.openclaw-work",
workspaceDir: null,
isActive: true,
hasConfig: true,
},
],
});
}
if (url === "/api/workspace/delete" && method === "POST") {
return jsonResponse({ deleted: true, profile: "work" });
}
throw new Error(`Unexpected fetch call: ${method} ${url}`);
}) as typeof fetch;
window.confirm = vi.fn(() => true);
render(<ProfileSwitcher onWorkspaceDelete={onWorkspaceDelete} />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/profiles");
});
await user.click(screen.getByTitle("Switch workspace profile"));
await user.click(screen.getByTitle("Delete workspace for work"));
await waitFor(() => {
expect(onWorkspaceDelete).toHaveBeenCalledWith("work");
});
const deleteCall = vi
.mocked(global.fetch)
.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : (call[0] as URL).href) === "/api/workspace/delete");
expect(deleteCall).toBeTruthy();
expect(deleteCall?.[1]).toMatchObject({
method: "POST",
});
});
});