diff --git a/apps/web/app/components/chat-panel.stream-parser.test.ts b/apps/web/app/components/chat-panel.stream-parser.test.ts new file mode 100644 index 00000000000..48cb9999aec --- /dev/null +++ b/apps/web/app/components/chat-panel.stream-parser.test.ts @@ -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" }]); + }); +}); diff --git a/apps/web/app/components/subagent-panel.messages.test.ts b/apps/web/app/components/subagent-panel.messages.test.ts new file mode 100644 index 00000000000..40966fd6c05 --- /dev/null +++ b/apps/web/app/components/subagent-panel.messages.test.ts @@ -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"); + }); +}); diff --git a/apps/web/app/components/workspace/object-filter-bar.test.tsx b/apps/web/app/components/workspace/object-filter-bar.test.tsx new file mode 100644 index 00000000000..74f85fab7d3 --- /dev/null +++ b/apps/web/app/components/workspace/object-filter-bar.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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()); + }); +}); diff --git a/apps/web/app/components/workspace/profile-switcher.test.tsx b/apps/web/app/components/workspace/profile-switcher.test.tsx new file mode 100644 index 00000000000..aee5bb36e58 --- /dev/null +++ b/apps/web/app/components/workspace/profile-switcher.test.tsx @@ -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(); + 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", + }); + }); +});