Files
worldquant-alpha-system/frontend/tests/ai.spec.ts
T

465 lines
18 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
const headers = { "X-WQ-Request": "1" };
async function login(page: Page) {
await page.goto("/");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("button", { name: "切换研究助手" }),
).toBeVisible();
}
async function configure(page: Page) {
const body = { base_url: "https://model.test/v1", model: "test-model" };
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...body, api_key: "synthetic-model-key" },
});
const result = await page.request.post("/api/v1/ai/settings/test", {
headers,
});
expect((await result.json()).ready).toBe(true);
await page.request.put("/api/v1/ai/settings", {
headers,
data: { ...body, enabled: true },
});
await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
await page.request.post("/api/v1/account/connect", { headers });
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
await page.request.post("/api/v1/sync-jobs", {
headers,
data: { kind: "full_sync" },
});
await page.request.post("/api/v1/sync-jobs", {
headers,
data: {
kind: "daily_sync",
submission: "UNSUBMITTED",
date_from: "2025-01-01",
date_to: "2025-01-01",
},
});
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/alphas")).json()).total,
)
.toBe(620);
await page.reload();
}
test("model settings, text stream, contextual query, confirmation and preserved draft", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await login(page);
await page.getByRole("button", { name: "配置", exact: true }).click();
await page
.getByRole("textbox", { name: "模型 Base URL" })
.fill("https://model.test/v1");
await page
.getByRole("textbox", { name: "模型标识", exact: true })
.fill("test-model");
await page.getByLabel("模型 API Key").fill("synthetic-model-key");
await page.getByRole("button", { name: "保存模型配置" }).click();
await expect(page.getByLabel("模型 API Key")).toHaveValue("");
await page.getByRole("button", { name: "测试连接" }).click();
await expect(page.getByText("工具调用与结果回传成功")).toBeVisible();
await page
.getByText("启用研究助手(能力测试通过后可开启)", { exact: true })
.click();
await page.getByRole("button", { name: "保存模型配置" }).click();
await page.locator(".settings-drawer .semi-sidesheet-close").click();
await configure(page);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
await page.getByRole("button", { name: "查询", exact: true }).click();
await page.locator(".alpha-link").click();
await page.getByRole("tab", { name: "研究信息" }).click();
await page
.getByRole("textbox", { name: "研究备注" })
.fill("尚未保存的人工研究草稿");
// A global launcher must remain reachable when the detail mask is open.
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
await expect(chat.locator(".research-ai-reference")).toContainText(
"TEST0001",
);
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await input.fill("修改当前 Alpha 的备注");
await chat.getByRole("button", { name: "发送", exact: true }).click();
await expect(chat.getByRole("button", { name: "确认执行" })).toBeEnabled();
const before = await (
await page.request.get("/api/v1/alphas/TEST0001")
).json();
expect(before.research.note).not.toBe("AI 测试研究记录");
await page.screenshot({
path: "../output/playwright/ai-approval.png",
fullPage: true,
});
await chat.getByRole("button", { name: "确认执行" }).click();
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/alphas/TEST0001")).json())
.research.note,
)
.toBe("AI 测试研究记录");
await expect(chat.locator(".ai-run-state")).toContainText("已完成");
await expect(page.getByRole("textbox", { name: "研究备注" })).toHaveValue(
"尚未保存的人工研究草稿",
);
await expect(page.getByText(/研究记录已被其他操作更新/)).toBeVisible();
await input.fill("筛选换手率不超过 15% 的 Alpha");
await chat.getByRole("button", { name: "发送", exact: true }).click();
await expect(chat.getByRole("button", { name: "应用到列表" })).toBeVisible();
const conversation = (
await (await page.request.get("/api/v1/ai/conversations")).json()
)[0];
const history = await (
await page.request.get(`/api/v1/ai/conversations/${conversation.id}`)
).json();
expect(JSON.stringify(history)).not.toContain("尚未保存的人工研究草稿");
expect(history.runs.at(-1).tools[0].result.filters.turnover_max).toBe(0.15);
await page.reload();
await page.getByRole("button", { name: "切换研究助手" }).click();
await expect(
chat.getByText("筛选换手率不超过 15% 的 Alpha", { exact: true }),
).toBeVisible();
await chat.getByRole("button", { name: "应用到列表" }).click();
await expect(page.getByRole("textbox", { name: "搜索 Alpha" })).toHaveValue(
"",
);
await page.screenshot({
path: "../output/playwright/ai-query.png",
fullPage: true,
});
expect(errors).toEqual([]);
});
test("collapse, navigation, reload, narrow viewport and cancellation preserve execution", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
await chat.getByRole("textbox", { name: "发送给研究助手" }).fill("SLOW");
await chat.getByRole("button", { name: "发送", exact: true }).click();
await expect(chat.getByText("正在查询", { exact: true })).toBeVisible();
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
await page.getByRole("button", { name: "查看个人信息", exact: true }).click();
await page.reload();
await page.getByRole("button", { name: "切换研究助手" }).click();
await expect(
chat.getByText("正在查询,查询完成。", { exact: true }),
).toBeVisible({ timeout: 10000 });
await page.setViewportSize({ width: 850, height: 900 });
await chat
.getByRole("textbox", { name: "发送给研究助手" })
.fill("SLOW again");
await chat.getByRole("button", { name: "发送", exact: true }).click();
await expect(chat.getByRole("button", { name: "停止生成" })).toBeVisible();
await chat.getByRole("button", { name: "停止生成" }).click();
await expect(chat.locator(".ai-run-state")).toContainText("已停止");
await chat.getByRole("textbox", { name: "发送给研究助手" }).focus();
await page.keyboard.press("Escape");
await expect(chat).not.toBeVisible();
await page.getByRole("button", { name: "用户菜单" }).click();
await page.getByRole("button", { name: "退出登录" }).click();
await expect(
page.getByRole("heading", { name: "登录研究工作空间" }),
).toBeVisible();
expect((await page.request.get("/api/v1/ai/conversations")).status()).toBe(
401,
);
});
test("Lark workspace keeps pagination, account details and chat usable at every width", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await expect(page.locator(".alpha-link")).toHaveCount(25);
await expect(page.locator(".app-sidebar")).toHaveCSS("width", "230px");
await expect(page.locator(".sidebar-nav-item.active")).toHaveCSS(
"background-color",
"rgb(234, 240, 255)",
);
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
for (const width of [1920, 1440, 1280, 850, 390]) {
await page.setViewportSize({ width, height: 900 });
const footer = page.locator('footer[aria-label="Alpha 分页"]');
await expect(footer).toBeVisible();
const before = (await footer.boundingBox())!;
expect(before.y + before.height).toBeLessThanOrEqual(900);
expect(before.y + before.height).toBeGreaterThan(864);
await page
.locator(".alpha-page-view:not([hidden]) .semi-table-body")
.evaluate((el) => el.scrollTo({ top: 1000 }));
expect((await footer.boundingBox())!.y).toBeCloseTo(before.y, 0);
await page.getByRole("button", { name: "切换研究助手" }).click();
await expect(chat).toBeVisible();
await expect(chat).toHaveCSS("background-color", "rgb(255, 255, 255)");
const box = (await chat.boundingBox())!;
expect(box.x + box.width).toBeCloseTo(width, 0);
if (width >= 1440) {
expect(
(await page.locator(".main-shell").boundingBox())!.width,
).toBeGreaterThan(600);
expect(
(await footer.boundingBox())!.x + (await footer.boundingBox())!.width,
).toBeLessThan(box.x);
} else {
await expect(page.locator(".main-shell")).toHaveAttribute("inert", "");
await expect(page.locator(".ai-mask")).toBeVisible();
}
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await page.screenshot({
path: `../output/playwright/lark-chat-${width}.png`,
});
await chat.getByRole("button", { name: "收起研究助手" }).click();
}
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
await page.getByRole("button", { name: "查看个人信息", exact: true }).click();
await expect(page.getByRole("button", { name: "连接设置" })).toHaveAttribute(
"aria-expanded",
"false",
);
await expect(page.locator(".permission-list li")).toHaveCount(10);
await expect(page.getByRole("row", { name: /回测/ })).toContainText(
"10,000(本地设定)",
);
await page.getByRole("button", { name: "配置", exact: true }).click();
await page
.getByRole("textbox", { name: "模型标识", exact: true })
.fill("保留模型设置草稿");
await page.locator(".settings-drawer .semi-sidesheet-close").click();
await page.getByRole("button", { name: "切换研究助手" }).click();
const resize = chat.getByRole("separator", { name: "调整助手宽度" });
await resize.focus();
for (let i = 0; i < 11; i++) await resize.press("ArrowLeft");
expect((await chat.boundingBox())!.width).toBe(640);
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await input.fill("保留聊天草稿");
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("button", { name: "用户菜单", exact: true }).click();
await page.getByRole("button", { name: "查看个人信息", exact: true }).click();
await expect(input).toHaveText("保留聊天草稿");
await chat.getByRole("button", { name: "收起研究助手" }).click();
await page.getByRole("button", { name: "配置", exact: true }).click();
await expect(
page.getByRole("textbox", { name: "模型标识", exact: true }),
).toHaveValue("保留模型设置草稿");
await page.screenshot({
path: "../output/playwright/settings-drawer.png",
animations: "disabled",
});
await page.locator(".settings-drawer .semi-sidesheet-close").click();
await page.screenshot({
path: "../output/playwright/lark-account.png",
fullPage: true,
});
});
test("Markdown streams incomplete code, finishes safely and restores history", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await login(page);
await configure(page);
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const prefix = "## 研究结论\n\n**正在生成**\n\n```python\nrank(close)";
const suffix =
"\n```\n\n| 指标 | 值 |\n| --- | --- |\n| Sharpe | 1.2 |\n\n[文档](https://example.com)\n\n<script>alert('unsafe')</script>\n\n[危险](javascript:alert(1))";
await page.evaluate(() => {
const original = window.fetch.bind(window);
window.fetch = async (url, init) => {
if (!String(url).match(/\/ai\/conversations\/[^/]+\/runs$/))
return original(url, init);
return new Response(
new ReadableStream({
start(controller) {
Object.assign(window, {
pushMarkdown: (events: unknown[], done = false) => {
for (const event of events)
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(event)}\n\n`,
),
);
if (done) {
controller.enqueue(
new TextEncoder().encode("data: [DONE]\n\n"),
);
controller.close();
}
},
});
},
}),
{
headers: {
"content-type": "text/event-stream",
"x-vercel-ai-ui-message-stream": "v1",
},
},
);
};
});
await chat
.getByRole("textbox", { name: "发送给研究助手" })
.fill("**用户原文**");
await chat.getByRole("button", { name: "发送", exact: true }).click();
await page.waitForFunction(
() => typeof (window as any).pushMarkdown === "function",
);
await page.evaluate(
(delta) =>
(window as any).pushMarkdown([
{ type: "start", messageId: "markdown-answer" },
{ type: "text-start", id: "part" },
{ type: "text-delta", id: "part", delta },
]),
prefix,
);
await expect(chat.getByRole("heading", { name: "研究结论" })).toBeVisible();
await expect(chat.locator(".ai-markdown pre")).toContainText("rank(close)");
await expect(chat.locator(".ai-messages")).toHaveAttribute(
"aria-busy",
"true",
);
await expect(chat.locator(".ai-message.user .ai-text")).toHaveText(
"**用户原文**",
);
await page.route("**/api/v1/ai/conversations/*", async (route) => {
if (route.request().method() !== "GET") return route.continue();
await route.fulfill({
json: {
id: route.request().url().split("/").at(-1),
title: "Markdown",
runs: [],
messages: [
{
id: "markdown-answer",
role: "assistant",
parts: [{ type: "text", text: prefix + suffix }],
},
],
},
});
});
await page.evaluate(
(delta) =>
(window as any).pushMarkdown(
[
{ type: "text-delta", id: "part", delta },
{ type: "text-end", id: "part" },
{ type: "finish" },
],
true,
),
suffix,
);
await expect(chat.locator(".ai-messages")).toHaveAttribute(
"aria-busy",
"false",
);
await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible();
await expect(
chat.getByRole("link", { name: "Link: https://example.com", exact: true }),
).toHaveAttribute("href", "https://example.com");
await expect(
chat.locator(".ai-markdown script, .ai-markdown a[href^='javascript:']"),
).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 900 });
expect(
await chat
.locator(".ai-messages")
.evaluate((el) => el.scrollWidth <= el.clientWidth),
).toBe(true);
await page.screenshot({ path: "../output/playwright/ai-markdown.png" });
await page.reload();
await page.getByRole("button", { name: "切换研究助手" }).click();
await expect(chat.getByRole("heading", { name: "研究结论" })).toBeVisible();
await expect(chat.getByRole("cell", { name: "1.2" })).toBeVisible();
expect(errors).toEqual([]);
});
test("AI composer preserves literal text, IME input and drafts while generating", async ({
page,
}) => {
await login(page);
await configure(page);
await page.getByRole("button", { name: "切换研究助手" }).click();
const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click();
const input = chat.getByRole("textbox", { name: "发送给研究助手" });
await expect(input.locator("p").first()).toHaveAttribute(
"data-placeholder",
"描述研究问题或要执行的操作…",
);
const sent: string[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
/\/ai\/conversations\/[^/]+\/runs$/.test(request.url())
)
sent.push(request.postDataJSON().message);
});
await chat
.getByRole("button", { name: "查询当前研究对象与已有结果", exact: true })
.click();
await expect(input).toHaveText("查询当前研究对象与已有结果");
expect(sent).toEqual([]);
await input.fill("SLOW <字段>");
await input.dispatchEvent("keydown", {
key: "Enter",
code: "Enter",
isComposing: true,
});
expect(sent).toEqual([]);
await input.press("Shift+Enter");
await input.pressSequentially("next");
await input.press("Enter");
await expect.poll(() => sent).toEqual(["SLOW <字段>\nnext"]);
await expect(chat.getByRole("button", { name: "停止生成" })).toBeVisible();
await input.fill("下一轮 <收益> 假设");
await chat.getByRole("button", { name: "停止生成" }).click();
await expect(chat.locator(".ai-run-state")).toContainText("已停止");
await expect(input).toHaveText("下一轮 <收益> 假设");
await input.fill("a".repeat(20001));
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
await input.press("Enter");
expect(sent).toHaveLength(1);
await input.fill("");
await expect(
chat.getByRole("button", { name: "发送", exact: true }),
).toBeDisabled();
});