mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): replay pending cron filter reloads
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -358,6 +358,8 @@ export type AppViewState = {
|
||||
| "cronQuickCreateStep"
|
||||
| "cronQuickCreateDraft"
|
||||
| "cronJobsLoadingMore"
|
||||
| "cronJobsReloadPending"
|
||||
| "cronJobsReloadPendingTableFilters"
|
||||
| "cronJobs"
|
||||
| "cronJobsTotal"
|
||||
| "cronJobsHasMore"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user