mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
feat(scheduled-tasks): browse paginated run history (#5363)
Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
This commit is contained in:
parent
b9d6b16084
commit
4501c76b0f
@ -1575,6 +1575,7 @@ Current MVP capabilities:
|
||||
- Freeze a task's definition while an occurrence is `queued`, `launching`, or `running`, so a durable occurrence cannot silently pick up a different prompt, thread, or schedule; transitioning a task to paused or deleting it cancels an existing waiting occurrence, while `launching`/`running` work must finish before those mutations are retried and an explicit manual trigger may still wait and run without resuming a paused schedule
|
||||
- Pause, resume, trigger, inspect history, and delete tasks
|
||||
- Execute scheduled work through the normal DeerFlow run lifecycle
|
||||
- Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs.
|
||||
|
||||
Current MVP limits:
|
||||
|
||||
|
||||
@ -824,6 +824,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled
|
||||
- 当某次执行处于 `queued`、`launching` 或 `running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度
|
||||
- 支持暂停、恢复、手动触发、查看历史和删除任务
|
||||
- 定时任务通过正常的 DeerFlow run 生命周期执行
|
||||
- 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。
|
||||
|
||||
当前 MVP 限制:
|
||||
|
||||
|
||||
@ -44,6 +44,14 @@ Rstest runs them as two projects (`rstest.config.ts`). `*.test.ts` / `*.test.tsx
|
||||
|
||||
E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`. The real-backend auth contract in `tests/e2e-real-backend/auth-disabled-contract.spec.ts` and `backend/tests/test_auth_me_permissions.py` pin the complete route-permission list; update both when adding registered permissions (including `projects:read/write/delete`).
|
||||
|
||||
The dedicated `run-history.ts` hook replaces the unpaged runs hook. Show counts
|
||||
only after a successful history read, never during initial loading or errors.
|
||||
Scheduled run history uses task/page query keys and the existing live offset API.
|
||||
Fetch 51 rows to display 50 plus a next-page sentinel; never append pages. Only
|
||||
page zero polls or refreshes on focus/reconnect. Task switches reset to page zero,
|
||||
and consumed AbortSignals cancel obsolete reads. Live offsets are not snapshots;
|
||||
explicit mutations or navigation may observe newly inserted runs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@ -43,12 +43,12 @@ import {
|
||||
useDeleteScheduledTask,
|
||||
usePauseScheduledTask,
|
||||
useResumeScheduledTask,
|
||||
useScheduledTaskRuns,
|
||||
useScheduledTasks,
|
||||
useTriggerScheduledTask,
|
||||
useThreadScheduledTasks,
|
||||
} from "@/core/scheduled-tasks/hooks";
|
||||
import { RECIPES, type Recipe } from "@/core/scheduled-tasks/recipes";
|
||||
import { useScheduledTaskRunHistory } from "@/core/scheduled-tasks/run-history";
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskRun,
|
||||
@ -187,7 +187,7 @@ export default function ScheduledTasksPage() {
|
||||
});
|
||||
const selectedTask =
|
||||
filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0];
|
||||
const taskRunsQuery = useScheduledTaskRuns(selectedTask?.id);
|
||||
const taskRunsQuery = useScheduledTaskRunHistory(selectedTask?.id);
|
||||
const createTask = useCreateScheduledTask();
|
||||
const updateTask = useUpdateScheduledTask(selectedTask?.id ?? "");
|
||||
const pauseTask = usePauseScheduledTask();
|
||||
@ -711,17 +711,80 @@ export default function ScheduledTasksPage() {
|
||||
{st.actions.delete}
|
||||
</Button>
|
||||
</div>
|
||||
<div data-testid="scheduled-task-runs">
|
||||
{(taskRunsQuery.data ?? []).length === 1
|
||||
? st.detail.runsCountOne.replace(
|
||||
"{count}",
|
||||
String((taskRunsQuery.data ?? []).length),
|
||||
)
|
||||
: st.detail.runsCount.replace(
|
||||
"{count}",
|
||||
String((taskRunsQuery.data ?? []).length),
|
||||
)}
|
||||
</div>
|
||||
<nav
|
||||
aria-label={st.history.navigation}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
taskRunsQuery.page === 0 || taskRunsQuery.isFetching
|
||||
}
|
||||
onClick={taskRunsQuery.newer}
|
||||
>
|
||||
{st.history.newer}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{st.history.page.replace(
|
||||
"{page}",
|
||||
String(taskRunsQuery.page + 1),
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
!taskRunsQuery.hasOlder || taskRunsQuery.isFetching
|
||||
}
|
||||
onClick={taskRunsQuery.older}
|
||||
>
|
||||
{st.history.older}
|
||||
</Button>
|
||||
{taskRunsQuery.page > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={taskRunsQuery.latest}
|
||||
>
|
||||
{st.history.latest}
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
{taskRunsQuery.page > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{st.history.paused}
|
||||
</p>
|
||||
)}
|
||||
{taskRunsQuery.isPending && (
|
||||
<p role="status">{st.history.loading}</p>
|
||||
)}
|
||||
{taskRunsQuery.isError && (
|
||||
<div role="alert">
|
||||
<p>{st.history.loadFailed}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={taskRunsQuery.isFetching}
|
||||
onClick={() => void taskRunsQuery.refetch()}
|
||||
>
|
||||
{st.history.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!taskRunsQuery.isPending && !taskRunsQuery.isError && (
|
||||
<div data-testid="scheduled-task-runs">
|
||||
{(taskRunsQuery.data ?? []).length === 1
|
||||
? st.detail.runsCountOne.replace(
|
||||
"{count}",
|
||||
String((taskRunsQuery.data ?? []).length),
|
||||
)
|
||||
: st.detail.runsCount.replace(
|
||||
"{count}",
|
||||
String((taskRunsQuery.data ?? []).length),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
data-testid="scheduled-task-run-list"
|
||||
@ -746,11 +809,11 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
) : !taskRunsQuery.isPending && !taskRunsQuery.isError ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{st.detail.noRuns}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -567,6 +567,18 @@ export const enUS: Translations = {
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
history: {
|
||||
navigation: "Run history pages",
|
||||
newer: "Newer runs",
|
||||
older: "Older runs",
|
||||
latest: "Latest runs",
|
||||
page: "Page {page}",
|
||||
paused:
|
||||
"Automatic refresh is paused on older pages. Return to latest for current runs.",
|
||||
loading: "Loading runs…",
|
||||
loadFailed: "Could not load run history.",
|
||||
retry: "Retry history",
|
||||
},
|
||||
runTrigger: { scheduled: "scheduled", manual: "manual" },
|
||||
runStatus: {
|
||||
queued: "Queued",
|
||||
|
||||
@ -473,6 +473,17 @@ export interface Translations {
|
||||
failed: string;
|
||||
cancelled: string;
|
||||
};
|
||||
history: {
|
||||
navigation: string;
|
||||
newer: string;
|
||||
older: string;
|
||||
latest: string;
|
||||
page: string;
|
||||
paused: string;
|
||||
loading: string;
|
||||
loadFailed: string;
|
||||
retry: string;
|
||||
};
|
||||
runTrigger: { scheduled: string; manual: string };
|
||||
runStatus: {
|
||||
queued: string;
|
||||
|
||||
@ -540,6 +540,17 @@ export const zhCN: Translations = {
|
||||
failed: "已失败",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
history: {
|
||||
navigation: "执行记录分页",
|
||||
newer: "较新记录",
|
||||
older: "更早记录",
|
||||
latest: "最新记录",
|
||||
page: "第 {page} 页",
|
||||
paused: "浏览历史页时暂停自动刷新,返回最新记录可查看当前执行情况。",
|
||||
loading: "正在加载执行记录…",
|
||||
loadFailed: "无法加载执行记录。",
|
||||
retry: "重试加载",
|
||||
},
|
||||
runTrigger: { scheduled: "定时", manual: "手动" },
|
||||
runStatus: {
|
||||
queued: "排队中",
|
||||
|
||||
@ -36,10 +36,15 @@ export async function fetchThreadScheduledTasks(
|
||||
|
||||
export async function fetchScheduledTaskRuns(
|
||||
taskId: string,
|
||||
page?: { limit: number; offset: number; signal?: AbortSignal },
|
||||
): Promise<ScheduledTaskRun[]> {
|
||||
const response = await fetch(
|
||||
scheduledTasksUrl(`/${encodeURIComponent(taskId)}/runs`),
|
||||
);
|
||||
const url = scheduledTasksUrl(`/${encodeURIComponent(taskId)}/runs`);
|
||||
const response = page
|
||||
? await fetch(
|
||||
`${url}?${new URLSearchParams({ limit: String(page.limit), offset: String(page.offset) })}`,
|
||||
{ signal: page.signal },
|
||||
)
|
||||
: await fetch(url);
|
||||
if (!response.ok) {
|
||||
await throwGatewayApiError(
|
||||
response,
|
||||
|
||||
@ -6,7 +6,6 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
createScheduledTask,
|
||||
deleteScheduledTask,
|
||||
fetchScheduledTaskRuns,
|
||||
fetchScheduledTasks,
|
||||
fetchThreadScheduledTasks,
|
||||
pauseScheduledTask,
|
||||
@ -33,16 +32,6 @@ export function useThreadScheduledTasks(threadId: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTaskRuns(taskId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", taskId],
|
||||
queryFn: () => fetchScheduledTaskRuns(taskId ?? ""),
|
||||
enabled: Boolean(taskId),
|
||||
refetchInterval: 15000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateScheduledTask() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
45
frontend/src/core/scheduled-tasks/run-history.ts
Normal file
45
frontend/src/core/scheduled-tasks/run-history.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { fetchScheduledTaskRuns } from "./api";
|
||||
|
||||
export const RUN_HISTORY_PAGE_SIZE = 50;
|
||||
|
||||
export function useScheduledTaskRunHistory(taskId: string | undefined) {
|
||||
const client = useQueryClient();
|
||||
const [position, setPosition] = useState({ taskId, page: 0 });
|
||||
const page = position.taskId === taskId ? position.page : 0;
|
||||
if (position.taskId !== taskId) {
|
||||
setPosition({ taskId, page: 0 });
|
||||
}
|
||||
const query = useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", taskId, page],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchScheduledTaskRuns(taskId ?? "", {
|
||||
limit: RUN_HISTORY_PAGE_SIZE + 1,
|
||||
offset: page * RUN_HISTORY_PAGE_SIZE,
|
||||
signal,
|
||||
}),
|
||||
enabled: Boolean(taskId),
|
||||
refetchInterval: page === 0 ? 15000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnMount: page === 0,
|
||||
refetchOnWindowFocus: page === 0,
|
||||
refetchOnReconnect: page === 0,
|
||||
});
|
||||
return {
|
||||
...query,
|
||||
data: query.data?.slice(0, RUN_HISTORY_PAGE_SIZE),
|
||||
page,
|
||||
hasOlder: (query.data?.length ?? 0) > RUN_HISTORY_PAGE_SIZE,
|
||||
older: () => setPosition({ taskId, page: page + 1 }),
|
||||
newer: () => setPosition({ taskId, page: Math.max(0, page - 1) }),
|
||||
latest: () => {
|
||||
setPosition({ taskId, page: 0 });
|
||||
void client.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "runs", taskId, 0],
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
228
frontend/tests/e2e/scheduled-run-history.spec.ts
Normal file
228
frontend/tests/e2e/scheduled-run-history.spec.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const task = {
|
||||
id: "history",
|
||||
thread_id: "thread-1",
|
||||
title: "History task",
|
||||
prompt: "Summarize",
|
||||
schedule_type: "cron" as const,
|
||||
schedule_spec: { cron: "0 9 * * *" },
|
||||
timezone: "UTC",
|
||||
status: "enabled" as const,
|
||||
next_run_at: null,
|
||||
last_run_at: null,
|
||||
last_run_id: null,
|
||||
last_error: null,
|
||||
run_count: 101,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const runs = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `row-${i}`,
|
||||
task_id: task.id,
|
||||
thread_id: task.thread_id,
|
||||
run_id: `execution-${i}`,
|
||||
scheduled_for: "2026-01-01T00:00:00Z",
|
||||
trigger: "scheduled" as const,
|
||||
status: "success" as const,
|
||||
error: null,
|
||||
attempt_count: 1,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
}));
|
||||
const endpoint = /\/api\/scheduled-tasks\/history\/runs(?:\?|$)/;
|
||||
async function seedRuns(
|
||||
page: Page,
|
||||
data: Record<string, ReturnType<typeof runs>>,
|
||||
) {
|
||||
await page.route(/\/api\/scheduled-tasks\/[^/]+\/runs(?:\?|$)/, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const taskId = url.pathname.split("/").at(-2)!;
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
return route.fulfill({
|
||||
json: (data[taskId] ?? []).slice(
|
||||
offset,
|
||||
offset + Number(url.searchParams.get("limit") ?? 50),
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const count of [100, 101]) {
|
||||
test(`browses ${count} runs without exposing the sentinel or an empty final page`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (endpoint.test(request.url()))
|
||||
requests.push(new URL(request.url()).search);
|
||||
});
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
|
||||
await seedRuns(page, { history: runs(count) });
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
const list = page.getByTestId("scheduled-task-run-list");
|
||||
const older = page.getByRole("button", { name: "Older runs", exact: true });
|
||||
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(50);
|
||||
await expect(list.getByText("execution-50", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await older.click();
|
||||
await expect(list.getByText("execution-50", { exact: true })).toBeVisible();
|
||||
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(50);
|
||||
if (count === 101) {
|
||||
await older.click();
|
||||
await expect(
|
||||
list.getByText("execution-100", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(list.getByText(/^execution-\d+$/)).toHaveCount(1);
|
||||
}
|
||||
await expect(older).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Newer runs", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Run history pages" }),
|
||||
).toContainText(count === 101 ? "Page 2" : "Page 1");
|
||||
if (count === 101)
|
||||
await page
|
||||
.getByRole("button", { name: "Latest runs", exact: true })
|
||||
.click();
|
||||
await expect(list.getByText("execution-0", { exact: true })).toBeVisible();
|
||||
expect(requests).toContain("?limit=51&offset=50");
|
||||
expect(requests).not.toContain("?limit=51&offset=150");
|
||||
});
|
||||
}
|
||||
|
||||
test("history load failure is retriable and switching tasks resets the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [],
|
||||
scheduledTasks: [task, { ...task, id: "other", title: "Other history" }],
|
||||
});
|
||||
await seedRuns(page, {
|
||||
history: runs(101),
|
||||
other: [
|
||||
{
|
||||
...runs(1)[0]!,
|
||||
id: "other-row",
|
||||
task_id: "other",
|
||||
run_id: "other-execution",
|
||||
},
|
||||
],
|
||||
});
|
||||
let fail = true;
|
||||
await page.route(endpoint, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.searchParams.get("offset") === "50" && fail)
|
||||
return route.fulfill({ status: 500, json: { detail: "unavailable" } });
|
||||
return route.fallback();
|
||||
});
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
await page.getByRole("button", { name: "Older runs", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "Could not load run history." }),
|
||||
).toContainText("Could not load run history.", { timeout: 15000 });
|
||||
await expect(page.getByTestId("scheduled-task-runs")).toHaveCount(0);
|
||||
fail = false;
|
||||
await page
|
||||
.getByRole("button", { name: "Retry history", exact: true })
|
||||
.click();
|
||||
await expect(page.getByTestId("scheduled-task-run-list")).toContainText(
|
||||
"execution-50",
|
||||
);
|
||||
await page.getByTestId("scheduled-task-item-other").click();
|
||||
await expect(page.getByTestId("scheduled-task-run-list")).toContainText(
|
||||
"other-execution",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Run history pages" }),
|
||||
).toContainText("Page 1");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Newer runs", exact: true }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("only latest history polls and returning to latest fetches newly inserted runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install();
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
|
||||
const rows = runs(101);
|
||||
const offsets: number[] = [];
|
||||
await page.route(endpoint, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
offsets.push(offset);
|
||||
return route.fulfill({
|
||||
json: rows.slice(
|
||||
offset,
|
||||
offset + Number(url.searchParams.get("limit") ?? 50),
|
||||
),
|
||||
});
|
||||
});
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
const list = page.getByTestId("scheduled-task-run-list");
|
||||
await expect(list).toContainText("execution-0");
|
||||
const initialRequests = offsets.length;
|
||||
await page.clock.fastForward(16000);
|
||||
await expect.poll(() => offsets.length).toBeGreaterThan(initialRequests);
|
||||
await page.getByRole("button", { name: "Older runs", exact: true }).click();
|
||||
await expect(list).toContainText("execution-50");
|
||||
const olderRequests = offsets.length;
|
||||
rows.unshift({ ...rows[0]!, id: "inserted", run_id: "new-execution" });
|
||||
await page.clock.fastForward(31000);
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("offline"));
|
||||
window.dispatchEvent(new Event("online"));
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(offsets.length).toBe(olderRequests);
|
||||
await expect(list).toContainText("execution-50");
|
||||
await page.getByRole("button", { name: "Latest runs", exact: true }).click();
|
||||
await expect(list).toContainText("new-execution");
|
||||
expect(offsets.at(-1)).toBe(0);
|
||||
});
|
||||
|
||||
test("Chinese history navigation and empty results are localized", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "locale=zh-CN; path=/";
|
||||
});
|
||||
await page.reload();
|
||||
const nav = page.getByRole("navigation", { name: "执行记录分页" });
|
||||
await expect(nav).toContainText("第 1 页");
|
||||
await expect(
|
||||
nav.getByRole("button", { name: "更早记录", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
nav.getByRole("button", { name: "较新记录", exact: true }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("pending history does not report an empty run count", async ({ page }) => {
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(endpoint, async (route) => {
|
||||
await gate;
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
try {
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: "Loading runs" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("scheduled-task-runs")).toHaveCount(0);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await expect(page.getByTestId("scheduled-task-runs")).toContainText("0 runs");
|
||||
});
|
||||
@ -677,15 +677,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/scheduled-tasks/*/runs", (route) => {
|
||||
void page.route(/\/api\/scheduled-tasks\/[^/]+\/runs(?:\?|$)/, (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
const taskId = decodeURIComponent(
|
||||
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
|
||||
);
|
||||
const url = new URL(route.request().url());
|
||||
const taskId = decodeURIComponent(url.pathname.split("/").at(-2) ?? "");
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50);
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mutableTaskRuns[taskId] ?? []),
|
||||
body: JSON.stringify(
|
||||
(mutableTaskRuns[taskId] ?? []).slice(offset, offset + limit),
|
||||
),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import { afterEach, expect, test, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
rs.mock("@/core/scheduled-tasks/api", () => ({
|
||||
fetchScheduledTaskRuns: rs.fn(),
|
||||
}));
|
||||
|
||||
import { fetchScheduledTaskRuns } from "@/core/scheduled-tasks/api";
|
||||
import { useScheduledTaskRunHistory } from "@/core/scheduled-tasks/run-history";
|
||||
import type { ScheduledTaskRun } from "@/core/scheduled-tasks/types";
|
||||
|
||||
const fetchRuns = rs.mocked(fetchScheduledTaskRuns);
|
||||
const clients: QueryClient[] = [];
|
||||
function wrapper() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
clients.push(client);
|
||||
return function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
const rows = (count: number, prefix = "run") =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index) => ({ id: `${prefix}-${index}` }) as ScheduledTaskRun,
|
||||
);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clients.splice(0).forEach((client) => client.clear());
|
||||
fetchRuns.mockReset();
|
||||
});
|
||||
|
||||
test("a full final page does not expose the next-page sentinel", async () => {
|
||||
fetchRuns.mockImplementation(async (_task, page) =>
|
||||
rows(page?.offset === 0 ? 51 : 50),
|
||||
);
|
||||
const { result } = renderHook(() => useScheduledTaskRunHistory("task-a"), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasOlder).toBe(true));
|
||||
expect(result.current.data).toHaveLength(50);
|
||||
act(() => result.current.older());
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.hasOlder).toBe(false);
|
||||
expect(result.current.data).toHaveLength(50);
|
||||
expect(fetchRuns).toHaveBeenLastCalledWith("task-a", {
|
||||
limit: 51,
|
||||
offset: 50,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
test("switching tasks aborts an in-flight older page and rejects its late result", async () => {
|
||||
let oldSignal: AbortSignal | undefined;
|
||||
let finishOld!: (data: ScheduledTaskRun[]) => void;
|
||||
fetchRuns.mockImplementation(async (id, page) => {
|
||||
if (id === "task-a" && page?.offset === 50) {
|
||||
oldSignal = page.signal;
|
||||
return new Promise((resolve) => {
|
||||
finishOld = resolve;
|
||||
});
|
||||
}
|
||||
return rows(id === "task-a" ? 51 : 1, id);
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskId }) => useScheduledTaskRunHistory(taskId),
|
||||
{ initialProps: { taskId: "task-a" }, wrapper: wrapper() },
|
||||
);
|
||||
await waitFor(() => expect(result.current.hasOlder).toBe(true));
|
||||
act(() => result.current.older());
|
||||
await waitFor(() => expect(oldSignal).toBeDefined());
|
||||
expect(result.current.isPending).toBe(true);
|
||||
rerender({ taskId: "task-b" });
|
||||
await waitFor(() => expect(result.current.data?.[0]?.id).toBe("task-b-0"));
|
||||
expect(result.current.page).toBe(0);
|
||||
expect(oldSignal?.aborted).toBe(true);
|
||||
await act(async () => finishOld(rows(50, "stale-task-a")));
|
||||
expect(result.current.data?.[0]?.id).toBe("task-b-0");
|
||||
});
|
||||
|
||||
test("an empty older page retains a route back to the latest records", async () => {
|
||||
fetchRuns.mockImplementation(async (_id, page) =>
|
||||
rows(page?.offset === 0 ? 51 : 0),
|
||||
);
|
||||
const { result } = renderHook(() => useScheduledTaskRunHistory("task-a"), {
|
||||
wrapper: wrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasOlder).toBe(true));
|
||||
act(() => result.current.older());
|
||||
await waitFor(() => expect(result.current.data).toEqual([]));
|
||||
expect(result.current.page).toBe(1);
|
||||
fetchRuns.mockImplementation(async () => rows(1, "new"));
|
||||
act(() => result.current.latest());
|
||||
await waitFor(() => expect(result.current.data?.[0]?.id).toBe("new-0"));
|
||||
expect(result.current.page).toBe(0);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user