mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 11:36:17 +00:00
* fix(scheduled-tasks): preserve unchanged one-time execution instants Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * fix(scheduled-tasks): reset edit state before task remounts Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * test(scheduled-tasks): exercise timezone fallback on UTC runners Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --------- Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api";
|
|
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test("scheduled tasks page is reachable from sidebar", async ({ page }) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-1",
|
|
thread_id: "thread-1",
|
|
title: "Daily summary",
|
|
prompt: "Summarize thread",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/chats/new");
|
|
await page.getByRole("link", { name: /scheduled tasks/i }).click();
|
|
await page.waitForURL("**/workspace/scheduled-tasks");
|
|
await expect(page).toHaveURL(/workspace\/scheduled-tasks/);
|
|
await expect(
|
|
page.getByRole("button", { name: /Daily summary/i }),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("scheduled-task-runs")).toContainText("0 runs");
|
|
});
|
|
|
|
test("thread page links to filtered scheduled tasks", async ({ page }) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [
|
|
{
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "Thread with schedules",
|
|
updated_at: "2025-06-01T12:00:00Z",
|
|
},
|
|
],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-1",
|
|
thread_id: MOCK_THREAD_ID,
|
|
title: "Thread task",
|
|
prompt: "Summarize thread",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
|
await page
|
|
.locator("header")
|
|
.getByRole("link", { name: /scheduled tasks/i })
|
|
.click();
|
|
await page.waitForURL(new RegExp(`thread_id=${MOCK_THREAD_ID}`));
|
|
});
|
|
|
|
test("user can create a scheduled task from the page", async ({ page }) => {
|
|
mockLangGraphAPI(page, { threads: [], scheduledTasks: [] });
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
const createForm = page.getByTestId("scheduled-task-create-form");
|
|
await createForm.getByRole("button", { name: "One-time" }).click();
|
|
await createForm.getByLabel("Run at").fill("2026-07-02T09:00");
|
|
await createForm.getByPlaceholder("Task title").fill("Created from UI");
|
|
await createForm.getByPlaceholder("Prompt").fill("Summarize thread");
|
|
await createForm.getByRole("button", { name: "Create" }).click();
|
|
await expect(
|
|
page.getByRole("button", { name: /Created from UI/i }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("scheduled-task-detail").getByText("Summarize thread"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("duplicate fills the create form without creating a task", async ({
|
|
page,
|
|
}) => {
|
|
let createRequests = 0;
|
|
page.on("request", (request) => {
|
|
if (
|
|
request.method() === "POST" &&
|
|
new URL(request.url()).pathname.endsWith("/api/scheduled-tasks")
|
|
) {
|
|
createRequests += 1;
|
|
}
|
|
});
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-copy-source",
|
|
thread_id: "thread-copy-source",
|
|
context_mode: "reuse_thread",
|
|
title: "Daily summary",
|
|
prompt: "Summarize this conversation",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 18 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-08-28T18:00:00Z",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_thread_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-08-01T00:00:00Z",
|
|
updated_at: "2026-08-01T00:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await page
|
|
.getByTestId("scheduled-task-detail")
|
|
.getByRole("button", { name: "Duplicate" })
|
|
.click();
|
|
|
|
const createForm = page.getByTestId("scheduled-task-create-form");
|
|
await expect(createForm.getByPlaceholder("Task title")).toHaveValue(
|
|
"Daily summary (Copy)",
|
|
);
|
|
await expect(createForm.getByPlaceholder("Prompt")).toHaveValue(
|
|
"Summarize this conversation",
|
|
);
|
|
await expect(createForm.getByPlaceholder("Thread ID")).toHaveValue(
|
|
"thread-copy-source",
|
|
);
|
|
await expect(createForm.getByTestId("schedule-preview")).toHaveText(
|
|
"Every day at 18:00 (UTC)",
|
|
);
|
|
await expect(createForm.getByPlaceholder("Task title")).toBeFocused();
|
|
expect(createRequests).toBe(0);
|
|
});
|
|
|
|
test("reuse-thread tasks explain their context and busy-thread queue behavior", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-reuse",
|
|
thread_id: "thread-1",
|
|
context_mode: "reuse_thread",
|
|
title: "Conversation summary",
|
|
prompt: "Summarize this conversation",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 18 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T18:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
|
|
const detailNotice = page
|
|
.getByTestId("scheduled-task-detail")
|
|
.getByRole("alert");
|
|
await expect(detailNotice).toContainText(
|
|
"Uses this thread's conversation history",
|
|
);
|
|
await expect(detailNotice).toContainText(
|
|
"queues this occurrence and starts it when the thread is available",
|
|
);
|
|
|
|
const createForm = page.getByTestId("scheduled-task-create-form");
|
|
await expect(createForm.getByRole("alert")).toHaveCount(0);
|
|
await createForm.getByRole("button", { name: "Reuse thread" }).click();
|
|
|
|
const createNotice = createForm.getByRole("alert");
|
|
await expect(createNotice).toContainText(
|
|
"Uses this thread's conversation history",
|
|
);
|
|
await expect(createNotice).toContainText(
|
|
"It fails if the configured queue wait limit is exceeded",
|
|
);
|
|
|
|
await createForm.getByRole("button", { name: "Fresh thread" }).click();
|
|
await expect(createForm.getByRole("alert")).toHaveCount(0);
|
|
});
|
|
|
|
test("user can pause a scheduled task from the detail pane", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-1",
|
|
thread_id: "thread-1",
|
|
title: "Pausable task",
|
|
prompt: "Summarize thread",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
const detail = page.getByTestId("scheduled-task-detail");
|
|
await detail.getByRole("button", { name: "Pause" }).click();
|
|
await expect(page.getByTestId("scheduled-task-item-task-1")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("scheduled-task-item-task-1").getByText(/paused/i),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("trigger shows a run entry in the detail pane", async ({ page }) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-1",
|
|
thread_id: "thread-1",
|
|
title: "Triggerable task",
|
|
prompt: "Summarize thread",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await page.getByRole("button", { name: "Trigger now" }).click();
|
|
await expect(page.getByTestId("scheduled-task-runs")).toContainText("1 run");
|
|
await expect(
|
|
page.getByTestId("scheduled-task-run-list").getByText(/Manual · Success/i),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("detail pane falls back to a visible task after filters hide the selected task", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-enabled",
|
|
thread_id: "thread-1",
|
|
title: "Enabled task",
|
|
prompt: "Enabled prompt",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
{
|
|
id: "task-paused",
|
|
thread_id: "thread-2",
|
|
title: "Paused task",
|
|
prompt: "Paused prompt",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 10 * * *" },
|
|
timezone: "UTC",
|
|
status: "paused",
|
|
next_run_at: "2026-07-02T02:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await page.getByTestId("scheduled-task-item-task-paused").click();
|
|
await expect(
|
|
page.getByTestId("scheduled-task-detail").getByText("Paused task"),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Enabled", exact: true }).click();
|
|
|
|
await expect(
|
|
page.getByTestId("scheduled-task-detail").getByText("Enabled task"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("scheduled-task-item-task-enabled"),
|
|
).toBeVisible();
|
|
await expect(page.getByTestId("scheduled-task-item-task-paused")).toHaveCount(
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("create posts the default lead_agent assistant_id", async ({ page }) => {
|
|
let createBody: Record<string, unknown> | null = null;
|
|
page.on("request", (request) => {
|
|
if (
|
|
request.method() === "POST" &&
|
|
new URL(request.url()).pathname.endsWith("/api/scheduled-tasks")
|
|
) {
|
|
createBody = request.postDataJSON() as Record<string, unknown>;
|
|
}
|
|
});
|
|
mockLangGraphAPI(page, { threads: [], scheduledTasks: [] });
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
const createForm = page.getByTestId("scheduled-task-create-form");
|
|
await expect(
|
|
createForm.getByTestId("scheduled-task-create-agent"),
|
|
).toContainText(/Default agent \(lead_agent\)/i);
|
|
await createForm.getByRole("button", { name: "One-time" }).click();
|
|
await createForm.getByLabel("Run at").fill("2026-07-02T09:00");
|
|
await createForm.getByPlaceholder("Task title").fill("Agent pin");
|
|
await createForm.getByPlaceholder("Prompt").fill("Summarize thread");
|
|
await createForm.getByRole("button", { name: "Create" }).click();
|
|
await expect(page.getByRole("button", { name: /Agent pin/i })).toBeVisible();
|
|
expect(createBody).toMatchObject({ assistant_id: "lead_agent" });
|
|
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
|
|
/Default agent \(lead_agent\)/i,
|
|
);
|
|
});
|
|
|
|
test("duplicate copies the source task assistant into the create form", async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-copy-agent",
|
|
thread_id: null,
|
|
context_mode: "fresh_thread_per_run",
|
|
assistant_id: "research-bot",
|
|
title: "Research digest",
|
|
prompt: "Summarize papers",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
|
|
"research-bot",
|
|
);
|
|
await page
|
|
.getByTestId("scheduled-task-detail")
|
|
.getByRole("button", { name: "Duplicate" })
|
|
.click();
|
|
await expect(page.getByTestId("scheduled-task-create-agent")).toContainText(
|
|
"research-bot",
|
|
);
|
|
});
|
|
|
|
test("edit omits assistant_id when the agent is unchanged", async ({
|
|
page,
|
|
}) => {
|
|
let patchBody: Record<string, unknown> | null = null;
|
|
page.on("request", (request) => {
|
|
if (
|
|
request.method() === "PATCH" &&
|
|
new URL(request.url()).pathname.includes("/api/scheduled-tasks/")
|
|
) {
|
|
patchBody = request.postDataJSON() as Record<string, unknown>;
|
|
}
|
|
});
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-edit-agent",
|
|
thread_id: null,
|
|
context_mode: "fresh_thread_per_run",
|
|
assistant_id: "research-bot",
|
|
title: "Research digest",
|
|
prompt: "Summarize papers",
|
|
schedule_type: "cron",
|
|
schedule_spec: { cron: "0 9 * * *" },
|
|
timezone: "UTC",
|
|
status: "enabled",
|
|
next_run_at: "2026-07-02T01:00:00+00:00",
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00+00:00",
|
|
updated_at: "2026-07-01T00:00:00+00:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await page
|
|
.getByTestId("scheduled-task-detail")
|
|
.getByRole("button", { name: "Edit" })
|
|
.click();
|
|
await page.getByPlaceholder("Edit title").fill("Renamed digest");
|
|
await page.getByRole("button", { name: "Save edit" }).click();
|
|
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
|
|
"Renamed digest",
|
|
);
|
|
expect(patchBody).toMatchObject({ title: "Renamed digest" });
|
|
expect(patchBody).not.toHaveProperty("assistant_id");
|
|
});
|
|
|
|
for (const runAt of ["2026-11-01T06:30:00Z", "2027-06-01T12:30:45Z"]) {
|
|
test(`editing title and prompt preserves one-time instant ${runAt}`, async ({
|
|
page,
|
|
}) => {
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: [
|
|
{
|
|
id: "task-instant",
|
|
thread_id: null,
|
|
context_mode: "fresh_thread_per_run",
|
|
title: "Original title",
|
|
prompt: "Original prompt",
|
|
schedule_type: "once",
|
|
schedule_spec: { run_at: runAt },
|
|
timezone: "America/New_York",
|
|
status: "enabled",
|
|
next_run_at: runAt,
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
updated_at: "2026-07-01T00:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
const detail = page.getByTestId("scheduled-task-detail");
|
|
await detail.getByRole("button", { name: "Edit", exact: true }).click();
|
|
await detail.getByPlaceholder("Edit title").fill("Renamed task");
|
|
await detail.getByPlaceholder("Edit prompt").fill("Updated prompt");
|
|
const submitted = page.waitForRequest(
|
|
(request) =>
|
|
request.method() === "PATCH" &&
|
|
new URL(request.url()).pathname === "/api/scheduled-tasks/task-instant",
|
|
);
|
|
await detail
|
|
.getByRole("button", { name: "Save edit", exact: true })
|
|
.click();
|
|
const request = await submitted;
|
|
expect(request.postDataJSON()).toMatchObject({
|
|
title: "Renamed task",
|
|
prompt: "Updated prompt",
|
|
schedule_spec: { run_at: runAt },
|
|
timezone: "America/New_York",
|
|
});
|
|
});
|
|
}
|
|
|
|
test("switching tasks during editing saves only the selected task's schedule", async ({
|
|
page,
|
|
}) => {
|
|
const tasks = [
|
|
{
|
|
id: "switch-a",
|
|
title: "Task A",
|
|
prompt: "Prompt A",
|
|
timezone: "America/New_York",
|
|
runAt: "2026-03-01T05:00:00+00:00",
|
|
},
|
|
{
|
|
id: "switch-b",
|
|
title: "Task B",
|
|
prompt: "Prompt B",
|
|
timezone: "Asia/Shanghai",
|
|
runAt: "2027-06-01T12:30:45+00:00",
|
|
},
|
|
];
|
|
mockLangGraphAPI(page, {
|
|
threads: [],
|
|
scheduledTasks: tasks.map((task) => ({
|
|
id: task.id,
|
|
title: task.title,
|
|
prompt: task.prompt,
|
|
timezone: task.timezone,
|
|
thread_id: null,
|
|
context_mode: "fresh_thread_per_run",
|
|
schedule_type: "once",
|
|
schedule_spec: { run_at: task.runAt },
|
|
status: "enabled",
|
|
next_run_at: task.runAt,
|
|
last_run_at: null,
|
|
last_run_id: null,
|
|
last_error: null,
|
|
run_count: 0,
|
|
created_at: "2026-07-01T00:00:00Z",
|
|
updated_at: "2026-07-01T00:00:00Z",
|
|
})),
|
|
});
|
|
await page.goto("/workspace/scheduled-tasks");
|
|
await page.getByTestId("scheduled-task-item-switch-a").click();
|
|
const detail = page.getByTestId("scheduled-task-detail");
|
|
await detail.getByRole("button", { name: "Edit", exact: true }).click();
|
|
await detail.getByPlaceholder("Edit title").fill("Unsaved A");
|
|
await page.getByTestId("scheduled-task-item-switch-b").click();
|
|
await expect(detail.getByPlaceholder("Edit title")).toHaveValue("Task B");
|
|
await expect(detail.getByPlaceholder("Edit prompt")).toHaveValue("Prompt B");
|
|
await expect(detail.getByLabel("Run at")).toHaveValue("2027-06-01T20:30");
|
|
await detail.getByLabel("Run at").fill("2027-06-01T21:30");
|
|
await detail.getByLabel("Run at").fill("2027-06-01T20:30");
|
|
const submitted = page.waitForRequest(
|
|
(request) =>
|
|
request.method() === "PATCH" &&
|
|
new URL(request.url()).pathname === "/api/scheduled-tasks/switch-b",
|
|
);
|
|
await detail.getByRole("button", { name: "Save edit", exact: true }).click();
|
|
expect((await submitted).postDataJSON()).toMatchObject({
|
|
title: "Task B",
|
|
prompt: "Prompt B",
|
|
timezone: "Asia/Shanghai",
|
|
schedule_spec: { run_at: tasks[1]!.runAt },
|
|
});
|
|
});
|