465 lines
18 KiB
TypeScript
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();
|
|
});
|