f9d64b9a9d
- Shared per-job trigger controller (apps/shared) coalesces duplicate clicks for the same profile+job inside a mounted client while letting unrelated jobs run independently; the backend durable claim remains authoritative across windows/processes. - Two-phase feedback everywhere: the action stays disabled/spinning while the request is in flight and the terminal success/error is reported once, after the HTTP response — no premature success toast (Web), matching the Desktop info notification. - Desktop keeps the 24h trigger timeout for the synchronous long operation and fences stale profile/list responses and unmounted surfaces; the sidebar trigger button shows a spinner while busy.
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createCronTriggerController } from "@hermes/shared";
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
describe("createCronTriggerController", () => {
|
|
it("announces immediately and coalesces the same job while it is running", async () => {
|
|
const request = deferred<string>();
|
|
const order: string[] = [];
|
|
const action = vi.fn(() => {
|
|
order.push("action");
|
|
return request.promise;
|
|
});
|
|
const onStarted = vi.fn(() => order.push("started"));
|
|
const onRunningChange = vi.fn();
|
|
const controller = createCronTriggerController(onRunningChange);
|
|
|
|
const first = controller.run("profile-a:job-1", action, onStarted);
|
|
const duplicate = await controller.run("profile-a:job-1", action, onStarted);
|
|
|
|
expect(action).toHaveBeenCalledTimes(1);
|
|
expect(onStarted).toHaveBeenCalledTimes(1);
|
|
expect(order).toEqual(["started", "action"]);
|
|
expect(onRunningChange).toHaveBeenNthCalledWith(1, "profile-a:job-1", true);
|
|
expect(duplicate).toEqual({ started: false, value: null });
|
|
|
|
request.resolve("done");
|
|
await expect(first).resolves.toEqual({ started: true, value: "done" });
|
|
expect(onRunningChange).toHaveBeenLastCalledWith("profile-a:job-1", false);
|
|
});
|
|
|
|
it("releases the job after failure so a retry can start", async () => {
|
|
const request = deferred<void>();
|
|
const controller = createCronTriggerController();
|
|
|
|
const failed = controller.run("job-1", () => request.promise);
|
|
request.reject(new Error("failed"));
|
|
|
|
await expect(failed).rejects.toThrow("failed");
|
|
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
|
|
started: true,
|
|
value: "retried",
|
|
});
|
|
});
|
|
|
|
it("releases the job when the immediate feedback callback fails", async () => {
|
|
const controller = createCronTriggerController();
|
|
|
|
await expect(
|
|
controller.run("job-1", async () => "not-called", () => {
|
|
throw new Error("toast failed");
|
|
}),
|
|
).rejects.toThrow("toast failed");
|
|
|
|
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
|
|
started: true,
|
|
value: "retried",
|
|
});
|
|
});
|
|
|
|
it("allows the same job id in different profiles to run concurrently", async () => {
|
|
const defaultRequest = deferred<string>();
|
|
const workRequest = deferred<string>();
|
|
const defaultAction = vi.fn(() => defaultRequest.promise);
|
|
const workAction = vi.fn(() => workRequest.promise);
|
|
const controller = createCronTriggerController();
|
|
|
|
const defaultRun = controller.run("default:job-1", defaultAction);
|
|
const workRun = controller.run("work:job-1", workAction);
|
|
|
|
expect(defaultAction).toHaveBeenCalledTimes(1);
|
|
expect(workAction).toHaveBeenCalledTimes(1);
|
|
|
|
defaultRequest.resolve("default");
|
|
workRequest.resolve("work");
|
|
|
|
await expect(defaultRun).resolves.toEqual({ started: true, value: "default" });
|
|
await expect(workRun).resolves.toEqual({ started: true, value: "work" });
|
|
});
|
|
|
|
it("releases the job when the running-state callback fails", async () => {
|
|
const controller = createCronTriggerController((_key, running) => {
|
|
if (running) throw new Error("state callback failed");
|
|
});
|
|
|
|
await expect(controller.run("job-1", async () => "not-called")).rejects.toThrow(
|
|
"state callback failed",
|
|
);
|
|
expect(controller.isRunning("job-1")).toBe(false);
|
|
});
|
|
|
|
it("releases the job before the stopped-state callback runs", async () => {
|
|
const action = vi.fn(async () => "done");
|
|
const controller = createCronTriggerController((_key, running) => {
|
|
if (!running) throw new Error("stopped callback failed");
|
|
});
|
|
|
|
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
|
|
expect(controller.isRunning("job-1")).toBe(false);
|
|
|
|
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
|
|
expect(action).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|