deer-flow/frontend/tests/e2e/subtask-card.spec.ts
Zeren Wang fd22f1a31d
fix(frontend): truncate long subtask card titles to a single line (#5136)
* fix(frontend): truncate long subtask card titles to a single line

The subtask card header rendered task.description without any width
constraint; when a provider omits the optional description, the full
task prompt becomes the title and overflows the card.

Wrap the title in a truncating span (full text remains available via
the title tooltip and the expanded card body), give the step min-w-0
flex-1, and pin the status cluster with shrink-0 so overflow resolves
at the title.

Add an e2e test asserting a long prompt renders with the truncate
class, a real ellipsis (scrollWidth > clientWidth), and single-line
height.

* fix(frontend): keep subtask card status cluster shrinkable on narrow viewports

The shrink-0 status cluster could not shrink below its max-content (model
label + usage + status pill, up to ~456px with a long tool-call
description), so on narrow viewports it overflowed the header row while the
title collapsed to zero. Drop shrink-0 and add min-w-0 to both the cluster
and the pill (the pill's min-content is the status text's longest
unbreakable word, so one min-w-0 was not enough), and floor the title at
min-w-24 so it stays visible.

Also extend the e2e spec per review: an in_progress shimmer truncation test
(held-open SSE stream keeps the card running), a 375px no-overflow
assertion for both the resting and running card, and a pixel-budget
single-line check instead of parseFloat(lineHeight) which NaNs on the
'normal' keyword.

* test(frontend): honest fixture text and explicit visibility timeout in subtask spec

Review nits: the long-title fixture lifted the stopped test's human turn
whose text narrates the stop scenario; give it its own LONG_TASK_USER_TEXT
and override content alongside id and tool_calls. Add the missing 15s
timeout on the running-375px title visibility assertion so a future
reorder doesn't turn the 5s default into a cold-start flake.
2026-09-02 00:08:38 +08:00

358 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { expect, test, type Locator } from "@playwright/test";
import {
mockLangGraphAPI,
MOCK_RUN_ID,
MOCK_THREAD_ID,
} from "./utils/mock-api";
const STOPPED_TASK_PROMPT =
"Investigate why the stopped subtask card should not remain running after reload.";
const LONG_TASK_PROMPT =
"你的任务:分析 bytedance/deer-flow 前端核心线程同步文件 `frontend/src/core/threads/hooks.ts`(约 108KB提取其消息流同步机制的关键信息。背景用户在 DeerFlow 前端发现子代理任务卡片标题过长,需要确认截断行为。请重点关注消息合并、流式节流与本地排序逻辑,并输出结构化结论。";
const LONG_RUNNING_STATUS =
"Writing the quarterly infrastructure cost breakdown report to workspace/reports/q3-infra-cost-breakdown-final-v2.md";
const LONG_TASK_USER_TEXT =
"Analyze the thread sync hooks and report how the subtask card renders.";
const stoppedSubtaskMessages = [
{
type: "human",
id: "msg-human-stopped-subtask",
content: [
{
type: "text",
text: "Start a subtask and then stop before the task tool returns.",
},
],
},
{
type: "ai",
id: "msg-ai-stopped-subtask",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: [
{
id: "call-stopped-subtask",
name: "task",
args: {
subagent_type: "general-purpose",
prompt: STOPPED_TASK_PROMPT,
},
type: "tool_call",
},
],
invalid_tool_calls: [],
},
];
const longSubtaskThread = {
thread_id: MOCK_THREAD_ID,
title: "Long subtask title",
updated_at: "2026-06-18T12:00:00Z",
messages: [
{
// Reuse the stopped test's message *shape* only: its human text narrates
// the stop scenario, which none of the long-title tests exercise.
...stoppedSubtaskMessages[0],
id: "msg-human-long-subtask",
content: [{ type: "text", text: LONG_TASK_USER_TEXT }],
},
{
...stoppedSubtaskMessages[1],
id: "msg-ai-long-subtask",
tool_calls: [
{
id: "call-long-subtask",
name: "task",
args: {
subagent_type: "general-purpose",
prompt: LONG_TASK_PROMPT,
},
type: "tool_call",
},
],
},
],
};
async function expectSingleLineEllipsis(title: Locator) {
await expect(title).toHaveClass(/truncate/);
const metrics = await title.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
height: el.getBoundingClientRect().height,
}));
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
// A pixel budget instead of `parseFloat(lineHeight)`: the `normal` keyword
// parses to NaN and fails with an opaque "Received NaN" rather than pointing
// at the layout. One line of `text-sm` is 20px; 32px leaves slack without
// admitting a wrapped second line.
expect(metrics.height).toBeLessThanOrEqual(32);
}
/**
* SSE server that emits one `values` frame carrying an unresolved `task` tool
* call and then holds the connection open, keeping `thread.isLoading` true so
* the subtask card renders its running (shimmer) branch. Closed via
* `closeAllConnections` in test teardown.
*/
async function startRunningSubtaskStream() {
const aiMessage = {
type: "ai",
id: "msg-ai-running-subtask",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: [
{
id: "call-running-subtask",
name: "task",
args: {
subagent_type: "general-purpose",
prompt: LONG_TASK_PROMPT,
},
type: "tool_call",
},
],
invalid_tool_calls: [],
};
const server = createServer((request, response) => {
let body = "";
request.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
request.on("end", () => {
let inputMessages: unknown[] = [];
let bodyThreadId: string | undefined;
try {
const parsed = JSON.parse(body) as {
input?: { messages?: unknown[] };
thread_id?: string;
};
inputMessages = parsed.input?.messages ?? [];
bodyThreadId = parsed.thread_id;
} catch {
inputMessages = [];
}
const threadId =
/\/threads\/([^/]+)\/runs\/stream/.exec(request.url ?? "")?.[1] ??
bodyThreadId ??
MOCK_THREAD_ID;
const frames = [
{
event: "metadata",
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
},
{
event: "values",
data: { messages: [...inputMessages, aiMessage] },
},
{
// A `task_running` step whose last tool call carries a long
// description: the collapsed header's status pill renders it via
// `explainLastToolCall`, growing the right-hand cluster past the
// width of a narrow card.
event: "custom",
data: {
type: "task_running",
task_id: "call-running-subtask",
message: {
type: "ai",
id: "msg-ai-running-subtask-step",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: [
{
id: "call-running-subtask-step",
name: "write_file",
args: { description: LONG_RUNNING_STATUS },
type: "tool_call",
},
],
invalid_tool_calls: [],
},
message_index: 1,
},
},
]
.map(
(event) =>
`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`,
)
.join("");
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
});
response.write(frames);
});
});
await new Promise<void>((resolve, reject) => {
const handleError = (error: Error) => reject(error);
server.once("error", handleError);
server.listen(0, "127.0.0.1", () => {
server.off("error", handleError);
resolve();
});
});
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}/runs/stream`,
async close() {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
test.describe("Subtask card", () => {
test("shows failed after a stopped task thread is reloaded", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Stopped subtask",
updated_at: "2026-06-18T12:00:00Z",
messages: stoppedSubtaskMessages,
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
await expect(page.getByText(STOPPED_TASK_PROMPT)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Subtask failed")).toBeVisible();
await expect(page.getByText("Running subtask")).toHaveCount(0);
});
test("truncates a long task title to a single line", async ({ page }) => {
mockLangGraphAPI(page, {
threads: [longSubtaskThread],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
await expectSingleLineEllipsis(title);
});
test("keeps the header inside the card on a 375px viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 800 });
mockLangGraphAPI(page, {
threads: [longSubtaskThread],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
// The status cluster is shrinkable (`min-w-0`, per-item `truncate`), so a
// narrow card ellipsizes the cluster instead of collapsing the title to
// zero width and overflowing the row.
const row = title.locator(
"xpath=ancestor::div[contains(@class,'justify-between')][1]",
);
const rowMetrics = await row.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(rowMetrics.scrollWidth).toBeLessThanOrEqual(rowMetrics.clientWidth);
await expectSingleLineEllipsis(title);
});
test("truncates a running task title with the shimmer inline", async ({
page,
}) => {
const streamServer = await startRunningSubtaskStream();
mockLangGraphAPI(page, {
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Run a subtask with a long title");
await textarea.press("Enter");
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
// The shimmer must stay one inline text run inside the truncating span:
// `as="span"` avoids nesting the component's default <p>, and
// `className="inline"` overrides its `inline-block` so the parent span's
// nowrap/ellipsis still apply.
const shimmer = title.locator("span").first();
await expect(shimmer).toHaveCSS("display", "inline");
await expectSingleLineEllipsis(title);
} finally {
await streamServer.close();
}
});
test("keeps a running card inside the row on a 375px viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 800 });
const streamServer = await startRunningSubtaskStream();
mockLangGraphAPI(page, {
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Run a subtask with a long status");
await textarea.press("Enter");
// The long `task_running` status pushes the right-hand cluster's
// max-content past the card width. The cluster must absorb that by
// ellipsizing (`min-w-0` on both wrappers + per-item `truncate`), not by
// pinning its width with `shrink-0` and overflowing the row; the title's
// `min-w-24` floor keeps it visible instead of collapsing to zero.
const status = page.getByText(LONG_RUNNING_STATUS, { exact: true });
await expect(status).toBeVisible({ timeout: 15_000 });
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
await expectSingleLineEllipsis(title);
const pill = status.locator("xpath=..");
const row = status.locator(
"xpath=ancestor::div[contains(@class,'justify-between')][1]",
);
const metrics = await row.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
const pillMetrics = await pill.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(pillMetrics.scrollWidth).toBeGreaterThan(pillMetrics.clientWidth);
} finally {
await streamServer.close();
}
});
});