379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import type { DeckColumn } from "../../src/features/decks/model";
|
|
import type { ResearchRun } from "../../src/features/research/model";
|
|
import { expect, test } from "../fixtures";
|
|
import { closeMobileSidebar, showDeckManagement, showResearch } from "../workspace";
|
|
|
|
test("pushes the current research snapshot on each SSE connection", async ({ page }) => {
|
|
await page.goto("/deck");
|
|
const snapshots = await page.evaluate(async () => {
|
|
const connect = () =>
|
|
new Promise<unknown>((resolve, reject) => {
|
|
const events = new EventSource("/api/research/events");
|
|
const timer = setTimeout(() => {
|
|
events.close();
|
|
reject(new Error("SSE timed out"));
|
|
}, 5000);
|
|
events.onmessage = (event) => {
|
|
clearTimeout(timer);
|
|
events.close();
|
|
resolve(JSON.parse(event.data));
|
|
};
|
|
events.onerror = () => {
|
|
clearTimeout(timer);
|
|
events.close();
|
|
reject(new Error("SSE failed"));
|
|
};
|
|
});
|
|
return [await connect(), await connect()];
|
|
});
|
|
expect(snapshots).toEqual([
|
|
{ configured: false, run: null, history: [] },
|
|
{ configured: false, run: null, history: [] },
|
|
]);
|
|
});
|
|
|
|
test("restores the conversation and growing temporary deck from reconnect snapshots", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/deck");
|
|
await showDeckManagement(page);
|
|
await page.getByRole("button", { name: "Add column", exact: true }).click();
|
|
const accounts = page.getByRole("combobox", {
|
|
name: "Connection profile",
|
|
exact: true,
|
|
});
|
|
await accounts.selectOption({ label: "e2e" });
|
|
const connectionId = await accounts.inputValue();
|
|
await page.keyboard.press("Escape");
|
|
await closeMobileSidebar(page);
|
|
const run: ResearchRun = {
|
|
id: "36ad8cc7-318c-46ae-94fa-28d30336f027",
|
|
threadId: "conversation-test",
|
|
connectionIds: [connectionId],
|
|
topic: "既存の観点を使って調査して",
|
|
status: "running",
|
|
startedAt: Date.now(),
|
|
deckVersion: 1,
|
|
citations: [],
|
|
message: "既存の検索条件を再利用しています。",
|
|
messages: [
|
|
{ id: "user-1", role: "user", text: "既存の観点を使って調査して" },
|
|
{ id: "tool-1", role: "tool", text: "list_decks: 完了" },
|
|
{
|
|
id: "assistant-1",
|
|
role: "assistant",
|
|
text: "既存の検索条件を再利用しています。",
|
|
},
|
|
],
|
|
deck: {
|
|
id: "generated-plan",
|
|
title: "会話からの調査",
|
|
columns: [
|
|
{
|
|
id: "one",
|
|
title: "最初の観点",
|
|
connectionId,
|
|
source: {
|
|
platform: "twitter",
|
|
kind: "search",
|
|
query: "WebMCP",
|
|
product: "Latest",
|
|
following: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
await page.route("**/api/research/events*", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/event-stream",
|
|
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
|
|
}),
|
|
);
|
|
await page.goto(`/deck?run=${run.id}`);
|
|
await showResearch(page);
|
|
const conversation = page.getByRole("log", { name: "Research conversation" });
|
|
await expect(conversation).toContainText("既存の観点を使って調査して");
|
|
await expect(conversation).toContainText("list_decks: 完了");
|
|
await expect(conversation).toContainText("既存の検索条件を再利用しています。");
|
|
await expect(conversation.locator('[data-slot="bubble-content"]')).not.toHaveCSS(
|
|
"background-color",
|
|
"rgba(0, 0, 0, 0)",
|
|
);
|
|
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点"]);
|
|
run.deckVersion = 2;
|
|
run.status = "complete";
|
|
run.messages.push({
|
|
id: "assistant-2",
|
|
role: "assistant",
|
|
text: "比較する観点を追加しました。",
|
|
});
|
|
run.deck?.columns.push({
|
|
id: "two",
|
|
title: "比較の観点",
|
|
connectionId,
|
|
source: {
|
|
platform: "twitter",
|
|
kind: "search",
|
|
query: "WebMCP lang:ja",
|
|
product: "Latest",
|
|
following: false,
|
|
},
|
|
});
|
|
await page.reload();
|
|
await showResearch(page);
|
|
await expect(conversation).toContainText("既存の観点を使って調査して");
|
|
await expect(conversation).toContainText("比較する観点を追加しました。");
|
|
await expect(page.locator(".deck-column h2")).toHaveText(["最初の観点", "比較の観点"]);
|
|
await closeMobileSidebar(page);
|
|
await expect(page.getByRole("button", { name: "Save as deck", exact: true })).toBeEnabled();
|
|
await showResearch(page);
|
|
const accountPicker = page.getByRole("button", {
|
|
name: "Connected accounts · 1",
|
|
});
|
|
await accountPicker.click();
|
|
await expect(accountPicker).toHaveAttribute("aria-expanded", "true");
|
|
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).toBeChecked();
|
|
await accountPicker.click();
|
|
await expect(accountPicker).toHaveAttribute("aria-expanded", "false");
|
|
await page.getByLabel("Message", { exact: true }).fill("この観点を詳しく");
|
|
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeEnabled();
|
|
await page.getByRole("button", { name: "Close sidebar", exact: true }).click();
|
|
run.deckVersion = 3;
|
|
if (run.deck) run.deck.title = "閉じている間に更新された調査";
|
|
await expect(page.getByRole("heading", { level: 1 })).toHaveText("閉じている間に更新された調査", {
|
|
timeout: 15000,
|
|
});
|
|
await showResearch(page);
|
|
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
|
|
await page.setViewportSize({ width: 600, height: 900 });
|
|
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
|
|
await showResearch(page);
|
|
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
|
|
await page.getByRole("button", { name: "Connected accounts · 1" }).click();
|
|
await page.getByRole("checkbox", { name: "e2e · Twitter" }).uncheck();
|
|
await page.setViewportSize({ width: 1100, height: 900 });
|
|
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(0);
|
|
await showResearch(page);
|
|
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
|
|
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
|
|
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
|
|
await page.setViewportSize({ width: 600, height: 900 });
|
|
await expect(page.locator('[data-sidebar="sidebar"][data-mobile="true"]')).toHaveCount(1);
|
|
await showResearch(page);
|
|
await expect(page.getByLabel("Message", { exact: true })).toHaveValue("この観点を詳しく");
|
|
await page.getByRole("button", { name: "Connected accounts · 0" }).click();
|
|
await expect(page.getByRole("checkbox", { name: "e2e · Twitter" })).not.toBeChecked();
|
|
});
|
|
|
|
test("rejects a different tailnet owner at the SSE endpoint", async ({ request }) => {
|
|
const response = await request.get("/api/research/events", {
|
|
headers: { "Tailscale-User-Login": "[email protected]" },
|
|
});
|
|
expect(response.status()).toBe(403);
|
|
});
|
|
|
|
test("renders Markdown and reveals an unloaded citation in an offscreen deck column", async ({
|
|
page,
|
|
a11y,
|
|
}) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto("/deck");
|
|
await showDeckManagement(page);
|
|
await page.getByRole("button", { name: "Add column", exact: true }).click();
|
|
const accounts = page.getByRole("combobox", {
|
|
name: "Connection profile",
|
|
exact: true,
|
|
});
|
|
await accounts.selectOption({ label: "e2e" });
|
|
const connectionId = await accounts.inputValue();
|
|
await page.keyboard.press("Escape");
|
|
await closeMobileSidebar(page);
|
|
const source: DeckColumn = {
|
|
id: "citation-column-2",
|
|
title: "引用の観点",
|
|
connectionId,
|
|
source: {
|
|
platform: "twitter",
|
|
kind: "search",
|
|
query: "citation-query-2",
|
|
product: "Latest",
|
|
following: false,
|
|
},
|
|
};
|
|
const columns: DeckColumn[] = ["概要", "比較"].map((title, index) => ({
|
|
id: `citation-column-${index}`,
|
|
title,
|
|
connectionId,
|
|
source: {
|
|
platform: "twitter",
|
|
kind: "search",
|
|
query: `citation-query-${index}`,
|
|
product: "Latest",
|
|
following: false,
|
|
},
|
|
}));
|
|
columns.push(source);
|
|
const run: ResearchRun = {
|
|
id: "973ae084-18ed-4674-8899-c75851e51d1b",
|
|
topic: "引用を確認",
|
|
status: "complete",
|
|
startedAt: Date.now(),
|
|
deckVersion: 1,
|
|
message: "",
|
|
deck: { id: "citation-deck", title: "引用付きの調査", columns },
|
|
messages: [
|
|
{
|
|
id: "answer",
|
|
role: "assistant",
|
|
text: "## 調査結果\n\n**重要な発見**です。\n\n- [根拠の投稿](https://x.com/author/status/123456789?s=20)\n\n| 観点 | 結果 |\n| --- | --- |\n| 投稿 | 確認済み |\n\n`コード`も表示します。",
|
|
},
|
|
],
|
|
citations: [
|
|
{
|
|
column: source,
|
|
post: {
|
|
key: "twitter:123456789",
|
|
nativeId: "123456789",
|
|
platform: "twitter",
|
|
url: "https://twitter.com/author/status/123456789",
|
|
text: "AIが調査時に取得した引用本文",
|
|
author: { name: "引用元の著者", handle: "author" },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
await page.route("**/api/research/events*", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/event-stream",
|
|
body: `data: ${JSON.stringify({ configured: true, run })}\n\n`,
|
|
}),
|
|
);
|
|
await page.goto(`/deck?run=${run.id}`);
|
|
await showResearch(page);
|
|
const chat = page.getByRole("log", { name: "Research conversation" });
|
|
await expect(chat.getByRole("heading", { name: "調査結果" })).toBeVisible();
|
|
await expect(chat.getByRole("heading", { name: "調査結果" })).toHaveCSS("font-weight", "600");
|
|
await expect(chat.locator("ul")).toHaveCSS("list-style-type", "disc");
|
|
await expect(chat.locator("strong").filter({ hasText: "重要な発見" })).toBeVisible();
|
|
await expect(chat.getByRole("table")).toBeVisible();
|
|
await expect(page.getByText("AIが調査時に取得した引用本文", { exact: true })).toHaveCount(0);
|
|
await chat.getByRole("link", { name: "根拠の投稿" }).click();
|
|
const card = page.locator(
|
|
'.research-post[data-post-url="https://twitter.com/author/status/123456789"]',
|
|
);
|
|
await expect(card).toBeVisible();
|
|
await expect(card).toHaveClass(/citation-highlight/);
|
|
await expect(card).toBeFocused();
|
|
await expect(card).toContainText("Post saved during research");
|
|
await expect(card).toContainText("AIが調査時に取得した引用本文");
|
|
expect(
|
|
await page.locator(".deck-board").evaluate((element) => element.scrollLeft),
|
|
).toBeGreaterThan(0);
|
|
expect(page.context().pages()).toHaveLength(1);
|
|
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
|
const bookmarked = new URL(page.url());
|
|
bookmarked.searchParams.set("run", run.id);
|
|
bookmarked.searchParams.set("deck", "citation-deck");
|
|
const submitted: string[] = [];
|
|
page.on("request", (request) => {
|
|
if (request.method() === "POST") submitted.push(request.url());
|
|
});
|
|
await page.goto(bookmarked.toString());
|
|
await expect(card).toBeVisible();
|
|
await expect(card).toHaveClass(/citation-highlight/);
|
|
await expect(page).toHaveURL(/citation=twitter%3A123456789/);
|
|
await expect(page.locator(".deck-column")).toHaveCount(3);
|
|
expect(submitted).toEqual([]);
|
|
expect((await a11y().analyze()).violations).toEqual([]);
|
|
});
|
|
|
|
test("explains unconfigured Codex and prevents starting research without affecting the deck", async ({
|
|
page,
|
|
a11y,
|
|
}) => {
|
|
await page.goto("/deck");
|
|
const heading = page.getByRole("heading", { level: 1 });
|
|
await expect(heading).toBeVisible();
|
|
const originalTitle = await heading.textContent();
|
|
await showResearch(page);
|
|
const panel = page.getByRole("complementary", { name: "Research chat" });
|
|
await expect(panel).toBeVisible();
|
|
await expect(
|
|
panel.getByText("The connection to Codex on your home server is not configured."),
|
|
).toBeVisible();
|
|
await expect(panel.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
|
|
await expect(panel.getByLabel("Message", { exact: true })).toBeDisabled();
|
|
const accessibility = await a11y().analyze();
|
|
expect(accessibility.violations).toEqual([]);
|
|
await closeMobileSidebar(page);
|
|
await expect(heading).toHaveText(originalTitle ?? "");
|
|
await showDeckManagement(page);
|
|
await page.getByRole("button", { name: "Create deck", exact: true }).click();
|
|
await page.getByLabel("New deck name").fill("手動の調査");
|
|
await page.getByRole("button", { name: "Create", exact: true }).click();
|
|
await closeMobileSidebar(page);
|
|
await expect(heading).toHaveText("手動の調査");
|
|
await showResearch(page);
|
|
await expect(panel).toBeVisible();
|
|
});
|
|
|
|
test("keeps browser conversation selection independent and restores it on reload", async ({
|
|
page,
|
|
browser,
|
|
context,
|
|
}) => {
|
|
const { openDatabase } = await import("../../src/features/storage/database.server");
|
|
const { createResearchStore } = await import("../../src/features/research/repository.server");
|
|
const db = openDatabase(process.env.TWITTER_LITE_E2E_DB_PATH ?? "");
|
|
const store = createResearchStore(db);
|
|
const first = crypto.randomUUID();
|
|
const second = crypto.randomUUID();
|
|
for (const [id, text] of [
|
|
[first, "Browser one conversation"],
|
|
[second, "Browser two conversation"],
|
|
] as const) {
|
|
store.save({
|
|
id,
|
|
topic: text,
|
|
status: "complete",
|
|
startedAt: Date.now(),
|
|
deckVersion: 0,
|
|
message: text,
|
|
messages: [{ id: `${id}-message`, role: "assistant", text }],
|
|
citations: [],
|
|
});
|
|
}
|
|
const other = await browser.newContext({
|
|
storageState: await context.storageState(),
|
|
extraHTTPHeaders: { "Tailscale-User-Login": "[email protected]" },
|
|
});
|
|
try {
|
|
const secondPage = await other.newPage();
|
|
await page.goto(`/deck?run=${first}`, { waitUntil: "domcontentloaded" });
|
|
await showResearch(page);
|
|
await secondPage.goto(`/deck?run=${second}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await showResearch(secondPage);
|
|
await expect(page.getByRole("log", { name: "Research conversation" })).toContainText(
|
|
"Browser one conversation",
|
|
);
|
|
await expect(secondPage.getByRole("log", { name: "Research conversation" })).toContainText(
|
|
"Browser two conversation",
|
|
);
|
|
await secondPage.getByRole("button", { name: "New chat", exact: true }).click();
|
|
await expect(secondPage).toHaveURL(/run=new/);
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await showResearch(page);
|
|
await expect(page).toHaveURL(new RegExp(`run=${first}`));
|
|
await expect(page.getByRole("log", { name: "Research conversation" })).toContainText(
|
|
"Browser one conversation",
|
|
);
|
|
} finally {
|
|
await other.close();
|
|
db.$client.prepare("DELETE FROM research_sessions WHERE id IN (?, ?)").run(first, second);
|
|
db.$client.close();
|
|
}
|
|
});
|