fix(ui): replay pending cron filter reloads

This commit is contained in:
Vincent Koc
2026-05-29 02:06:24 +02:00
parent 27d1c08c51
commit 8bd4736f03
7 changed files with 245 additions and 7 deletions
+23 -1
View File
@@ -10,6 +10,23 @@ import { PROTOCOL_VERSION } from "../../../src/gateway/protocol/version.js";
const require = createRequire(import.meta.url);
const json5EsmPath = require.resolve("json5/dist/index.mjs");
const commonJsOptimizeDeps = [
"highlight.js/lib/core",
"highlight.js/lib/languages/bash",
"highlight.js/lib/languages/cpp",
"highlight.js/lib/languages/css",
"highlight.js/lib/languages/diff",
"highlight.js/lib/languages/go",
"highlight.js/lib/languages/java",
"highlight.js/lib/languages/javascript",
"highlight.js/lib/languages/json",
"highlight.js/lib/languages/markdown",
"highlight.js/lib/languages/python",
"highlight.js/lib/languages/rust",
"highlight.js/lib/languages/typescript",
"highlight.js/lib/languages/xml",
"highlight.js/lib/languages/yaml",
] as const;
export type MockGatewayRequest = {
id: string;
@@ -82,7 +99,12 @@ export async function startControlUiE2eServer(): Promise<ControlUiE2eServer> {
},
logLevel: "error",
optimizeDeps: {
include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"],
include: [
"ipaddr.js",
"lit/directives/repeat.js",
"markdown-it-task-lists",
...commonJsOptimizeDeps,
],
},
publicDir: path.join(uiRoot, "public"),
resolve: {
+2
View File
@@ -358,6 +358,8 @@ export type AppViewState = {
| "cronQuickCreateStep"
| "cronQuickCreateDraft"
| "cronJobsLoadingMore"
| "cronJobsReloadPending"
| "cronJobsReloadPendingTableFilters"
| "cronJobs"
| "cronJobsTotal"
| "cronJobsHasMore"
+2
View File
@@ -522,6 +522,8 @@ export class OpenClawApp extends LitElement {
| import("./views/cron-quick-create.ts").CronQuickCreateDraft
| null = null;
@state() cronJobsLoadingMore = false;
cronJobsReloadPending = false;
cronJobsReloadPendingTableFilters = false;
@state() cronJobs: CronJob[] = [];
@state() cronJobsTotal = 0;
@state() cronJobsHasMore = false;
+135
View File
@@ -11,6 +11,7 @@ import {
runCronJob,
startCronEdit,
startCronClone,
updateCronJobsFilter,
validateCronForm,
type CronState,
} from "./cron.ts";
@@ -24,6 +25,8 @@ function createState(overrides: Partial<CronState> = {}): CronState {
cronQuickCreateStep: "what",
cronQuickCreateDraft: null,
cronJobsLoadingMore: false,
cronJobsReloadPending: false,
cronJobsReloadPendingTableFilters: false,
cronJobs: [],
cronJobsTotal: 0,
cronJobsHasMore: false,
@@ -99,6 +102,13 @@ function requestPatch(call: readonly [method: string, payload?: unknown]) {
return requireRecord(requestPayload(call).patch, `${call[0]} patch`);
}
type EmptyCronListResponse = {
jobs: [];
total: number;
hasMore: boolean;
nextOffset: null;
};
describe("cron controller", () => {
it("loads model suggestions from the configured model view", async () => {
const request = vi.fn(async () => ({
@@ -1323,6 +1333,131 @@ describe("cron controller", () => {
);
});
it("reloads cron jobs after filters change during an in-flight table load", async () => {
let resolveFirst!: (value: EmptyCronListResponse) => void;
const firstResponse = new Promise<EmptyCronListResponse>((resolve) => {
resolveFirst = resolve;
});
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method !== "cron.list") {
return {};
}
if (request.mock.calls.length === 1) {
return firstResponse;
}
expectRecordFields(requireRecord(payload, "pending cron.list payload"), {
scheduleKind: "cron",
lastRunStatus: "unknown",
});
return { jobs: [], total: 0, hasMore: false, nextOffset: null };
});
const state = createState({
client: { request } as unknown as CronState["client"],
});
const firstLoad = loadCronJobsPage(state, { tableFilters: true });
updateCronJobsFilter(state, {
cronJobsScheduleKindFilter: "cron",
cronJobsLastStatusFilter: "unknown",
});
await loadCronJobsPage(state, { tableFilters: true });
resolveFirst({ jobs: [], total: 0, hasMore: false, nextOffset: null });
await firstLoad;
expect(request).toHaveBeenCalledTimes(2);
expect(state.cronJobsReloadPending).toBe(false);
expect(state.cronJobsReloadPendingTableFilters).toBe(false);
});
it("reloads cron jobs after filters change during an in-flight append load", async () => {
let resolveAppend!: (value: EmptyCronListResponse) => void;
const appendResponse = new Promise<EmptyCronListResponse>((resolve) => {
resolveAppend = resolve;
});
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method !== "cron.list") {
return {};
}
if (request.mock.calls.length === 1) {
expectRecordFields(requireRecord(payload, "append cron.list payload"), {
offset: 1,
});
return appendResponse;
}
expectRecordFields(requireRecord(payload, "pending append cron.list payload"), {
offset: 0,
scheduleKind: "cron",
lastRunStatus: "unknown",
});
return { jobs: [], total: 0, hasMore: false, nextOffset: null };
});
const state = createState({
client: { request } as unknown as CronState["client"],
cronJobs: [
{
id: "existing",
name: "Existing",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "ping" },
},
],
cronJobsHasMore: true,
cronJobsNextOffset: 1,
});
const appendLoad = loadCronJobsPage(state, { append: true, tableFilters: true });
updateCronJobsFilter(state, {
cronJobsScheduleKindFilter: "cron",
cronJobsLastStatusFilter: "unknown",
});
await loadCronJobsPage(state, { tableFilters: true });
resolveAppend({ jobs: [], total: 0, hasMore: false, nextOffset: null });
await appendLoad;
expect(request).toHaveBeenCalledTimes(2);
expect(state.cronJobsReloadPending).toBe(false);
expect(state.cronJobsReloadPendingTableFilters).toBe(false);
});
it("uses the latest queued cron jobs table-filter mode", async () => {
let resolveFirst!: (value: EmptyCronListResponse) => void;
const firstResponse = new Promise<EmptyCronListResponse>((resolve) => {
resolveFirst = resolve;
});
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method !== "cron.list") {
return {};
}
if (request.mock.calls.length === 1) {
return firstResponse;
}
const pendingPayload = requireRecord(payload, "latest pending cron.list payload");
expect(pendingPayload).not.toHaveProperty("scheduleKind");
expect(pendingPayload).not.toHaveProperty("lastRunStatus");
return { jobs: [], total: 0, hasMore: false, nextOffset: null };
});
const state = createState({
client: { request } as unknown as CronState["client"],
cronJobsScheduleKindFilter: "cron",
cronJobsLastStatusFilter: "unknown",
});
const firstLoad = loadCronJobsPage(state);
await loadCronJobsPage(state, { tableFilters: true });
await loadCronJobsPage(state);
resolveFirst({ jobs: [], total: 0, hasMore: false, nextOffset: null });
await firstLoad;
expect(request).toHaveBeenCalledTimes(2);
expect(state.cronJobsReloadPending).toBe(false);
expect(state.cronJobsReloadPendingTableFilters).toBe(false);
});
it("drops malformed cron jobs before they enter UI state", async () => {
const request = vi.fn(async (method: string) => {
if (method === "cron.list") {
+18 -1
View File
@@ -55,6 +55,8 @@ export type CronState = {
cronQuickCreateStep: import("../views/cron-quick-create.ts").CronQuickCreateStep;
cronQuickCreateDraft: import("../views/cron-quick-create.ts").CronQuickCreateDraft | null;
cronJobsLoadingMore: boolean;
cronJobsReloadPending: boolean;
cronJobsReloadPendingTableFilters: boolean;
cronJobs: CronJob[];
cronJobsTotal: number;
cronJobsHasMore: boolean;
@@ -276,6 +278,16 @@ function normalizeCronPageMeta(params: {
return { total, hasMore, nextOffset };
}
async function drainPendingCronJobsReload(state: CronState) {
if (!state.cronJobsReloadPending) {
return;
}
const tableFilters = state.cronJobsReloadPendingTableFilters;
state.cronJobsReloadPending = false;
state.cronJobsReloadPendingTableFilters = false;
await loadCronJobsPage(state, { tableFilters });
}
export async function loadCronJobsPage(
state: CronState,
opts?: { append?: boolean; tableFilters?: boolean },
@@ -283,10 +295,14 @@ export async function loadCronJobsPage(
if (!state.client || !state.connected) {
return;
}
const append = opts?.append === true;
if (state.cronLoading || state.cronJobsLoadingMore) {
if (!append) {
state.cronJobsReloadPending = true;
state.cronJobsReloadPendingTableFilters = opts?.tableFilters === true;
}
return;
}
const append = opts?.append === true;
if (append && !state.cronJobsHasMore) {
return;
}
@@ -340,6 +356,7 @@ export async function loadCronJobsPage(
} else {
state.cronLoading = false;
}
await drainPendingCronJobsReload(state);
}
}
+42 -4
View File
@@ -71,10 +71,43 @@ async function waitForCronListRequest(
throw new Error(`No matching cron.list request found: ${JSON.stringify(requests)}`);
}
type PageDiagnostics = {
consoleMessages: string[];
pageErrors: string[];
};
function jobTitle(page: Page, name: string) {
return page.locator(".cron-job .list-title", { hasText: new RegExp(`^${name}$`, "u") });
}
async function waitForJobTitle(
page: Page,
gateway: MockGatewayControls,
diagnostics: PageDiagnostics,
name: string,
) {
try {
await jobTitle(page, name).waitFor({ timeout: 10_000 });
} catch (err) {
const requests = await gateway.getRequests();
const bodyText = await page.locator("body").textContent({ timeout: 1_000 }).catch(String);
const content = await page.content().catch(String);
throw new Error(
[
`Timed out waiting for cron job title: ${name}`,
`URL: ${page.url()}`,
`Gateway requests: ${JSON.stringify(requests)}`,
`Page errors: ${JSON.stringify(diagnostics.pageErrors)}`,
`Console: ${JSON.stringify(diagnostics.consoleMessages)}`,
`Page text: ${bodyText}`,
`Page content: ${content.slice(0, 1000)}`,
`Original error: ${String(err)}`,
].join("\n"),
{ cause: err },
);
}
}
describeControlUiE2e("Control UI cron mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
@@ -111,6 +144,10 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => {
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const pageErrors: string[] = [];
const consoleMessages: string[] = [];
page.on("pageerror", (err) => pageErrors.push(String(err)));
page.on("console", (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`));
const gateway = await installMockGateway(page, {
methodResponses: {
"cron.list": {
@@ -143,9 +180,10 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => {
});
try {
await page.goto(`${server.baseUrl}cron`);
await jobTitle(page, "Digest every minute").waitFor({ timeout: 10_000 });
await jobTitle(page, "Nightly cron pending").waitFor({ timeout: 10_000 });
const response = await page.goto(`${server.baseUrl}cron`);
expect(response?.status()).toBe(200);
await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Digest every minute");
await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Nightly cron pending");
const initialRequest = await waitForCronListRequest(
gateway,
@@ -180,7 +218,7 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => {
sortBy: "nextRunAtMs",
sortDir: "asc",
});
await jobTitle(page, "Nightly cron pending").waitFor({ timeout: 10_000 });
await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Nightly cron pending");
await expect.poll(async () => jobTitle(page, "Digest every minute").count()).toBe(0);
} finally {
await context.close();
+23 -1
View File
@@ -11,6 +11,23 @@ const repoRoot = path.resolve(here, "..");
const outDir = path.resolve(here, "../dist/control-ui");
const require = createRequire(import.meta.url);
const json5EsmPath = require.resolve("json5/dist/index.mjs");
const commonJsOptimizeDeps = [
"highlight.js/lib/core",
"highlight.js/lib/languages/bash",
"highlight.js/lib/languages/cpp",
"highlight.js/lib/languages/css",
"highlight.js/lib/languages/diff",
"highlight.js/lib/languages/go",
"highlight.js/lib/languages/java",
"highlight.js/lib/languages/javascript",
"highlight.js/lib/languages/json",
"highlight.js/lib/languages/markdown",
"highlight.js/lib/languages/python",
"highlight.js/lib/languages/rust",
"highlight.js/lib/languages/typescript",
"highlight.js/lib/languages/xml",
"highlight.js/lib/languages/yaml",
] as const;
function normalizeBase(input: string): string {
const trimmed = input.trim();
@@ -96,7 +113,12 @@ export default defineConfig(() => {
},
publicDir: path.resolve(here, "public"),
optimizeDeps: {
include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"],
include: [
"ipaddr.js",
"lit/directives/repeat.js",
"markdown-it-task-lists",
...commonJsOptimizeDeps,
],
},
resolve: {
alias: {