From 4501c76b0f44cc55af6332d65ac2e7f5311f71fd Mon Sep 17 00:00:00 2001 From: tiammomo <26957354+tiammomo@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:59:52 +0800 Subject: [PATCH] feat(scheduled-tasks): browse paginated run history (#5363) Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> --- README.md | 1 + README_zh.md | 1 + frontend/AGENTS.md | 8 + .../app/workspace/scheduled-tasks/page.tsx | 93 +++++-- frontend/src/core/i18n/locales/en-US.ts | 12 + frontend/src/core/i18n/locales/types.ts | 11 + frontend/src/core/i18n/locales/zh-CN.ts | 11 + frontend/src/core/scheduled-tasks/api.ts | 11 +- frontend/src/core/scheduled-tasks/hooks.ts | 11 - .../src/core/scheduled-tasks/run-history.ts | 45 ++++ .../tests/e2e/scheduled-run-history.spec.ts | 228 ++++++++++++++++++ frontend/tests/e2e/utils/mock-api.ts | 13 +- .../scheduled-tasks/run-history.dom.test.tsx | 101 ++++++++ 13 files changed, 512 insertions(+), 34 deletions(-) create mode 100644 frontend/src/core/scheduled-tasks/run-history.ts create mode 100644 frontend/tests/e2e/scheduled-run-history.spec.ts create mode 100644 frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx diff --git a/README.md b/README.md index 85a4a22fd..1a9863344 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/README_zh.md b/README_zh.md index cf3265402..0f66cc71e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -824,6 +824,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled - 当某次执行处于 `queued`、`launching` 或 `running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度 - 支持暂停、恢复、手动触发、查看历史和删除任务 - 定时任务通过正常的 DeerFlow run 生命周期执行 +- 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。 当前 MVP 限制: diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0248f1f9c..67b0f07c6 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 ``` diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index 7de386685..4afd85152 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -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} -
- {(taskRunsQuery.data ?? []).length === 1 - ? st.detail.runsCountOne.replace( - "{count}", - String((taskRunsQuery.data ?? []).length), - ) - : st.detail.runsCount.replace( - "{count}", - String((taskRunsQuery.data ?? []).length), - )} -
+ + {taskRunsQuery.page > 0 && ( +

+ {st.history.paused} +

+ )} + {taskRunsQuery.isPending && ( +

{st.history.loading}

+ )} + {taskRunsQuery.isError && ( +
+

{st.history.loadFailed}

+ +
+ )} + {!taskRunsQuery.isPending && !taskRunsQuery.isError && ( +
+ {(taskRunsQuery.data ?? []).length === 1 + ? st.detail.runsCountOne.replace( + "{count}", + String((taskRunsQuery.data ?? []).length), + ) + : st.detail.runsCount.replace( + "{count}", + String((taskRunsQuery.data ?? []).length), + )} +
+ )}
)) - ) : ( + ) : !taskRunsQuery.isPending && !taskRunsQuery.isError ? (
{st.detail.noRuns}
- )} + ) : null}
) : ( diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index e12874435..07d8edd42 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 954020247..b670f36ac 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index b23ce4a4b..243f420c4 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "排队中", diff --git a/frontend/src/core/scheduled-tasks/api.ts b/frontend/src/core/scheduled-tasks/api.ts index 4a735eb8d..e721526a2 100644 --- a/frontend/src/core/scheduled-tasks/api.ts +++ b/frontend/src/core/scheduled-tasks/api.ts @@ -36,10 +36,15 @@ export async function fetchThreadScheduledTasks( export async function fetchScheduledTaskRuns( taskId: string, + page?: { limit: number; offset: number; signal?: AbortSignal }, ): Promise { - 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, diff --git a/frontend/src/core/scheduled-tasks/hooks.ts b/frontend/src/core/scheduled-tasks/hooks.ts index afd048613..d6bdb6ed7 100644 --- a/frontend/src/core/scheduled-tasks/hooks.ts +++ b/frontend/src/core/scheduled-tasks/hooks.ts @@ -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(); diff --git a/frontend/src/core/scheduled-tasks/run-history.ts b/frontend/src/core/scheduled-tasks/run-history.ts new file mode 100644 index 000000000..04a2e75a0 --- /dev/null +++ b/frontend/src/core/scheduled-tasks/run-history.ts @@ -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, + }); + }, + }; +} diff --git a/frontend/tests/e2e/scheduled-run-history.spec.ts b/frontend/tests/e2e/scheduled-run-history.spec.ts new file mode 100644 index 000000000..780eb2676 --- /dev/null +++ b/frontend/tests/e2e/scheduled-run-history.spec.ts @@ -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>, +) { + 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((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"); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 53c3c615c..1c338f99e 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -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(); diff --git a/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx b/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx new file mode 100644 index 000000000..babdeafc8 --- /dev/null +++ b/frontend/tests/unit/core/scheduled-tasks/run-history.dom.test.tsx @@ -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 ( + {children} + ); + }; +} +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); +});