818 lines
29 KiB
TypeScript
818 lines
29 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import type { Connection } from "../connections/model";
|
|
import type { ResearchHistoryEntry, ResearchRun } from "./model";
|
|
import { ResearchPanel } from "./research-panel";
|
|
|
|
const navigation = vi.hoisted(() => ({
|
|
search: {} as { run?: string; deck?: string; citation?: string },
|
|
set: vi.fn<(patch: { run?: string; deck?: string; citation?: string }) => void>(),
|
|
}));
|
|
vi.mock("../navigation/use-url-state", async () => {
|
|
const { useState } = await import("react");
|
|
return {
|
|
useUrlState: () => {
|
|
const [search, update] = useState(navigation.search);
|
|
return [
|
|
search,
|
|
(patch: typeof search) => {
|
|
navigation.set(patch);
|
|
update((previous) => ({ ...previous, ...patch }));
|
|
},
|
|
];
|
|
},
|
|
};
|
|
});
|
|
const actions = vi.hoisted(() => ({
|
|
status: vi.fn<(opts: { data: { id?: string } }) => Promise<typeof current>>(),
|
|
start: vi.fn<typeof import("./server-functions").startResearch>(),
|
|
cancel: vi.fn<typeof import("./server-functions").cancelResearch>(),
|
|
}));
|
|
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
|
|
vi.mock("./server-functions", () => ({
|
|
loadResearchStatus: actions.status,
|
|
startResearch: actions.start,
|
|
cancelResearch: actions.cancel,
|
|
}));
|
|
|
|
const connection: Connection = {
|
|
id: "first",
|
|
platform: "twitter",
|
|
origin: "https://relay.invalid",
|
|
accountId: null,
|
|
displayName: "調査用アカウント",
|
|
status: "connected",
|
|
};
|
|
const running: ResearchRun = {
|
|
id: "run1",
|
|
topic: "WebMCPの反応",
|
|
status: "running",
|
|
deckVersion: 1,
|
|
startedAt: 1000,
|
|
message: "投稿を調べています",
|
|
citations: [],
|
|
messages: [{ id: "u1", role: "user", text: "WebMCPの反応" }],
|
|
deck: { id: "plan", title: "WebMCP調査", columns: [] },
|
|
};
|
|
let current: {
|
|
configured: boolean;
|
|
run: ResearchRun | null;
|
|
history?: ResearchHistoryEntry[];
|
|
};
|
|
const clients: QueryClient[] = [];
|
|
class MockEventSource {
|
|
static instances: MockEventSource[] = [];
|
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
onopen: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
close = vi.fn<() => void>();
|
|
constructor(readonly url: string) {
|
|
MockEventSource.instances.push(this);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
navigation.search = {};
|
|
vi.resetAllMocks();
|
|
MockEventSource.instances = [];
|
|
vi.stubGlobal("EventSource", MockEventSource);
|
|
current = { configured: true, run: null };
|
|
actions.status.mockImplementation(async ({ data }: { data: { id?: string } }) => ({
|
|
...current,
|
|
run: current.run?.id === data.id ? current.run : null,
|
|
}));
|
|
actions.start.mockImplementation(async () => {
|
|
current = { configured: true, run: running };
|
|
return running;
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
for (const client of clients.splice(0)) client.clear();
|
|
});
|
|
|
|
function show(connections = [connection], ready = true) {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
clients.push(client);
|
|
const onOpenDeck =
|
|
vi.fn<(deck: import("../decks/model").Deck, activate?: boolean) => Promise<void> | void>();
|
|
const onNavigateCitation = vi
|
|
.fn<
|
|
(url: string, citations: import("./model").ResearchCitation[], restore?: boolean) => boolean
|
|
>()
|
|
.mockReturnValue(true);
|
|
const ui = (
|
|
ready = true,
|
|
contextDeck = { id: "manual", title: "手動デッキ", columns: [] },
|
|
presentation = "desktop",
|
|
) => (
|
|
<QueryClientProvider client={client}>
|
|
<ResearchPanel
|
|
ready={ready}
|
|
contextDeck={contextDeck}
|
|
connections={connections}
|
|
onOpenDeck={onOpenDeck}
|
|
onNavigateCitation={onNavigateCitation}
|
|
>
|
|
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>}
|
|
</ResearchPanel>
|
|
</QueryClientProvider>
|
|
);
|
|
return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation };
|
|
}
|
|
|
|
it("retains input, selected accounts, and the request ID across responsive view remounts after a failed start", async () => {
|
|
actions.start.mockRejectedValue(new Error("internal credential detail"));
|
|
const view = show([connection, { ...connection, id: "second", displayName: "second" }]);
|
|
await waitFor(() => expect(screen.getByLabelText("Message")).toBeEnabled());
|
|
fireEvent.click(screen.getByRole("button", { name: "Connected accounts · 2" }));
|
|
fireEvent.click(screen.getByRole("checkbox", { name: "second · Twitter" }));
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "WebMCPの反応" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("Your draft has been kept");
|
|
expect(screen.getByLabelText("Message")).toHaveValue("WebMCPの反応");
|
|
expect(screen.queryByText(/internal credential/)).toBeNull();
|
|
view.rerender(view.ui(true, undefined, "mobile"));
|
|
expect(screen.getByRole("alert")).toHaveTextContent("Your draft has been kept");
|
|
expect(screen.getByLabelText("Message")).toHaveValue("WebMCPの反応");
|
|
fireEvent.click(screen.getByRole("button", { name: "Connected accounts · 1" }));
|
|
expect(screen.getByRole("checkbox", { name: "second · Twitter" })).not.toBeChecked();
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2));
|
|
expect(actions.start.mock.calls[1]?.[0]).toEqual(actions.start.mock.calls[0]?.[0]);
|
|
});
|
|
|
|
it("opens each generated deck version only once for the run started in this browser", async () => {
|
|
const view = show();
|
|
await waitFor(() => expect(screen.getByLabelText("Message")).toBeEnabled());
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "WebMCPの反応" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce());
|
|
act(() => {
|
|
view.client.setQueryData(["research-status", running.id], {
|
|
...current,
|
|
run: { ...running },
|
|
});
|
|
});
|
|
expect(view.onOpenDeck).toHaveBeenCalledOnce();
|
|
view.rerender(view.ui());
|
|
current = {
|
|
configured: true,
|
|
run: { ...running, deckVersion: 2, message: "追加の検索を実行中" },
|
|
};
|
|
act(() =>
|
|
MockEventSource.instances.at(-1)?.onmessage?.({
|
|
data: JSON.stringify(current),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2), {
|
|
timeout: 2500,
|
|
});
|
|
expect(screen.getByRole("log", { name: "Research conversation" })).toBeVisible();
|
|
});
|
|
|
|
it("restores the latest plan automatically on reload and permits manual activation", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
const view = show();
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true));
|
|
fireEvent.click(await screen.findByRole("button", { name: "Open temporary deck" }));
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenLastCalledWith(running.deck, true));
|
|
});
|
|
|
|
it("reports cancellation failure and updates the run after a successful retry", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
actions.cancel
|
|
.mockRejectedValueOnce(new Error("offline"))
|
|
.mockResolvedValue({ ...running, status: "cancelled" });
|
|
show();
|
|
fireEvent.click(await screen.findByRole("button", { name: "Stop research" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("Unable to stop research");
|
|
fireEvent.click(screen.getByRole("button", { name: "Stop research" }));
|
|
await waitFor(() => expect(screen.queryByRole("button", { name: "Stop research" })).toBeNull());
|
|
expect(actions.cancel).toHaveBeenCalledWith({ data: { id: running.id } });
|
|
});
|
|
|
|
it("limits account selection to six and shows the host report path on completion", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = {
|
|
configured: true,
|
|
run: {
|
|
...running,
|
|
status: "complete",
|
|
reportPath: "/home/owner/reports/webmcp.md",
|
|
},
|
|
};
|
|
show(
|
|
Array.from({ length: 7 }, (_, i) => ({
|
|
...connection,
|
|
id: `connection${i}`,
|
|
displayName: `account${i}`,
|
|
})),
|
|
);
|
|
await screen.findByText("/home/owner/reports/webmcp.md");
|
|
fireEvent.click(screen.getByText("Connected accounts · 6"));
|
|
expect(screen.getByRole("checkbox", { name: "account6 · Twitter" })).toHaveAttribute(
|
|
"aria-disabled",
|
|
"true",
|
|
);
|
|
fireEvent.click(screen.getByRole("checkbox", { name: "account0 · Twitter" }));
|
|
expect(screen.getByRole("checkbox", { name: "account6 · Twitter" })).not.toHaveAttribute(
|
|
"aria-disabled",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
it("waits for the workspace before restoring a completed plan from the stream", async () => {
|
|
navigation.search = { run: running.id };
|
|
const view = show([connection], false);
|
|
await screen.findByLabelText("Message");
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...running, status: "complete" },
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(view.client.getQueryData(["research-status", running.id])).toMatchObject({
|
|
run: { status: "complete" },
|
|
}),
|
|
);
|
|
expect(view.onOpenDeck).not.toHaveBeenCalled();
|
|
view.rerender(view.ui(true));
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true));
|
|
});
|
|
|
|
it("reconnects the stream without polling or cancelling the host job on unmount", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
const view = show();
|
|
await waitFor(() => expect(actions.status).toHaveBeenCalledOnce());
|
|
const stream = MockEventSource.instances[0];
|
|
expect(stream?.url).toBe("/api/research/events?run=run1");
|
|
act(() => stream?.onerror?.());
|
|
expect(screen.getByText(/Reconnecting to research updates/)).toBeVisible();
|
|
act(() => stream?.onopen?.());
|
|
expect(screen.queryByText(/Reconnecting to research updates/)).toBeNull();
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
});
|
|
expect(actions.status).toHaveBeenCalledOnce();
|
|
view.unmount();
|
|
expect(stream?.close).toHaveBeenCalledOnce();
|
|
expect(actions.cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps newer stream state when the start response arrives late", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = {
|
|
configured: true,
|
|
run: {
|
|
...running,
|
|
status: "complete",
|
|
threadId: "thread1",
|
|
deck: undefined,
|
|
deckVersion: 0,
|
|
},
|
|
};
|
|
let finish!: (run: ResearchRun) => void;
|
|
actions.start.mockImplementation(
|
|
() =>
|
|
new Promise<ResearchRun>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
const view = show();
|
|
await waitFor(() => expect(screen.getByLabelText("Message")).toBeEnabled());
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "WebMCP" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({ configured: true, run: running }),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce());
|
|
await act(async () =>
|
|
finish({ ...running, status: "starting", deck: undefined, deckVersion: 0 }),
|
|
);
|
|
expect(view.client.getQueryData(["research-status", running.id])).toMatchObject({
|
|
configured: true,
|
|
run: running,
|
|
});
|
|
expect(view.onOpenDeck).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("applies rapid versions in order while an earlier workspace save is pending", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
const view = show();
|
|
let finish!: () => void;
|
|
view.onOpenDeck.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledOnce());
|
|
const nextDeck = { id: "plan", title: "追加検索", columns: [] };
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...running, deckVersion: 2, deck: nextDeck },
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(view.client.getQueryData(["research-status", running.id])).toMatchObject({
|
|
run: { deckVersion: 2 },
|
|
}),
|
|
);
|
|
expect(view.onOpenDeck).toHaveBeenCalledOnce();
|
|
await act(async () => finish());
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledTimes(2));
|
|
expect(view.onOpenDeck).toHaveBeenLastCalledWith(nextDeck, false);
|
|
});
|
|
|
|
it("shows streamed conversation and sends follow-up input with the current deck and same run", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = {
|
|
configured: true,
|
|
run: {
|
|
...running,
|
|
status: "complete",
|
|
threadId: "thread1",
|
|
messages: [
|
|
{ id: "u1", role: "user", text: "WebMCPを調べて" },
|
|
{ id: "a1", role: "assistant", text: "検索結果を比較しました。" },
|
|
{ id: "t1", role: "tool", text: "検索カラムを追加しました" },
|
|
],
|
|
},
|
|
};
|
|
const view = show();
|
|
expect(await screen.findByText("検索結果を比較しました。")).toBeVisible();
|
|
expect(screen.getByText("検索カラムを追加しました")).toBeVisible();
|
|
expect(screen.getByText("You")).toBeVisible();
|
|
const editedDeck = { id: "other", title: "切り替えたデッキ", columns: [] };
|
|
view.rerender(view.ui(true, editedDeck));
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "このデッキを絞って" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() =>
|
|
expect(actions.start).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
runId: running.id,
|
|
contextDeck: editedDeck,
|
|
topic: "このデッキを絞って",
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(screen.getByLabelText("Message")).toHaveValue(""));
|
|
const updatedDeck = { id: "plan", title: "絞り込み結果", columns: [] };
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...running, deck: updatedDeck, deckVersion: 2 },
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenLastCalledWith(updatedDeck, true));
|
|
const laterDeck = { ...updatedDeck, title: "追加の結果" };
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...running, deck: laterDeck, deckVersion: 3 },
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenLastCalledWith(laterDeck, false));
|
|
});
|
|
|
|
it("selects newly loaded connected accounts until the user changes their selection", async () => {
|
|
const view = show([]);
|
|
await waitFor(() => expect(screen.getByLabelText("Message")).toBeEnabled());
|
|
fireEvent.click(screen.getByRole("button", { name: "Connected accounts · 0" }));
|
|
await screen.findByText("Add a connected account first.");
|
|
view.rerender(
|
|
<QueryClientProvider client={view.client}>
|
|
<ResearchPanel
|
|
ready
|
|
contextDeck={{ id: "manual", title: "手動", columns: [] }}
|
|
connections={[connection]}
|
|
onOpenDeck={view.onOpenDeck}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "調べて" },
|
|
});
|
|
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
|
});
|
|
|
|
it("starts a new chat while research continues without stopping the host job or changing the deck", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
const view = show();
|
|
await screen.findByText("WebMCPの反応");
|
|
const oldStream = MockEventSource.instances[0];
|
|
const opened = view.onOpenDeck.mock.calls.length;
|
|
expect(screen.getByRole("button", { name: "New chat" })).toBeEnabled();
|
|
fireEvent.click(screen.getByRole("button", { name: "New chat" }));
|
|
await waitFor(() => expect(screen.queryByText("WebMCPの反応")).toBeNull());
|
|
expect(navigation.set).toHaveBeenCalledWith({
|
|
run: "new",
|
|
citation: undefined,
|
|
});
|
|
expect(oldStream?.close).toHaveBeenCalledOnce();
|
|
expect(actions.cancel).not.toHaveBeenCalled();
|
|
expect(view.onOpenDeck).toHaveBeenCalledTimes(opened);
|
|
expect(screen.getByText("手動デッキ")).toBeVisible();
|
|
expect(screen.getByLabelText("Message")).toHaveValue("");
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "新しい話題" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() => expect(actions.start).toHaveBeenCalledOnce());
|
|
expect(actions.start.mock.calls[0]?.[0].data).not.toHaveProperty("runId");
|
|
});
|
|
|
|
it("ignores snapshots from other conversations and late events after switching away", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = { configured: true, run: running };
|
|
const view = show();
|
|
await screen.findByText("WebMCPの反応");
|
|
const oldStream = MockEventSource.instances[0];
|
|
const unrelated = {
|
|
...running,
|
|
id: "other",
|
|
messages: [{ id: "other-message", role: "user", text: "Unrelated conversation" }],
|
|
};
|
|
act(() =>
|
|
oldStream?.onmessage?.({
|
|
data: JSON.stringify({ configured: true, run: unrelated }),
|
|
}),
|
|
);
|
|
expect(screen.queryByText("Unrelated conversation")).toBeNull();
|
|
expect(view.client.getQueryData(["research-status", running.id])).toMatchObject({ run: running });
|
|
fireEvent.click(screen.getByRole("button", { name: "New chat" }));
|
|
await waitFor(() => expect(screen.queryByText("WebMCPの反応")).toBeNull());
|
|
act(() =>
|
|
oldStream?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...running, message: "Late old event" },
|
|
}),
|
|
}),
|
|
);
|
|
await act(async () => {});
|
|
expect(view.client.getQueryData(["research-status", "new"])).toMatchObject({
|
|
run: null,
|
|
});
|
|
expect(screen.queryByText("Unrelated conversation")).toBeNull();
|
|
expect(actions.cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders assistant Markdown and forwards its citation with the current evidence", async () => {
|
|
navigation.search = { run: running.id };
|
|
const citations: ResearchRun["citations"] = [
|
|
{
|
|
column: {
|
|
id: "source",
|
|
title: "投稿元",
|
|
connectionId: "first",
|
|
source: { platform: "twitter", kind: "user", target: "author" },
|
|
},
|
|
post: {
|
|
key: "twitter:123",
|
|
platform: "twitter",
|
|
nativeId: "123",
|
|
url: "https://x.com/author/status/123",
|
|
text: "調査に使った投稿",
|
|
author: { name: "Author", handle: "author" },
|
|
},
|
|
},
|
|
];
|
|
current = {
|
|
configured: true,
|
|
run: {
|
|
...running,
|
|
citations,
|
|
messages: [
|
|
{ id: "u1", role: "user", text: "# ユーザーの入力はそのまま" },
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
text: "# 調査結果\n\n[根拠の投稿](https://x.com/author/status/123)",
|
|
},
|
|
{ id: "t1", role: "tool", text: "**ツールの活動もそのまま**" },
|
|
],
|
|
},
|
|
};
|
|
const view = show();
|
|
expect(await screen.findByRole("heading", { level: 3, name: "調査結果" })).toBeVisible();
|
|
expect(screen.getByText("# ユーザーの入力はそのまま")).toBeVisible();
|
|
expect(screen.getByText("**ツールの活動もそのまま**")).toBeVisible();
|
|
fireEvent.click(screen.getByRole("link", { name: "根拠の投稿" }));
|
|
expect(view.onNavigateCitation).toHaveBeenCalledWith(
|
|
"https://x.com/author/status/123",
|
|
citations,
|
|
);
|
|
});
|
|
|
|
it("opens a saved chat with its context deck and selected accounts, preserving local choices on updates", async () => {
|
|
const contextDeck = { id: "past-deck", title: "以前のデッキ", columns: [] };
|
|
const saved: ResearchRun = {
|
|
...running,
|
|
id: "saved",
|
|
status: "interrupted",
|
|
threadId: "saved-thread",
|
|
connectionIds: ["second"],
|
|
contextDeck,
|
|
deck: undefined,
|
|
deckVersion: 0,
|
|
messages: [
|
|
{
|
|
id: "past",
|
|
role: "assistant",
|
|
text: "保存した会話\n\n[前回の引用](https://x.com/author/status/123)",
|
|
},
|
|
],
|
|
citations: [
|
|
{
|
|
column: {
|
|
id: "source",
|
|
title: "投稿元",
|
|
connectionId: "second",
|
|
source: { platform: "twitter", kind: "user", target: "author" },
|
|
},
|
|
post: {
|
|
key: "twitter:123",
|
|
platform: "twitter",
|
|
nativeId: "123",
|
|
url: "https://x.com/author/status/123",
|
|
text: "前回の投稿",
|
|
author: { name: "Author", handle: "author" },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const history: ResearchHistoryEntry[] = [
|
|
{
|
|
id: saved.id,
|
|
title: "以前の調査",
|
|
status: saved.status,
|
|
updatedAt: 1000,
|
|
},
|
|
];
|
|
current = { configured: true, run: null, history };
|
|
current = { ...current, run: saved };
|
|
actions.start.mockImplementation(async () => ({
|
|
...saved,
|
|
status: "running",
|
|
}));
|
|
const view = show([connection, { ...connection, id: "second", displayName: "account2" }]);
|
|
await screen.findByLabelText("Chat history");
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "別の下書き" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText("Chat history"), {
|
|
target: { value: saved.id },
|
|
});
|
|
expect(await screen.findByText("保存した会話")).toBeVisible();
|
|
fireEvent.click(screen.getByRole("link", { name: "前回の引用" }));
|
|
expect(view.onNavigateCitation).toHaveBeenCalledWith(
|
|
"https://x.com/author/status/123",
|
|
saved.citations,
|
|
);
|
|
expect(actions.status).toHaveBeenCalledWith({ data: { id: saved.id } });
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true));
|
|
expect(screen.getByLabelText("Message")).toHaveValue("");
|
|
expect(screen.getByText("Interrupted · Ready to resume")).toBeVisible();
|
|
fireEvent.click(screen.getByText("Connected accounts · 1"));
|
|
expect(screen.getByRole("checkbox", { name: "調査用アカウント · Twitter" })).not.toBeChecked();
|
|
expect(screen.getByRole("checkbox", { name: "account2 · Twitter" })).toBeChecked();
|
|
fireEvent.click(screen.getByRole("checkbox", { name: "調査用アカウント · Twitter" }));
|
|
act(() =>
|
|
MockEventSource.instances.at(-1)?.onmessage?.({
|
|
data: JSON.stringify({
|
|
configured: true,
|
|
run: { ...saved, message: "Refresh" },
|
|
history,
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(view.client.getQueryData(["research-status", saved.id])).toMatchObject({
|
|
run: { message: "Refresh" },
|
|
}),
|
|
);
|
|
expect(screen.getByRole("checkbox", { name: "調査用アカウント · Twitter" })).toBeChecked();
|
|
expect(view.onOpenDeck).toHaveBeenCalledOnce();
|
|
fireEvent.change(screen.getByLabelText("Message"), {
|
|
target: { value: "続きを調べて" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() =>
|
|
expect(actions.start).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
runId: saved.id,
|
|
connectionIds: ["second", "first"],
|
|
}),
|
|
}),
|
|
);
|
|
expect(view.client.getQueryData(["research-status", saved.id])).toMatchObject({
|
|
history,
|
|
});
|
|
});
|
|
|
|
it("restores a context-only chat on reload and permits browsing history during a running turn", async () => {
|
|
navigation.search = { run: running.id };
|
|
const contextDeck = { id: "context", title: "前回の手動デッキ", columns: [] };
|
|
current = {
|
|
configured: true,
|
|
run: { ...running, deck: undefined, contextDeck },
|
|
history: [
|
|
{
|
|
id: running.id,
|
|
title: running.topic,
|
|
status: running.status,
|
|
updatedAt: 1000,
|
|
},
|
|
],
|
|
};
|
|
const view = show();
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true));
|
|
expect(screen.getByLabelText("Chat history")).toBeEnabled();
|
|
});
|
|
|
|
it("keeps newer scoped stream state when the selected chat fetch arrives late", async () => {
|
|
const saved = { ...running, id: "saved", status: "complete" as const };
|
|
const history = [
|
|
{
|
|
id: saved.id,
|
|
title: "Saved chat",
|
|
status: saved.status,
|
|
updatedAt: 1000,
|
|
},
|
|
];
|
|
current = { configured: true, run: null, history };
|
|
let finish!: (snapshot: typeof current) => void;
|
|
actions.status.mockImplementation(({ data }: { data: { id?: string } }) =>
|
|
data.id === saved.id
|
|
? new Promise<typeof current>((resolve) => {
|
|
finish = resolve;
|
|
})
|
|
: Promise.resolve(current),
|
|
);
|
|
const view = show();
|
|
fireEvent.change(await screen.findByLabelText("Chat history"), {
|
|
target: { value: saved.id },
|
|
});
|
|
await waitFor(() => expect(MockEventSource.instances).toHaveLength(2));
|
|
expect(MockEventSource.instances[0]?.close).toHaveBeenCalledOnce();
|
|
expect(MockEventSource.instances[1]?.url).toBe("/api/research/events?run=saved");
|
|
const newer = {
|
|
configured: true,
|
|
run: { ...saved, deckVersion: 2, message: "Newer" },
|
|
history,
|
|
};
|
|
act(() => MockEventSource.instances[1]?.onmessage?.({ data: JSON.stringify(newer) }));
|
|
await waitFor(() =>
|
|
expect(view.client.getQueryData(["research-status", saved.id])).toEqual(newer),
|
|
);
|
|
await act(async () => finish({ configured: true, run: saved, history }));
|
|
expect(view.client.getQueryData(["research-status", saved.id])).toEqual(newer);
|
|
expect(actions.cancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reconstructs and activates the generated deck requested by a bookmarked research URL", async () => {
|
|
const plan = {
|
|
id: "bookmarked-plan",
|
|
title: "Bookmarked research",
|
|
columns: [],
|
|
};
|
|
const saved: ResearchRun = {
|
|
...running,
|
|
id: "saved-run",
|
|
status: "complete",
|
|
deck: plan,
|
|
};
|
|
navigation.search = { run: saved.id, deck: plan.id };
|
|
current = { ...current, run: saved };
|
|
const view = show();
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(plan, true));
|
|
expect(actions.status).toHaveBeenCalledWith({ data: { id: saved.id } });
|
|
expect(actions.start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([true, false])(
|
|
"waits for reconstruction before restoring a citation (deck in URL: %s)",
|
|
async (withDeck) => {
|
|
const citation = {
|
|
column: {
|
|
id: "source",
|
|
title: "Source",
|
|
connectionId: "first",
|
|
source: {
|
|
platform: "twitter" as const,
|
|
kind: "user" as const,
|
|
target: "author",
|
|
},
|
|
},
|
|
post: {
|
|
key: "twitter:123",
|
|
platform: "twitter" as const,
|
|
nativeId: "123",
|
|
url: "https://x.com/author/status/123",
|
|
text: "Cited post",
|
|
author: { name: "Author", handle: "author" },
|
|
},
|
|
};
|
|
const plan = {
|
|
id: "bookmarked-plan",
|
|
title: "Research",
|
|
columns: [citation.column],
|
|
};
|
|
const saved: ResearchRun = {
|
|
...running,
|
|
id: "saved-run",
|
|
status: "complete",
|
|
deck: plan,
|
|
citations: [citation],
|
|
};
|
|
navigation.search = {
|
|
run: saved.id,
|
|
deck: withDeck ? plan.id : undefined,
|
|
citation: citation.post.key,
|
|
};
|
|
current = { ...current, run: saved };
|
|
let finishSync!: () => void;
|
|
const reconstructed = new Promise<void>((resolve) => {
|
|
finishSync = resolve;
|
|
});
|
|
const view = show();
|
|
view.onOpenDeck.mockImplementation(() => reconstructed);
|
|
await waitFor(() => expect(view.onOpenDeck).toHaveBeenCalledWith(plan, false));
|
|
expect(view.onNavigateCitation).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
finishSync();
|
|
await reconstructed;
|
|
});
|
|
await waitFor(() =>
|
|
expect(view.onNavigateCitation).toHaveBeenCalledWith(
|
|
citation.post.url,
|
|
saved.citations,
|
|
true,
|
|
),
|
|
);
|
|
expect(navigation.set).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("animates only the latest live assistant response and leaves prior replies static", async () => {
|
|
navigation.search = { run: running.id };
|
|
current = {
|
|
configured: true,
|
|
run: {
|
|
...running,
|
|
messages: [
|
|
{ id: "older", role: "assistant", text: "Earlier answer" },
|
|
{ id: "followup", role: "user", text: "Continue" },
|
|
{ id: "live", role: "assistant", text: "Latest answer" },
|
|
],
|
|
},
|
|
};
|
|
const view = show();
|
|
expect(await screen.findByText("Earlier answer")).toBeVisible();
|
|
const replies = view.container.querySelectorAll(".research-markdown");
|
|
expect(replies).toHaveLength(2);
|
|
expect(replies[0]?.querySelector("[data-sd-animate]")).toBeNull();
|
|
expect(replies[1]?.querySelector("[data-sd-animate]")).not.toBeNull();
|
|
act(() =>
|
|
MockEventSource.instances[0]?.onmessage?.({
|
|
data: JSON.stringify({
|
|
...current,
|
|
run: { ...current.run, status: "complete" },
|
|
}),
|
|
}),
|
|
);
|
|
await waitFor(() => expect(view.container.querySelector("[data-sd-animate]")).toBeNull());
|
|
});
|