From 03493d54169e2078b4b64c55a2adc955e344758a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 05:36:57 -0500 Subject: [PATCH 1/3] fix(web): confirm before deleting scheduled tasks Co-Authored-By: GPT-6.1 Sol --- .../ScheduledTasksSettings.browser.test.tsx | 136 ++++++++++++++++++ .../settings/ScheduledTasksSettings.tsx | 7 + 2 files changed, 143 insertions(+) create mode 100644 apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx new file mode 100644 index 0000000000000..ce5ac443c8ab1 --- /dev/null +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -0,0 +1,136 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + ProjectId, + ProviderInstanceId, + ScheduledTaskId, + type ScheduledTask, + type ScheduledTaskListResult, +} from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import "~/index.css"; +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ + tasks: null as unknown as Atom.Writable>, +})); + +vi.mock(import("../../state/server"), async (original) => { + const actual = await original(); + const { Atom, AsyncResult } = await import("effect/reactivity"); + state.tasks = Atom.make>( + AsyncResult.success({ tasks: [] }), + ); + const allowed = Atom.make(true); + return { + ...actual, + serverEnvironment: { + ...actual.serverEnvironment, + scheduledTasksLive: (() => state.tasks) as never, + upsertScheduledTask: { + ...actual.serverEnvironment.upsertScheduledTask, + permissionAtom: () => allowed, + }, + deleteScheduledTask: { + ...actual.serverEnvironment.deleteScheduledTask, + run: async (registry, { input }) => { + const current = registry.get(state.tasks); + if (current._tag !== "Success") throw new Error("Missing task list"); + registry.set( + state.tasks, + AsyncResult.success({ + tasks: current.value.tasks.filter((task) => task.id !== input.id), + }), + ); + return AsyncResult.success({ id: input.id }); + }, + }, + }, + }; +}); +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => { + const environment = { + environmentId: "env", + label: "Local environment", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" } }, + }; + return { + scope: { kind: "all", environmentIds: ["env"] }, + environments: [environment], + connectedEnvironments: [environment], + environment, + }; + }) as never, +})); +// Rows otherwise subscribe to unrelated settings write permissions and overrides. +vi.mock(import("./settingsLayout"), async (original) => ({ + ...(await original()), + SettingsPageContainer: ({ children }: { children: ReactNode }) => <>{children}, + SettingsRow: ({ title, control }: { title: ReactNode; control?: ReactNode }) => ( +
+ {title} + {control} +
+ ), +})); + +import { ConfirmDialogHost } from "../ConfirmDialogHost"; +import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; + +it("keeps a scheduled task until deletion is confirmed, including after cancelling", async () => { + using resources = new DisposableStack(); + const registry = AtomRegistry.make(); + resources.defer(() => registry.dispose()); + const task: ScheduledTask = { + id: ScheduledTaskId.make("task"), + title: "Daily summary", + prompt: "Summarize the project", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project"), + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "example" }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + createdAt: "2026-10-20T00:00:00.000Z", + updatedAt: "2026-10-20T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + }; + registry.set(state.tasks, AsyncResult.success({ tasks: [task] })); + await using screen = await render( + + + + , + ); + + await screen.getByRole("button", { name: "Actions for Daily summary" }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + expect(registry.get(state.tasks)).toMatchObject({ value: { tasks: [task] } }); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(registry.get(state.tasks)).toMatchObject({ value: { tasks: [task] } }); + + await screen.getByRole("button", { name: "Actions for Daily summary" }).click(); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + await expect.element(screen.getByText("No scheduled tasks", { exact: true })).toBeVisible(); + expect(registry.get(state.tasks)).toMatchObject({ value: { tasks: [] } }); +}); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 82d2092f61fbe..223d862f44d92 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -691,6 +691,13 @@ function ScheduledTaskRow({ }); const act = async (action: "toggle" | "run" | "delete") => { if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; + if (action === "delete") { + const message = `Delete "${task.title}"?\nThis cannot be undone.`; + const confirmed = + (await requestConfirmDialog(message, { variant: "destructive" })) ?? + window.confirm(message); + if (!confirmed) return; + } setBusy(true); const result = action === "toggle" From 6d9e4a362b2db6f8a830c486019e5456c220ec21 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 23 Oct 2026 21:05:11 -0500 Subject: [PATCH 2/3] test(web): initialize scheduled task mock state in setup() The vi.mock factory created the shared tasks atom, so the test never reset that state itself. setup() now creates the atom with the test's tasks and runs first. Co-Authored-By: Claude Opus 5.5 --- .../ScheduledTasksSettings.browser.test.tsx | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index ce5ac443c8ab1..eb5ee3c126817 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -21,9 +21,6 @@ const state = vi.hoisted(() => ({ vi.mock(import("../../state/server"), async (original) => { const actual = await original(); const { Atom, AsyncResult } = await import("effect/reactivity"); - state.tasks = Atom.make>( - AsyncResult.success({ tasks: [] }), - ); const allowed = Atom.make(true); return { ...actual, @@ -87,33 +84,40 @@ vi.mock(import("./settingsLayout"), async (original) => ({ import { ConfirmDialogHost } from "../ConfirmDialogHost"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; +function setup(tasks: ScheduledTaskListResult["tasks"]) { + state.tasks = Atom.make>( + AsyncResult.success({ tasks }), + ); +} + +const task: ScheduledTask = { + id: ScheduledTaskId.make("task"), + title: "Daily summary", + prompt: "Summarize the project", + enabled: false, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: ProjectId.make("project"), + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "example" }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + createdAt: "2026-10-20T00:00:00.000Z", + updatedAt: "2026-10-20T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, +}; + it("keeps a scheduled task until deletion is confirmed, including after cancelling", async () => { + setup([task]); using resources = new DisposableStack(); const registry = AtomRegistry.make(); resources.defer(() => registry.dispose()); - const task: ScheduledTask = { - id: ScheduledTaskId.make("task"), - title: "Daily summary", - prompt: "Summarize the project", - enabled: false, - schedule: { type: "interval", everyMs: 60_000 }, - projectId: ProjectId.make("project"), - threadId: null, - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "example" }, - runtimeMode: "full-access", - interactionMode: "default", - createdBy: "user", - creationSource: "web", - createdAt: "2026-10-20T00:00:00.000Z", - updatedAt: "2026-10-20T00:00:00.000Z", - nextRunAt: null, - lastRunAt: null, - lastRunStatus: "never", - lastRunError: null, - runCount: 0, - }; - registry.set(state.tasks, AsyncResult.success({ tasks: [task] })); await using screen = await render( From fbd78af44c7e090c37a5b66adf51fb102f1545d5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:10:52 +0000 Subject: [PATCH 3/3] test(web): reuse scheduled task settings coverage after merging main --- .../ScheduledTasksSettings.browser.test.tsx | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..42b3b89658e5c 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -12,6 +12,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; import { render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; +import { ConfirmDialogHost } from "../ConfirmDialogHost"; const environmentId = EnvironmentId.make("laptop"); const projectId = ProjectId.make("project"); @@ -38,7 +39,18 @@ const task: ScheduledTask = { runCount: 0, }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, remove: vi.fn() })); + +function setup() { + state.connected = true; + state.tasksLoaded = true; + state.remove.mockReset().mockResolvedValue({ _tag: "Success", value: { id: task.id } }); +} + +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); function environment() { return { @@ -74,6 +86,10 @@ vi.mock(import("../../state/server"), async (original) => { ...actual, serverEnvironment: { ...actual.serverEnvironment, + deleteScheduledTask: { + ...actual.serverEnvironment.deleteScheduledTask, + run: state.remove, + }, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, permissionAtom: () => allowed, @@ -105,9 +121,36 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ })); describe("ScheduledTasksSettings", () => { + it("only deletes a scheduled task after confirmation, including after cancelling", async () => { + setup(); + await using screen = await render( + <> + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Actions for Review issues" })); + await userEvent.click(page.getByRole("menuitem", { name: "Delete", exact: true })); + await expect.element(page.getByRole("alertdialog")).toBeVisible(); + expect(state.remove).not.toHaveBeenCalled(); + await userEvent.click(page.getByRole("button", { name: "Cancel", exact: true })); + expect(state.remove).not.toHaveBeenCalled(); + await expect + .element(screen.getByRole("button", { name: "Actions for Review issues" })) + .toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Actions for Review issues" })); + await userEvent.click(page.getByRole("menuitem", { name: "Delete", exact: true })); + await userEvent.click(page.getByRole("button", { name: "Confirm", exact: true })); + await vi.waitFor(() => + expect(state.remove).toHaveBeenCalledExactlyOnceWith(expect.anything(), { + environmentId, + input: { id: task.id }, + }), + ); + }); + it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +167,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" }));