LibreChat/e2e/specs/mock/schedules-execution.spec.ts

309 lines
12 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { getAccessToken, requestJson, replyPrompt, replyText } from './helpers';
const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
type AgentSummary = { id: string; name?: string };
type AgentList = { data?: AgentSummary[] };
type Schedule = {
id: string;
name: string;
nextRunAt?: string;
enabled?: boolean;
cadence?: { frequency: string; hour?: number; minute?: number };
lastRun?: { status: string; conversationId?: string };
runCount?: number;
};
type ScheduleList = { schedules: Schedule[] };
type RunNowResult = { scheduleId: string; conversationId?: string; status?: string };
async function ensureAgent(page: Page, token: string): Promise<AgentSummary> {
const list = await requestJson<AgentList>(page, { path: '/api/agents?limit=1', token }).catch(
() => ({}) as AgentList,
);
const existing = list.data?.[0];
if (existing?.id) {
return existing;
}
const agent = await requestJson<AgentSummary>(page, {
path: '/api/agents',
token,
method: 'POST',
body: { name: uniqueName('E2E Agent'), provider: 'Mock Provider A', model: 'mock-model-a' },
});
expect(agent.id).toBeTruthy();
return agent;
}
async function createSchedule(
page: Page,
token: string,
body: Record<string, unknown>,
): Promise<Schedule> {
const schedule = await requestJson<Schedule>(page, {
path: '/api/schedules',
token,
method: 'POST',
body,
});
expect(schedule.id).toBeTruthy();
return schedule;
}
async function readSchedule(page: Page, token: string, id: string): Promise<Schedule | undefined> {
const list = await requestJson<ScheduleList>(page, { path: '/api/schedules', token });
return list.schedules.find((s) => s.id === id);
}
async function openSchedulesPanel(page: Page) {
const navButton = page.getByRole('button', { name: 'Scheduled chats' });
await expect(navButton).toBeVisible();
if ((await navButton.getAttribute('aria-pressed')) !== 'true') {
await navButton.click();
}
const panel = page.getByRole('region', { name: 'Scheduled chats' });
await expect(panel).toBeVisible({ timeout: 15000 });
return panel;
}
const scheduleBody = (agentId: string, over: Record<string, unknown> = {}) => ({
name: uniqueName('E2E Schedule'),
prompt: 'Summarize what happened today',
agent_id: agentId,
cadence: { frequency: 'daily', hour: 8, minute: 0 },
timezone: 'America/New_York',
target: 'new',
enabled: true,
clientRequestId: uniqueName('e2e-intent'),
...over,
});
test.describe('scheduled chat execution', () => {
/**
* Run Now is the one path that dispatches a real generation on demand, so it is
* where a broken loopback URL, a rejected fire token, or a lost schedule identity
* surfaces. The smoke spec only proves the card renders.
*/
test('Run Now generates a conversation and records it on the schedule', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/c/new', { timeout: 15000 });
const token = await getAccessToken(page);
const agent = await ensureAgent(page, token);
const label = `sched-${Date.now()}`;
const schedule = await createSchedule(
page,
token,
scheduleBody(agent.id, { prompt: replyPrompt(label) }),
);
// A skip/throttle answers 409/429, which requestJson surfaces as a throw.
const result = await requestJson<RunNowResult>(page, {
path: `/api/schedules/${schedule.id}/run`,
token,
method: 'POST',
});
expect(result.status).toBe('started');
expect(result.conversationId).toBeTruthy();
// Poll the schedule until the run's own completion hook records its outcome.
await expect
.poll(async () => (await readSchedule(page, token, schedule.id))?.lastRun?.status, {
timeout: 60000,
intervals: [1000],
})
.toBe('success');
const settled = await readSchedule(page, token, schedule.id);
expect(settled?.runCount).toBe(1);
const conversationId = settled?.lastRun?.conversationId;
expect(conversationId).toBeTruthy();
// The generated chat is real and reachable: the agent's reply is persisted.
await page.goto(`/c/${conversationId}`, { timeout: 15000 });
await expect(page.getByTestId('messages-view')).toContainText(replyText(label), {
timeout: 20000,
});
});
/**
* A due schedule must fire on the engine's own tick — no user action — exactly once,
* and then advance past the occurrence. TICK_MS is 30s, so budget for one tick.
*/
test('a due schedule fires automatically, once, and advances', async ({ page }) => {
test.setTimeout(600000);
await page.goto('/c/new', { timeout: 15000 });
const token = await getAccessToken(page);
const agent = await ensureAgent(page, token);
const label = `auto-${Date.now()}`;
// Hourly ignores `hour` (cron `m * * * *`) but the payload schema still requires it.
const schedule = await createSchedule(
page,
token,
scheduleBody(agent.id, {
prompt: replyPrompt(label),
cadence: { frequency: 'hourly', hour: 0, minute: (new Date().getUTCMinutes() + 1) % 60 },
timezone: 'UTC',
}),
);
const before = await readSchedule(page, token, schedule.id);
expect(before?.nextRunAt).toBeTruthy();
// Budget from the server's OWN nextRunAt rather than a guessed constant: it already
// includes this schedule's deterministic jitter (up to SCHEDULE_JITTER_WINDOW_MS,
// 120s), which no fixed timeout can safely assume away. Add the engine tick
// (30s + 2s jitter) plus room for the generation.
const dueIn = Math.max(new Date(before!.nextRunAt!).getTime() - Date.now(), 0);
const budget = dueIn + 120000;
await expect
.poll(async () => (await readSchedule(page, token, schedule.id))?.lastRun?.status, {
timeout: budget,
intervals: [2000],
})
.toBe('success');
const after = await readSchedule(page, token, schedule.id);
// Exactly one run, and the occurrence was advanced rather than re-fired.
expect(after?.runCount).toBe(1);
expect(new Date(after!.nextRunAt!).getTime()).toBeGreaterThan(
new Date(before!.nextRunAt!).getTime(),
);
});
test('rejects an invalid timezone before persisting anything', async ({ page }) => {
await page.goto('/c/new', { timeout: 15000 });
const token = await getAccessToken(page);
const agent = await ensureAgent(page, token);
const before = await requestJson<ScheduleList>(page, { path: '/api/schedules', token });
const rejected = await requestJson<unknown>(page, {
path: '/api/schedules',
token,
method: 'POST',
body: scheduleBody(agent.id, { timezone: 'Not/AZone' }),
}).then(
() => null,
(err: Error) => err.message,
);
expect(rejected).toMatch(/400/);
const after = await requestJson<ScheduleList>(page, { path: '/api/schedules', token });
expect(after.schedules).toHaveLength(before.schedules.length);
});
/**
* Edits through the real dialog and proves the change round-trips the backend.
*
* Seeded over the API rather than created through the UI on purpose: creation
* requires the agent picker, whose list comes from a React Query cache that an
* API-created agent does not invalidate, and which renders virtualized. That made
* the create half brittle for reasons that have nothing to do with schedules. UI
* CREATION is therefore still uncovered — worth a follow-up that seeds the agent
* before first paint.
*/
test('edits a schedule through the UI with the cadence persisted', async ({ page }) => {
test.setTimeout(120000);
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/c/new', { timeout: 15000 });
const token = await getAccessToken(page);
const agent = await ensureAgent(page, token);
const name = uniqueName('UI Schedule');
await createSchedule(
page,
token,
scheduleBody(agent.id, { name, cadence: { frequency: 'weekly', hour: 8, minute: 0 } }),
);
await openSchedulesPanel(page);
const persisted = page.getByTestId('schedule-card').filter({ hasText: name });
await expect(persisted).toContainText(/Runs weekly/i, { timeout: 15000 });
// EDIT through the dialog: rename and move the cadence to Custom. The dialog
// pre-populates the agent from the schedule, so no picker interaction is needed.
await persisted.getByRole('button', { name: 'Schedule options' }).click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
const editDialog = page.getByRole('dialog');
const renamed = `${name} edited`;
await editDialog.locator('#schedule-name').fill(renamed);
await editDialog.getByRole('radio', { name: 'Custom' }).click();
await editDialog.getByTestId('schedule-cron-input').fill('0 9 * * *');
// Custom adds a hint and validation message. On a 720px viewport, the dialog
// itself must scroll so the footer remains reachable.
const save = editDialog.getByRole('button', { name: 'Save' });
await save.scrollIntoViewIfNeeded();
await expect(save).toBeInViewport();
await save.click();
await page.reload();
await openSchedulesPanel(page);
const edited = page.getByTestId('schedule-card').filter({ hasText: renamed });
await expect(edited).toBeVisible({ timeout: 15000 });
await expect(edited).toContainText(/Runs on cron 0 9 \* \* \*/i);
});
/**
* Deleting a schedule mid-run must quiesce it: the in-flight generation is aborted and
* the run settles, rather than the row lingering `started` and holding a global
* capacity slot until the orphan sweep.
*/
test('deleting a schedule while its run is active aborts the generation', async ({ page }) => {
test.setTimeout(180000);
await page.goto('/c/new', { timeout: 15000 });
const token = await getAccessToken(page);
const agent = await ensureAgent(page, token);
const schedule = await createSchedule(
page,
token,
// A slow reply keeps the generation in flight long enough to delete underneath it.
scheduleBody(agent.id, { prompt: `E2E_SLOW_REPLY:del-${Date.now()}` }),
);
const started = await requestJson<RunNowResult>(page, {
path: `/api/schedules/${schedule.id}/run`,
token,
method: 'POST',
});
const conversationId = started.conversationId!;
expect(conversationId).toBeTruthy();
// PROVE the run is actually generating before deleting. The schedule row is hidden
// from the owner the instant it is soft-deleted, so its disappearance is no evidence
// that the abort was delivered, the run settled, or the row was erased.
await expect
.poll(
async () =>
(
await requestJson<{ active?: boolean }>(page, {
path: `/api/agents/chat/status/${conversationId}`,
token,
})
).active,
{ timeout: 60000, intervals: [500] },
)
.toBe(true);
await requestJson<unknown>(page, {
path: `/api/schedules/${schedule.id}`,
token,
method: 'DELETE',
});
// The delete has to reach the loopback generation, not just hide the row.
await expect
.poll(
async () =>
(
await requestJson<{ active?: boolean }>(page, {
path: `/api/agents/chat/status/${conversationId}`,
token,
})
).active,
{ timeout: 60000, intervals: [1000] },
)
.toBe(false);
expect(await readSchedule(page, token, schedule.id)).toBeUndefined();
});
});