test(web): add profile-switcher, object-filter-bar, stream-parser, and subagent-panel message tests
This commit is contained in:
parent
a61aedd51d
commit
693811ccb2
97
apps/web/app/components/chat-panel.stream-parser.test.ts
Normal file
97
apps/web/app/components/chat-panel.stream-parser.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
37
apps/web/app/components/subagent-panel.messages.test.ts
Normal file
37
apps/web/app/components/subagent-panel.messages.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
85
apps/web/app/components/workspace/object-filter-bar.test.tsx
Normal file
85
apps/web/app/components/workspace/object-filter-bar.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
91
apps/web/app/components/workspace/profile-switcher.test.tsx
Normal file
91
apps/web/app/components/workspace/profile-switcher.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user