Files
paseo/packages/app/e2e/schedules-project-target.spec.ts
T
Mohamed BoudraandGitHub bf5e3b47e7 Add a Schedules screen to manage recurring agents (#1246)
* feat: add Schedules screen to manage scheduled agents

Adds a Schedules section in the sidebar header (below Sessions) for viewing
and managing new-agent schedules: recurring jobs that spawn an agent on a
cron or interval cadence.

- Borderless table showing provider icon, model, cadence, next run, and
  status, with row actions to edit, pause/resume, run now, and delete.
- Create/edit sheet that reuses the agent provider/model/mode/working-
  directory pickers, plus an interval/cron cadence editor with presets,
  validation, and a UTC preview.
- Responsive: hover-revealed row actions on desktop, an always-visible
  action menu on mobile.
- Data layer over the existing schedule RPCs (list/create/update/pause/
  resume/delete/run-once) with optimistic pause, resume, and delete.

Scope is limited to new-agent schedules for now.

* Make the schedule form controls visually consistent

Merge the provider and model pickers into the single nested selector the
draft screen uses, render it as a full-width field, mute the placeholder,
proportion the cadence segmented controls, unify the cron preset chips,
and route the sheet scroll through the themed scrollbar.

* Address schedules review feedback

* Translate schedule hook client fallback errors

* Make schedules global and project scoped

* Stabilize project settings transport E2E

* Reset schedule model on cross-host project change

* Reject malformed cron step expressions

* Tighten schedules project query freshness

* refactor(schedules): clean up review feedback

* test(schedules): extract fake host setup

* refactor(schedules): share cron expression parsing

Use one structural cron parser for app previews and daemon cadence validation so the accepted grammar cannot drift between surfaces.

Remove the unused host-scoped schedules redirect while keeping the global schedules route.
2026-06-30 22:58:49 +02:00

157 lines
6.1 KiB
TypeScript

import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addFakeScheduleHostAndReload,
buildFakeScheduleHostWorkspace,
installFakeScheduleHost,
} from "./helpers/schedule-fake-host";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { buildSchedulesRoute } from "../src/utils/host-routes";
interface ScheduleListItem {
id: string;
name: string | null;
target: { type: string; config?: { cwd?: string } };
}
interface ScheduleSeedClient {
scheduleList(): Promise<{ schedules: ScheduleListItem[]; error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
async function selectModelByLabel(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: /select model/i }).click();
const popup = page.getByTestId("combobox-desktop-container");
await expect(popup).toBeVisible({ timeout: 30_000 });
await popup.getByText(label, { exact: true }).click();
await expect(popup).toHaveCount(0, { timeout: 30_000 });
}
async function deleteScheduleByName(workspace: SeededWorkspace, name: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === name);
if (schedule) {
await client.scheduleDelete({ id: schedule.id }).catch(() => undefined);
}
}
async function expectScheduleCreatedForProject(input: {
workspace: SeededWorkspace;
name: string;
}): Promise<void> {
const client = input.workspace.client as unknown as ScheduleSeedClient;
const list = await client.scheduleList();
const schedule = list.schedules.find((candidate) => candidate.name === input.name);
expect(schedule).toEqual(
expect.objectContaining({
name: input.name,
target: expect.objectContaining({
type: "new-agent",
config: expect.objectContaining({
cwd: input.workspace.repoPath,
}),
}),
}),
);
}
test.describe("Schedules project target", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
for (const cleanup of cleanupTasks.toReversed()) {
await cleanup();
}
cleanupTasks.length = 0;
});
test("creates a schedule from a project picker instead of a raw CWD selector", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-target-" });
cleanupTasks.push(() => workspace.cleanup());
const scheduleName = `Project schedule ${Date.now()}`;
cleanupTasks.push(() => deleteScheduleByName(workspace, scheduleName));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.getByRole("button", { name: "Schedules" }).click();
await expect(page).toHaveURL(/\/schedules$/);
await expect(page).not.toHaveURL(/\/h\//);
await expect(page.getByTestId(`schedules-section-${getServerId()}`)).toBeVisible();
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("schedule-cwd-trigger")).toHaveCount(0);
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await page.getByLabel("Schedule name").fill(scheduleName);
await page.getByLabel("Prompt").fill("Summarize the project status.");
await page.getByRole("button", { name: "Cron" }).click();
await page.getByRole("button", { name: "Create schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toHaveCount(0, { timeout: 30_000 });
await expectScheduleCreatedForProject({ workspace, name: scheduleName });
});
test("clears the selected model when the chosen project moves to another host", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-project-host-model-" });
cleanupTasks.push(() => workspace.cleanup());
const fakeHost = await buildFakeScheduleHostWorkspace(workspace);
const fakePort = String(59_000 + Math.floor(Math.random() * 900));
await installFakeScheduleHost({
page,
port: fakePort,
serverId: fakeHost.serverId,
workspace: fakeHost.workspace,
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildSchedulesRoute());
await addFakeScheduleHostAndReload({
page,
serverId: fakeHost.serverId,
label: "Fake host",
port: fakePort,
});
await expect(page.getByTestId(`schedules-section-${fakeHost.serverId}`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "New schedule" }).click();
await expect(page.getByTestId("schedule-form-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
workspace.projectDisplayName,
);
await selectModelByLabel(page, "Ten second stream");
await expect(page.getByRole("button", { name: /ten second stream/i })).toBeVisible();
await page.getByRole("button", { name: /select project/i }).click();
await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click();
await expect(page.getByRole("button", { name: /select project/i })).toContainText(
fakeHost.projectDisplayName,
);
await expect(page.getByRole("button", { name: /select model/i })).toBeVisible();
await page.getByLabel("Schedule name").fill(`Cross host model ${Date.now()}`);
await page.getByLabel("Prompt").fill("Run on the fake host project.");
await expect(page.getByRole("button", { name: "Create schedule" })).toBeDisabled();
});
});