summaryrefslogtreecommitdiffhomepage
path: root/src/app/App.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/App.test.ts')
-rw-r--r--src/app/App.test.ts345
1 files changed, 331 insertions, 14 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index dcdcb9b..ebc8004 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -1,9 +1,12 @@
+import type { LayoutTree } from "@dispatch/layout-contract";
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
+import { overlaySidePanels } from "../features/layout";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
@@ -57,11 +60,17 @@ function fakeSocket(): FakeSocket {
function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
+ if (url.endsWith("/layout")) {
+ // No layout provider in these tests: the plan-02 no-provider signal.
+ return new Response(JSON.stringify({ error: "No layout provider is active" }), {
+ status: 404,
+ });
+ }
if (url.includes("/conversations?status=")) {
return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
}
@@ -73,7 +82,60 @@ function fakeFetchImpl(): typeof fetch {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ if (url.endsWith("/settings/vision")) {
+ return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), {
+ status: 200,
+ });
+ }
+ if (/\/plugins\/[^/]+\/frontend/.test(url)) {
+ // plan 03 frontend module (no fixtures declare one) — the plain 404
+ // the route returns for unknown/unloaded/frontend-less plugins.
+ return new Response(JSON.stringify({ error: "No frontend module for this plugin" }), {
+ status: 404,
+ });
+ }
+ if (/\/plugins(\?.*)?$/.test(url)) {
+ // Valid EMPTY list (plan 03): the plugin host's boot
+ // `extensions-changed`/workspace syncs read this — a shapeless
+ // fallback body would be cast to PluginListResponse and crash the
+ // diff (`list.loaded` not iterable).
+ return new Response(
+ JSON.stringify({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
+ };
+}
+
+/**
+ * A fetch whose layout provider is ACTIVE and serves the given sidebar
+ * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with
+ * the views overlaid; `PUT` echoes the tree (200). Tests that need specific
+ * sidebar views visible drive the layout through THIS edge — the pre-seeded
+ * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7
+ * migration now.
+ */
+function fakeFetchWithLayout(views: readonly string[]): typeof fetch {
+ const inner = fakeFetchImpl();
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/layout")) {
+ const method = init?.method ?? "GET";
+ if (method === "PUT" && typeof init?.body === "string") {
+ const body = JSON.parse(init.body) as { tree: LayoutTree };
+ return new Response(JSON.stringify({ tree: body.tree }), { status: 200 });
+ }
+ if (method === "GET") {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ return inner(input, init);
};
}
@@ -118,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
}
describe("App component interaction tests", () => {
+ it.each([
+ "all",
+ "parent",
+ "cancel",
+ ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const closed: string[] = [];
+ const base = fakeFetchWithLayout(["tabs"]);
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ { id: "p", workspaceId: "default" },
+ { id: "c1", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "c2", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "g", workspaceId: "default", tabGroupParentId: "c1" },
+ { id: "solo", workspaceId: "default" },
+ { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" },
+ ].map((conversation) => ({
+ ...conversation,
+ title: conversation.id,
+ status: "idle",
+ lastActivityAt: 0,
+ })),
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/close")) {
+ expect(init?.method).toBe("POST");
+ const id = url.split("/").at(-2) as string;
+ closed.push(id);
+ return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ },
+ localStorage: storage,
+ });
+ try {
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ await screen.findByRole("tablist");
+ for (const [id, parent] of [
+ ["p", null],
+ ["c1", "p"],
+ ["c2", "p"],
+ ["g", "c1"],
+ ["solo", null],
+ ] as const) {
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: id,
+ workspaceId: "default",
+ ...(parent === null ? {} : { tabGroupParentId: parent }),
+ });
+ }
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "other-workspace",
+ workspaceId: "other",
+ tabGroupParentId: "p",
+ });
+ await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5));
+ store.selectTab("p");
+ const parent = await waitFor(() => {
+ const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]');
+ expect(tab).not.toBeNull();
+ return tab;
+ });
+ if (parent === null) throw new Error("Parent tab missing");
+ await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" }));
+ const dialog = screen.getByRole("dialog", { name: "Close subagents too?" });
+ expect(dialog).toHaveTextContent("3 subagent tabs");
+ expect(closed).toEqual([]);
+ await fireEvent.click(
+ within(dialog).getByRole("button", {
+ name:
+ choice === "all"
+ ? /Yes, close all 4/
+ : choice === "parent"
+ ? /No, keep them open/
+ : "Cancel",
+ }),
+ );
+ const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : [];
+ expect(closed).toEqual(expected);
+ expect(store.tabs.map((tab) => tab.conversationId)).toEqual(
+ ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ if (choice !== "cancel") {
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as {
+ tabs: { conversationId: string }[];
+ };
+ expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([
+ ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ "other-workspace",
+ ]);
+ expect(store.activeConversationId).not.toBe("p");
+ }
+ } finally {
+ store.dispose();
+ }
+ });
+
it("renders the model selector and composer in draft mode", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -231,7 +406,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -295,7 +470,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -372,12 +547,133 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it.each([
+ "look at this",
+ "",
+ ])("queues staged images through the full UI with text %j", async (text) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ const { container } = render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const conversationId = activeConversationId(store);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: "t1" },
+ });
+ if (text) await user.type(textarea, text);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ ws.sent.length = 0;
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId,
+ workspaceId: "default",
+ text,
+ images: [{ url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }],
+ },
+ ]);
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ store.dispose();
+ });
+
+ it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => {
+ const ws = fakeSocket();
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const firstId = activeConversationId(store);
+ const startTurn = (conversationId: string) =>
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` },
+ });
+ const pushQueue = (conversationId: string, payload: unknown) =>
+ ws.feedSurfaceMessage({
+ type: "surface",
+ conversationId,
+ spec: {
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ },
+ });
+ startTurn(firstId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" },
+ ],
+ });
+ const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
+ pushQueue(firstId, pending);
+ const flush = await screen.findByRole("button", { name: "Flush" });
+ ws.sent.length = 0;
+ fetchImpl.mockClear();
+ await user.click(flush);
+ expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
+ [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
+ ]);
+ expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation.
+
+ store.newDraft();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "second");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const secondId = activeConversationId(store);
+ expect(secondId).not.toBe(firstId);
+ startTurn(secondId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ pushQueue(secondId, pending);
+ await screen.findByRole("button", { name: "Flush" });
+ pushQueue(secondId, { messages: [] });
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(secondId, { messages: "malformed" });
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ store.dispose();
+ });
+
it("incoming chat.delta renders text in the chat transcript", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -415,7 +711,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -454,19 +750,20 @@ describe("App component interaction tests", () => {
store.dispose();
});
- it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
+ it("the Extensions view lists frontend modules aggregated from feature manifests", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
render(App, { props: { store, onNavigate: vi.fn() } });
- // Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
- expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
+ // The Extensions view arrives with the provider's tree (an async GET —
+ // plan 02), so await the modules table rendering.
+ expect(await screen.findByRole("columnheader", { name: "Module" })).toBeInTheDocument();
for (const name of [
"chat",
"tabs",
@@ -486,8 +783,8 @@ describe("App component interaction tests", () => {
// network failure / unreachable backend on a new device).
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -502,7 +799,9 @@ describe("App component interaction tests", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
};
const ws = fakeSocket();
@@ -583,4 +882,22 @@ describe("App component interaction tests", () => {
store.dispose();
});
+
+ it("the Vision view renders the vision settings UI (image limit + compaction model)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchWithLayout(["vision"]),
+ localStorage: createFakeStorageWithViews(["vision"]),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ // The vision settings load (`GET /settings/vision`) seeds the inputs.
+ expect(await screen.findByLabelText(/Image limit/)).toBeInTheDocument();
+ expect(await screen.findByLabelText(/Compaction model/)).toBeInTheDocument();
+
+ store.dispose();
+ });
});