mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-30 08:26:19 +00:00
fix(scheduled-tasks): preserve unchanged one-time execution instants (#5330)
* 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>
This commit is contained in:
parent
796ca28f55
commit
6d725f1ccb
@ -1777,6 +1777,8 @@ Deleting a project moves its entire shelf to trash in the same step.
|
|||||||
|
|
||||||
DeerFlow now includes a first-class scheduled-task MVP in the workspace.
|
DeerFlow now includes a first-class scheduled-task MVP in the workspace.
|
||||||
|
|
||||||
|
Editing a one-time task's title or prompt preserves its original execution time, including seconds and the selected occurrence during a daylight-saving clock rollback. Changing its date, time, or timezone recalculates the execution time. Switching tasks while editing loads the selected task's own title, prompt, and schedule.
|
||||||
|
|
||||||
Current MVP capabilities:
|
Current MVP capabilities:
|
||||||
|
|
||||||
- Manage tasks at `/workspace/scheduled-tasks`
|
- Manage tasks at `/workspace/scheduled-tasks`
|
||||||
|
|||||||
@ -95,6 +95,8 @@ do not use HTML `maxLength`, which counts UTF-16 code units instead.
|
|||||||
- **Path alias**: `@/*` maps to `src/*`.
|
- **Path alias**: `@/*` maps to `src/*`.
|
||||||
- **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these.
|
- **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these.
|
||||||
|
|
||||||
|
Single-run schedule edits retain the mounted task's original `run_at` while its wall time and timezone match. The parent echoes edits through `initial`; retain a stable snapshot and reset the parent draft during render before remounting with a task key when switching tasks. Use the resolved timezone consistently for the snapshot and displayed wall time. Component and scheduled-task E2E tests cover DST folds and timestamp precision.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
Scheduled-task interval forms preserve the initial `every_seconds` on mount,
|
Scheduled-task interval forms preserve the initial `every_seconds` on mount,
|
||||||
|
|||||||
@ -145,6 +145,7 @@ export default function ScheduledTasksPage() {
|
|||||||
>("all");
|
>("all");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editTaskId, setEditTaskId] = useState<string | undefined>(undefined);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
const [editPrompt, setEditPrompt] = useState("");
|
const [editPrompt, setEditPrompt] = useState("");
|
||||||
const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID);
|
const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID);
|
||||||
@ -264,35 +265,35 @@ export default function ScheduledTasksPage() {
|
|||||||
}
|
}
|
||||||
}, [filteredData, selectedTaskId]);
|
}, [filteredData, selectedTaskId]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Reset before children commit so the keyed input captures this task.
|
||||||
|
// Same-id refetches retain the in-progress draft.
|
||||||
|
if (editTaskId !== selectedTask?.id) {
|
||||||
|
setEditTaskId(selectedTask?.id);
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
return;
|
} else {
|
||||||
|
setEditTitle(selectedTask.title);
|
||||||
|
setEditPrompt(selectedTask.prompt);
|
||||||
|
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
|
||||||
|
const spec = selectedTask.schedule_spec as {
|
||||||
|
cron?: string;
|
||||||
|
run_at?: string;
|
||||||
|
every_seconds?: number;
|
||||||
|
};
|
||||||
|
setEditSchedule({
|
||||||
|
schedule_type: selectedTask.schedule_type,
|
||||||
|
schedule_spec: {
|
||||||
|
cron: typeof spec.cron === "string" ? spec.cron : undefined,
|
||||||
|
run_at: typeof spec.run_at === "string" ? spec.run_at : undefined,
|
||||||
|
every_seconds:
|
||||||
|
typeof spec.every_seconds === "number"
|
||||||
|
? spec.every_seconds
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
timezone: selectedTask.timezone || "UTC",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setEditTitle(selectedTask.title);
|
}
|
||||||
setEditPrompt(selectedTask.prompt);
|
|
||||||
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
|
|
||||||
const spec = selectedTask.schedule_spec as {
|
|
||||||
cron?: string;
|
|
||||||
run_at?: string;
|
|
||||||
every_seconds?: number;
|
|
||||||
};
|
|
||||||
setEditSchedule({
|
|
||||||
schedule_type: selectedTask.schedule_type,
|
|
||||||
schedule_spec: {
|
|
||||||
cron: typeof spec.cron === "string" ? spec.cron : undefined,
|
|
||||||
run_at: typeof spec.run_at === "string" ? spec.run_at : undefined,
|
|
||||||
every_seconds:
|
|
||||||
typeof spec.every_seconds === "number"
|
|
||||||
? spec.every_seconds
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
timezone: selectedTask.timezone || "UTC",
|
|
||||||
});
|
|
||||||
// Depend on id only so a background refetch (same task, new object reference)
|
|
||||||
// does not wipe edits in progress.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedTask?.id]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContainer>
|
<WorkspaceContainer>
|
||||||
|
|||||||
@ -118,16 +118,27 @@ export function ScheduledTaskScheduleInput({
|
|||||||
const [parts, setParts] = useState<CronParts>(
|
const [parts, setParts] = useState<CronParts>(
|
||||||
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts,
|
() => parseCron(initial.schedule_spec.cron ?? "0 9 * * *").parts,
|
||||||
);
|
);
|
||||||
|
const [timezone, setTimezone] = useState<string>(
|
||||||
|
() => initial.timezone || detectBrowserTimezone(),
|
||||||
|
);
|
||||||
const [runAtLocal, setRunAtLocal] = useState<string>(
|
const [runAtLocal, setRunAtLocal] = useState<string>(
|
||||||
initial.schedule_type === "once" && initial.schedule_spec.run_at
|
initial.schedule_type === "once" && initial.schedule_spec.run_at
|
||||||
? utcToZonedLocalInput(
|
? utcToZonedLocalInput(initial.schedule_spec.run_at, timezone)
|
||||||
initial.schedule_spec.run_at,
|
|
||||||
initial.timezone || "UTC",
|
|
||||||
)
|
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
const [timezone, setTimezone] = useState<string>(
|
|
||||||
initial.timezone || detectBrowserTimezone(),
|
// Minute-precision wall time cannot retain seconds or identify the later
|
||||||
|
// occurrence of a repeated DST time. Keep the mounted task's original
|
||||||
|
// instant while its schedule fields match, even if the parent echoes edits
|
||||||
|
// back through initial. Task switches remount this component with a key.
|
||||||
|
const [initialOnce] = useState(() =>
|
||||||
|
initial.schedule_type === "once" && initial.schedule_spec.run_at
|
||||||
|
? {
|
||||||
|
runAt: initial.schedule_spec.run_at,
|
||||||
|
local: runAtLocal,
|
||||||
|
timezone,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
const initialInterval = parseInitialInterval(initial.schedule_spec);
|
const initialInterval = parseInitialInterval(initial.schedule_spec);
|
||||||
const [intervalAmount, setIntervalAmount] = useState(initialInterval.amount);
|
const [intervalAmount, setIntervalAmount] = useState(initialInterval.amount);
|
||||||
@ -151,7 +162,13 @@ export function ScheduledTaskScheduleInput({
|
|||||||
// value always matches what the user sees in the preview.
|
// value always matches what the user sees in the preview.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scheduleType === "once") {
|
if (scheduleType === "once") {
|
||||||
const runAt = runAtLocal ? zonedLocalToUtcIso(runAtLocal, timezone) : "";
|
const unchanged =
|
||||||
|
runAtLocal === initialOnce?.local && timezone === initialOnce.timezone;
|
||||||
|
const runAt = unchanged
|
||||||
|
? initialOnce.runAt
|
||||||
|
: runAtLocal
|
||||||
|
? zonedLocalToUtcIso(runAtLocal, timezone)
|
||||||
|
: "";
|
||||||
onChangeRef.current({
|
onChangeRef.current({
|
||||||
schedule_type: "once",
|
schedule_type: "once",
|
||||||
schedule_spec: runAt ? { run_at: runAt } : {},
|
schedule_spec: runAt ? { run_at: runAt } : {},
|
||||||
@ -188,6 +205,7 @@ export function ScheduledTaskScheduleInput({
|
|||||||
parts,
|
parts,
|
||||||
runAtLocal,
|
runAtLocal,
|
||||||
timezone,
|
timezone,
|
||||||
|
initialOnce,
|
||||||
intervalAmount,
|
intervalAmount,
|
||||||
intervalUnit,
|
intervalUnit,
|
||||||
intervalEdited,
|
intervalEdited,
|
||||||
|
|||||||
@ -458,3 +458,118 @@ test("edit omits assistant_id when the agent is unchanged", async ({
|
|||||||
expect(patchBody).toMatchObject({ title: "Renamed digest" });
|
expect(patchBody).toMatchObject({ title: "Renamed digest" });
|
||||||
expect(patchBody).not.toHaveProperty("assistant_id");
|
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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,233 @@
|
|||||||
|
import { afterEach, expect, rs, test } from "@rstest/core";
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ScheduledTaskScheduleInput,
|
||||||
|
type ScheduleValue,
|
||||||
|
} from "@/components/workspace/scheduled-task-schedule-input";
|
||||||
|
import { enUS } from "@/core/i18n/locales/en-US";
|
||||||
|
|
||||||
|
rs.mock("@/core/i18n/hooks", () => ({
|
||||||
|
useI18n: () => ({ locale: "en-US", t: enUS }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Exercise schedule state with native selects; the page E2E uses real Radix UI.
|
||||||
|
rs.mock("@/components/ui/select", () => ({
|
||||||
|
Select: ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onValueChange(event.target.value)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
SelectTrigger: () => null,
|
||||||
|
SelectValue: () => null,
|
||||||
|
SelectContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
|
||||||
|
<option value={value}>{children}</option>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function once(runAt: string, timezone = "America/New_York"): ScheduleValue {
|
||||||
|
return { schedule_type: "once", schedule_spec: { run_at: runAt }, timezone };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"2026-11-01T06:30:00Z",
|
||||||
|
"2027-06-01T12:30:45Z",
|
||||||
|
"2027-06-01T12:30:45.123Z",
|
||||||
|
"2027-06-01T08:30:00-04:00",
|
||||||
|
])("preserves the original one-time timestamp on mount: %s", (runAt) => {
|
||||||
|
const initial = once(runAt);
|
||||||
|
const onChange = rs.fn();
|
||||||
|
render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={initial}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parent feedback and title-only rerenders retain the original instant", () => {
|
||||||
|
const original = once("2026-11-01T06:30:00Z");
|
||||||
|
function Editor() {
|
||||||
|
const [schedule, setSchedule] = useState(original);
|
||||||
|
const [title, setTitle] = useState("Original");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
aria-label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={schedule}
|
||||||
|
onChange={(next) => setSchedule(next)}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>
|
||||||
|
<output>{schedule.schedule_spec.run_at}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ui = render(<Editor />);
|
||||||
|
fireEvent.change(ui.getByLabelText("Title"), {
|
||||||
|
target: { value: "Renamed" },
|
||||||
|
});
|
||||||
|
expect(ui.getByRole("status").textContent).toBe(
|
||||||
|
original.schedule_spec.run_at,
|
||||||
|
);
|
||||||
|
fireEvent.change(ui.getByLabelText("Run at"), {
|
||||||
|
target: { value: "2026-11-01T03:30" },
|
||||||
|
});
|
||||||
|
expect(ui.getByRole("status").textContent).toBe("2026-11-01T08:30:00+00:00");
|
||||||
|
// Returning to the original fields must use the original snapshot, not the
|
||||||
|
// latest value passed back through initial by the parent.
|
||||||
|
fireEvent.change(ui.getByLabelText("Run at"), {
|
||||||
|
target: { value: "2026-11-01T01:30" },
|
||||||
|
});
|
||||||
|
expect(ui.getByRole("status").textContent).toBe(
|
||||||
|
original.schedule_spec.run_at,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing timezone recomputes the instant and reverting restores seconds", () => {
|
||||||
|
const initial = once("2027-06-01T12:30:45Z");
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={initial}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(ui.getByRole("combobox"), {
|
||||||
|
target: { value: "Asia/Shanghai" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
once("2027-06-01T00:30:00+00:00", "Asia/Shanghai"),
|
||||||
|
);
|
||||||
|
fireEvent.change(ui.getByRole("combobox"), {
|
||||||
|
target: { value: "America/New_York" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing the date removes run_at", () => {
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={once("2027-06-01T12:30:45Z")}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(ui.getByLabelText("Run at"), { target: { value: "" } });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
schedule_type: "once",
|
||||||
|
schedule_spec: {},
|
||||||
|
timezone: "America/New_York",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty create form converts a newly entered date", () => {
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={{
|
||||||
|
schedule_type: "once",
|
||||||
|
schedule_spec: {},
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
schedule_type: "once",
|
||||||
|
schedule_spec: {},
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
});
|
||||||
|
fireEvent.change(ui.getByLabelText("Run at"), {
|
||||||
|
target: { value: "2027-06-01T09:30" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
once("2027-06-01T01:30:00+00:00", "Asia/Shanghai"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching schedule type preserves cron behavior and an unchanged once value", () => {
|
||||||
|
const initial = once("2027-06-01T12:30:45Z");
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput initial={initial} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(ui.getByRole("button", { name: "Recurring" }));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
schedule_type: "cron",
|
||||||
|
schedule_spec: { cron: "0 9 * * *" },
|
||||||
|
timezone: "America/New_York",
|
||||||
|
});
|
||||||
|
fireEvent.click(ui.getByRole("button", { name: "One-time" }));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a keyed task switch captures the next task's instant", () => {
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
key="first"
|
||||||
|
initial={once("2026-11-01T06:30:00Z")}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const next = once("2027-06-01T12:30:45Z");
|
||||||
|
ui.rerender(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
key="second"
|
||||||
|
initial={next}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty timezone uses a non-UTC browser zone without changing the instant", () => {
|
||||||
|
const timezone = "Asia/Shanghai";
|
||||||
|
const browserOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||||
|
const detectedZone = rs
|
||||||
|
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
|
||||||
|
.mockReturnValue({ ...browserOptions, timeZone: timezone });
|
||||||
|
try {
|
||||||
|
const initial = once("2026-11-01T06:30:00Z", "");
|
||||||
|
const onChange = rs.fn();
|
||||||
|
const ui = render(
|
||||||
|
<ScheduledTaskScheduleInput
|
||||||
|
initial={initial}
|
||||||
|
onChange={onChange}
|
||||||
|
scheduleTypeLocked
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(detectedZone).toHaveBeenCalled();
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ ...initial, timezone });
|
||||||
|
expect((ui.getByLabelText("Run at") as HTMLInputElement).value).toBe(
|
||||||
|
"2026-11-01T14:30",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
detectedZone.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user